fix: 유산 유니크 경매 기본 풀과 제한을 복구
Ref 기본 유니크 풀을 상태 조회·개설·마감에서 공유하고 API와 데몬 양쪽에서 입찰 부위 제한을 재검증한다. 실제 Chromium 및 개설·경쟁 입찰·마감 통합 회귀를 추가한다.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
|
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import {
|
import {
|
||||||
appendInheritanceLog,
|
appendInheritanceLog,
|
||||||
buildResetCost,
|
buildResetCost,
|
||||||
@@ -74,17 +75,17 @@ const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): numb
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
|
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
|
||||||
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
|
const configConst = asRecord(asRecord(worldState.config).const);
|
||||||
|
const loader = new ItemLoader();
|
||||||
|
const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader);
|
||||||
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
|
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
|
||||||
for (const entries of Object.values(asRecord(configuredItems))) {
|
for (const entries of Object.values(allItems)) {
|
||||||
for (const [key, amount] of Object.entries(asRecord(entries))) {
|
for (const [key, amount] of Object.entries(asRecord(entries))) {
|
||||||
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
|
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
|
||||||
enabledKeys.push(key);
|
enabledKeys.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loader = new ItemLoader();
|
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
[...new Set(enabledKeys)].map(async (key) => {
|
[...new Set(enabledKeys)].map(async (key) => {
|
||||||
const item = await loader.load(key);
|
const item = await loader.load(key);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const buildContext = (options: {
|
|||||||
target?: GeneralRow | null;
|
target?: GeneralRow | null;
|
||||||
inheritancePoint?: number;
|
inheritancePoint?: number;
|
||||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||||
|
configConst?: Record<string, unknown>;
|
||||||
}) => {
|
}) => {
|
||||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
@@ -113,10 +114,19 @@ const buildContext = (options: {
|
|||||||
const logCreate = vi.fn(async () => ({}));
|
const logCreate = vi.fn(async () => ({}));
|
||||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||||
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
||||||
|
const activeWorldState =
|
||||||
|
options.configConst === undefined
|
||||||
|
? worldState
|
||||||
|
: {
|
||||||
|
...worldState,
|
||||||
|
config: {
|
||||||
|
const: options.configConst,
|
||||||
|
},
|
||||||
|
};
|
||||||
const db = {
|
const db = {
|
||||||
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => worldState),
|
findFirst: vi.fn(async () => activeWorldState),
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
@@ -192,6 +202,22 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([{}, { allItems: '{}' }])(
|
||||||
|
'restores selectable Ref default uniques for a legacy scenario config: %j',
|
||||||
|
async (configConst) => {
|
||||||
|
const fixture = buildContext({ configConst });
|
||||||
|
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
|
||||||
|
|
||||||
|
expect(status.availableUnique.length).toBeGreaterThan(80);
|
||||||
|
expect(status.availableUnique).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
|
||||||
|
expect.objectContaining({ key: 'che_서적_07_논어', rawName: '논어' }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('loads the first inheritance-log page without an out-of-range integer cursor', async () => {
|
it('loads the first inheritance-log page without an out-of-range integer cursor', async () => {
|
||||||
const createdAt = new Date('2026-07-26T00:00:00Z');
|
const createdAt = new Date('2026-07-26T00:00:00Z');
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||||
@@ -23,6 +24,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
|||||||
interface AuctionRow {
|
interface AuctionRow {
|
||||||
id: number;
|
id: number;
|
||||||
type: AuctionType;
|
type: AuctionType;
|
||||||
|
targetCode: string | null;
|
||||||
hostGeneralId: number;
|
hostGeneralId: number;
|
||||||
detail: unknown;
|
detail: unknown;
|
||||||
status: AuctionStatus;
|
status: AuctionStatus;
|
||||||
@@ -99,6 +101,7 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
SELECT id,
|
SELECT id,
|
||||||
type,
|
type,
|
||||||
|
target_code as "targetCode",
|
||||||
host_general_id as "hostGeneralId",
|
host_general_id as "hostGeneralId",
|
||||||
detail,
|
detail,
|
||||||
status,
|
status,
|
||||||
@@ -178,6 +181,7 @@ export const createAuctionBidder = async (options: {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
const prisma = connector.prisma;
|
const prisma = connector.prisma;
|
||||||
const world = options.world;
|
const world = options.world;
|
||||||
|
const itemLoader = new ItemLoader();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bid: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
|
bid: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||||
@@ -290,6 +294,74 @@ export const createAuctionBidder = async (options: {
|
|||||||
reason: '장수 정보를 찾을 수 없습니다.',
|
reason: '장수 정보를 찾을 수 없습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (auction.type === 'UNIQUE_ITEM') {
|
||||||
|
const itemKey = auction.targetCode;
|
||||||
|
if (!itemKey || !isItemKey(itemKey)) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '아이템이 올바르지 않습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const item = await itemLoader.load(itemKey).catch(() => null);
|
||||||
|
if (!item || item.buyable) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: item ? '구매할 수 있는 아이템입니다.' : '아이템 정보를 불러올 수 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSlotItem = general.role.items[item.slot];
|
||||||
|
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
|
||||||
|
const currentItem = await itemLoader.load(currentSlotItem).catch(() => null);
|
||||||
|
if (currentItem && !currentItem.buyable) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason:
|
||||||
|
currentSlotItem === itemKey
|
||||||
|
? '이미 그 유니크를 가지고 있습니다.'
|
||||||
|
: '이미 다른 유니크를 가지고 있습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherHighestBids = await db.$queryRaw<Array<{ auctionId: number; targetCode: string | null }>>(
|
||||||
|
GamePrisma.sql`
|
||||||
|
SELECT candidate.id as "auctionId", candidate.target_code as "targetCode"
|
||||||
|
FROM auction candidate
|
||||||
|
INNER JOIN LATERAL (
|
||||||
|
SELECT bid.general_id
|
||||||
|
FROM auction_bid bid
|
||||||
|
WHERE bid.auction_id = candidate.id
|
||||||
|
ORDER BY bid.amount DESC, bid.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) highest ON true
|
||||||
|
WHERE candidate.type = 'UNIQUE_ITEM'
|
||||||
|
AND candidate.status IN ('OPEN', 'FINALIZING')
|
||||||
|
AND candidate.id <> ${auction.id}
|
||||||
|
AND highest.general_id = ${command.generalId}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
for (const other of otherHighestBids) {
|
||||||
|
if (!other.targetCode || !isItemKey(other.targetCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const otherItem = await itemLoader.load(other.targetCode).catch(() => null);
|
||||||
|
if (otherItem?.slot === item.slot) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) {
|
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) {
|
||||||
return {
|
return {
|
||||||
type: 'auctionBid',
|
type: 'auctionBid',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||||
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic';
|
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
|
||||||
|
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
|
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
|
||||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
|
|||||||
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
||||||
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||||
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
|
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
|
||||||
|
const COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT = 24;
|
||||||
|
const MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY = 5;
|
||||||
|
|
||||||
interface AuctionRow {
|
interface AuctionRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -363,7 +366,10 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const));
|
const config = await resolveLegacyCompatibleUniqueConfig(
|
||||||
|
asRecord(world.getScenarioConfig().const),
|
||||||
|
itemLoader
|
||||||
|
);
|
||||||
const scenarioMeta = asRecord(state.meta.scenarioMeta);
|
const scenarioMeta = asRecord(state.meta.scenarioMeta);
|
||||||
const startYear =
|
const startYear =
|
||||||
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
|
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
|
||||||
@@ -392,7 +398,11 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
const turnMinutes = await resolveTurnMinutes(db);
|
const turnMinutes = await resolveTurnMinutes(db);
|
||||||
const nextCloseAt = new Date(
|
const nextCloseAt = new Date(
|
||||||
auction.closeAt.getTime() +
|
auction.closeAt.getTime() +
|
||||||
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
|
Math.max(
|
||||||
|
MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY,
|
||||||
|
turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT
|
||||||
|
) *
|
||||||
|
60_000
|
||||||
);
|
);
|
||||||
const nextLatestBidCloseAt = new Date(
|
const nextLatestBidCloseAt = new Date(
|
||||||
nextCloseAt.getTime() +
|
nextCloseAt.getTime() +
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|||||||
|
|
||||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||||
import {
|
import { ActionLogger, ItemLoader, LogFormat, buildAuctionAlias, isItemKey } from '@sammo-ts/logic';
|
||||||
ActionLogger,
|
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
ItemLoader,
|
|
||||||
LogFormat,
|
|
||||||
buildAuctionAlias,
|
|
||||||
isItemKey,
|
|
||||||
resolveUniqueConfig,
|
|
||||||
} from '@sammo-ts/logic';
|
|
||||||
|
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||||
@@ -147,7 +141,8 @@ const openUniqueAuction = async (
|
|||||||
return fail(`최소 경매 금액은 ${minimumPoint}입니다.`);
|
return fail(`최소 경매 금액은 ${minimumPoint}입니다.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await new ItemLoader().load(itemKey).catch(() => null);
|
const itemLoader = new ItemLoader();
|
||||||
|
const item = await itemLoader.load(itemKey).catch(() => null);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return fail('아이템 정보를 불러올 수 없습니다.');
|
return fail('아이템 정보를 불러올 수 없습니다.');
|
||||||
}
|
}
|
||||||
@@ -156,7 +151,7 @@ const openUniqueAuction = async (
|
|||||||
}
|
}
|
||||||
const currentSlotItem = general.role.items[item.slot];
|
const currentSlotItem = general.role.items[item.slot];
|
||||||
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
|
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
|
||||||
const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null);
|
const currentItem = await itemLoader.load(currentSlotItem).catch(() => null);
|
||||||
if (currentItem && !currentItem.buyable) {
|
if (currentItem && !currentItem.buyable) {
|
||||||
return fail('이미 가진 아이템이 있습니다.');
|
return fail('이미 가진 아이템이 있습니다.');
|
||||||
}
|
}
|
||||||
@@ -189,7 +184,7 @@ const openUniqueAuction = async (
|
|||||||
return fail('아직 경매가 끝나지 않았습니다.');
|
return fail('아직 경매가 끝나지 않았습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueConfig = resolveUniqueConfig(configConst);
|
const uniqueConfig = await resolveLegacyCompatibleUniqueConfig(configConst, itemLoader);
|
||||||
const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0;
|
const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0;
|
||||||
const occupiedAmount = world
|
const occupiedAmount = world
|
||||||
.listGenerals()
|
.listGenerals()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatServerDateTime } from '@sammo-ts/common';
|
import { formatServerDateTime, JosaUtil } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -379,7 +379,8 @@ const buyRandomUnique = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openUniqueAuction = async () => {
|
const openUniqueAuction = async () => {
|
||||||
if (!uniqueForm.itemId.trim()) {
|
const selectedItem = status.value?.availableUnique.find((item) => item.key === uniqueForm.itemId.trim());
|
||||||
|
if (!selectedItem) {
|
||||||
actionError.value = '유니크를 선택해주세요.';
|
actionError.value = '유니크를 선택해주세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -388,15 +389,20 @@ const openUniqueAuction = async () => {
|
|||||||
actionError.value = '입찰 포인트를 입력해주세요.';
|
actionError.value = '입찰 포인트를 입력해주세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`유니크 경매를 ${amount} 포인트로 신청하시겠습니까?`)) {
|
if (previousPoint.value < amount) {
|
||||||
|
actionError.value = '유산 포인트가 부족합니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const itemJosa = JosaUtil.pick(selectedItem.rawName, '을');
|
||||||
|
if (!window.confirm(`${amount} 포인트로 ${selectedItem.name}${itemJosa} 입찰하겠습니까?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await trpc.inherit.openUniqueAuction.mutate({
|
await trpc.inherit.openUniqueAuction.mutate({
|
||||||
itemId: uniqueForm.itemId.trim(),
|
itemId: selectedItem.key,
|
||||||
amount,
|
amount,
|
||||||
});
|
});
|
||||||
});
|
}, '성공했습니다. 경매장을 확인해주세요.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkOwner = async () => {
|
const checkOwner = async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { createItemModuleRegistry, ItemLoader, ITEM_KEYS, loadItemModules } from '@sammo-ts/logic/items/index.js';
|
||||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||||
import type { UniqueItemPool } from './uniqueLottery.js';
|
import { resolveUniqueConfig, type UniqueItemPool, type UniqueLotteryConfig } from './uniqueLottery.js';
|
||||||
|
|
||||||
const LEGACY_UNIQUE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly string[]>> = {
|
const LEGACY_UNIQUE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly string[]>> = {
|
||||||
horse: [
|
horse: [
|
||||||
@@ -125,3 +126,38 @@ export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map<string, ItemM
|
|||||||
}
|
}
|
||||||
return pool;
|
return pool;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let legacyDefaultUniqueItemPoolPromise: Promise<UniqueItemPool> | null = null;
|
||||||
|
|
||||||
|
const cloneUniqueItemPool = (pool: UniqueItemPool): UniqueItemPool =>
|
||||||
|
Object.fromEntries(Object.entries(pool).map(([slot, entries]) => [slot, { ...entries }]));
|
||||||
|
|
||||||
|
export const loadLegacyDefaultUniqueItemPool = async (loader?: ItemLoader): Promise<UniqueItemPool> => {
|
||||||
|
if (loader) {
|
||||||
|
const modules = await loadItemModules([...ITEM_KEYS], loader);
|
||||||
|
return buildLegacyDefaultUniqueItemPool(createItemModuleRegistry(modules));
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyDefaultUniqueItemPoolPromise ??= loadItemModules([...ITEM_KEYS], new ItemLoader()).then((modules) =>
|
||||||
|
buildLegacyDefaultUniqueItemPool(createItemModuleRegistry(modules))
|
||||||
|
);
|
||||||
|
return cloneUniqueItemPool(await legacyDefaultUniqueItemPoolPromise);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref의 GameConst 기본값은 시나리오가 allItems를 덮어쓰지 않아도 항상 존재합니다.
|
||||||
|
* 오래된 Core snapshot의 생략값/문자열 빈 객체도 같은 기본 풀로 해석합니다.
|
||||||
|
*/
|
||||||
|
export const resolveLegacyCompatibleUniqueConfig = async (
|
||||||
|
configConst: Record<string, unknown>,
|
||||||
|
loader?: ItemLoader
|
||||||
|
): Promise<UniqueLotteryConfig> => {
|
||||||
|
const config = resolveUniqueConfig(configConst);
|
||||||
|
if (Object.keys(config.allItems).length > 0) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
allItems: await loadLegacyDefaultUniqueItemPool(loader),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveLegacyCompatibleUniqueConfig } from '../src/rewards/legacyUniqueItemPool.js';
|
||||||
|
|
||||||
|
describe('legacy-compatible unique item pool', () => {
|
||||||
|
it.each([undefined, {}, '{}'] as const)('restores the Ref default pool when allItems is %j', async (allItems) => {
|
||||||
|
const config = await resolveLegacyCompatibleUniqueConfig(allItems === undefined ? {} : { allItems });
|
||||||
|
|
||||||
|
expect(Object.keys(config.allItems)).toEqual(['horse', 'weapon', 'book', 'item']);
|
||||||
|
expect(config.allItems.weapon?.che_무기_12_칠성검).toBe(2);
|
||||||
|
expect(config.allItems.item?.che_의술_청낭서).toBe(1);
|
||||||
|
expect(config.allItems.weapon?.che_무기_01_단도).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an explicit scenario pool, including its counts', async () => {
|
||||||
|
const config = await resolveLegacyCompatibleUniqueConfig({
|
||||||
|
allItems: {
|
||||||
|
weapon: {
|
||||||
|
che_무기_12_칠성검: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.allItems).toEqual({
|
||||||
|
weapon: {
|
||||||
|
che_무기_12_칠성검: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,12 @@ const statusFixture = {
|
|||||||
rawName: '칠성검',
|
rawName: '칠성검',
|
||||||
info: '무력을 올려주는 유니크 무기입니다.',
|
info: '무력을 올려주는 유니크 무기입니다.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'che_서적_07_논어',
|
||||||
|
name: '논어(+7)',
|
||||||
|
rawName: '논어',
|
||||||
|
info: '지력을 올려주는 유니크 서적입니다.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
availableTargetGenerals: [{ id: 8, name: '조조' }],
|
availableTargetGenerals: [{ id: 8, name: '조조' }],
|
||||||
turnTimeZones: ['00:00'],
|
turnTimeZones: ['00:00'],
|
||||||
@@ -98,6 +104,7 @@ const statusFixture = {
|
|||||||
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
||||||
let buffMutationCount = 0;
|
let buffMutationCount = 0;
|
||||||
let resetTurnMutationCount = 0;
|
let resetTurnMutationCount = 0;
|
||||||
|
const uniqueAuctionRequests: unknown[] = [];
|
||||||
await installImages(page);
|
await installImages(page);
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
|
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
|
||||||
@@ -105,6 +112,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
|||||||
});
|
});
|
||||||
await page.route('**/che/api/trpc/**', async (route) => {
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
const names = operations(route);
|
const names = operations(route);
|
||||||
|
const requestBody: unknown = route.request().postData() ? route.request().postDataJSON() : null;
|
||||||
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
|
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -145,6 +153,10 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
|||||||
resetTurnMutationCount += 1;
|
resetTurnMutationCount += 1;
|
||||||
return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' });
|
return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' });
|
||||||
}
|
}
|
||||||
|
if (name === 'inherit.openUniqueAuction') {
|
||||||
|
uniqueAuctionRequests.push(requestBody);
|
||||||
|
return response({ ok: true, auctionId: 31, closeAt: '2026-07-27T00:00:00.000Z' });
|
||||||
|
}
|
||||||
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
|
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
|
||||||
});
|
});
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -156,6 +168,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
|||||||
return {
|
return {
|
||||||
buffMutationCount: () => buffMutationCount,
|
buffMutationCount: () => buffMutationCount,
|
||||||
resetTurnMutationCount: () => resetTurnMutationCount,
|
resetTurnMutationCount: () => resetTurnMutationCount,
|
||||||
|
uniqueAuctionRequests,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -290,6 +303,28 @@ test.describe('inheritance management legacy parity', () => {
|
|||||||
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => {
|
||||||
|
const fixture = await installFixture(page);
|
||||||
|
await page.goto(gameUrl);
|
||||||
|
|
||||||
|
await page.locator('#specific-unique').selectOption('che_서적_07_논어');
|
||||||
|
await page.locator('#specific-unique-amount').fill('6000');
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
expect(dialog.message()).toBe('6000 포인트로 논어(+7)를 입찰하겠습니까?');
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.locator('.shop-item')
|
||||||
|
.filter({ has: page.locator('#specific-unique') })
|
||||||
|
.getByRole('button', { name: '경매 시작' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect.poll(() => fixture.uniqueAuctionRequests.length).toBe(1);
|
||||||
|
expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('che_서적_07_논어');
|
||||||
|
expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('6000');
|
||||||
|
await expect(page.locator('.notice.success')).toHaveText('성공했습니다. 경매장을 확인해주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
|
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
|
||||||
await installFixture(page, { failBuff: true });
|
await installFixture(page, { failBuff: true });
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|||||||
@@ -26,15 +26,8 @@ import {
|
|||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import {
|
import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
||||||
buildNeutralResourceAuctionPlan,
|
import { createItemInventoryFromSlots, serializeItemInventory } from '@sammo-ts/logic/items/inventory.js';
|
||||||
ItemLoader,
|
|
||||||
ITEM_KEYS,
|
|
||||||
} from '@sammo-ts/logic';
|
|
||||||
import {
|
|
||||||
createItemInventoryFromSlots,
|
|
||||||
serializeItemInventory,
|
|
||||||
} from '@sammo-ts/logic/items/inventory.js';
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
@@ -301,6 +294,7 @@ describe('auction integration flow', () => {
|
|||||||
databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
|
databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
|
||||||
adminUser: bootstrap.user,
|
adminUser: bootstrap.user,
|
||||||
installOptions: {
|
installOptions: {
|
||||||
|
serverId: 'auction-integration-908',
|
||||||
turnTermMinutes: 1,
|
turnTermMinutes: 1,
|
||||||
sync: false,
|
sync: false,
|
||||||
fiction: 0,
|
fiction: 0,
|
||||||
@@ -676,6 +670,69 @@ describe('auction integration flow', () => {
|
|||||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
|
const directTransport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
|
await expect(
|
||||||
|
directTransport.requestCommand(
|
||||||
|
{
|
||||||
|
type: 'auctionBid',
|
||||||
|
auctionId: auction.id,
|
||||||
|
generalId: validBidder.generalId,
|
||||||
|
amount: 400,
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
)
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
reason: '이미 다른 유니크를 가지고 있습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const siblingCloseAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() + 10 * 60_000);
|
||||||
|
const siblingAuction = await prisma.auction.create({
|
||||||
|
data: {
|
||||||
|
type: 'UNIQUE_ITEM',
|
||||||
|
targetCode: uniquePair.keyB,
|
||||||
|
hostGeneralId: 0,
|
||||||
|
hostName: '시스템',
|
||||||
|
detail: {
|
||||||
|
startBidAmount: 200,
|
||||||
|
isReverse: false,
|
||||||
|
availableLatestBidCloseDate: siblingCloseAt.toISOString(),
|
||||||
|
},
|
||||||
|
status: 'OPEN',
|
||||||
|
closeAt: siblingCloseAt,
|
||||||
|
closeTick: BigInt(turnDaemon.world.dateToGameTick(siblingCloseAt)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.auctionBid.create({
|
||||||
|
data: {
|
||||||
|
auctionId: siblingAuction.id,
|
||||||
|
generalId: spareBidder.generalId,
|
||||||
|
amount: 350,
|
||||||
|
eventId: `same-slot-race-${siblingAuction.id}`,
|
||||||
|
eventAt: turnDaemon.world.getGameNow(new Date()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
directTransport.requestCommand(
|
||||||
|
{
|
||||||
|
type: 'auctionBid',
|
||||||
|
auctionId: auction.id,
|
||||||
|
generalId: spareBidder.generalId,
|
||||||
|
amount: 400,
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
)
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.',
|
||||||
|
});
|
||||||
|
await prisma.auction.update({
|
||||||
|
where: { id: siblingAuction.id },
|
||||||
|
data: { status: 'CANCELED', finishedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000);
|
const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000);
|
||||||
const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt);
|
const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt);
|
||||||
await prisma.auction.update({
|
await prisma.auction.update({
|
||||||
@@ -684,7 +741,6 @@ describe('auction integration flow', () => {
|
|||||||
});
|
});
|
||||||
await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]);
|
await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]);
|
||||||
|
|
||||||
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
@@ -694,15 +750,16 @@ describe('auction integration flow', () => {
|
|||||||
WHERE id = ${auction.id}
|
WHERE id = ${auction.id}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
const result = await directTransport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
expect(result).toMatchObject({ type: 'auctionFinalize', ok: false });
|
expect(result).toMatchObject({ type: 'auctionFinalize', ok: false });
|
||||||
|
|
||||||
const reopened = await prisma.auction.findUnique({
|
const reopened = await prisma.auction.findUnique({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
select: { status: true, closeAt: true },
|
select: { status: true, closeAt: true },
|
||||||
});
|
});
|
||||||
expect(reopened?.status).toBe('OPEN');
|
expect(reopened).not.toBeNull();
|
||||||
expect(reopened?.closeAt.getTime()).toBeGreaterThan(finalizeAt.getTime());
|
expect(reopened!.status).toBe('OPEN');
|
||||||
|
expect(reopened!.closeAt.getTime() - finalizeAt.getTime()).toBe(24 * 60_000);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it('unique auction: two bidders extend until limit, then winner gets item', async () => {
|
it('unique auction: two bidders extend until limit, then winner gets item', async () => {
|
||||||
@@ -1013,4 +1070,154 @@ describe('auction integration flow', () => {
|
|||||||
meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }),
|
meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }),
|
||||||
});
|
});
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
it('starts from inheritance, accepts another user bid, and awards a default-pool unique', async () => {
|
||||||
|
if (!gameConnector || !redisConnector || !gameServer) {
|
||||||
|
throw new Error('runtime not ready');
|
||||||
|
}
|
||||||
|
const prisma = gameConnector.prisma;
|
||||||
|
const redis = redisConnector.client;
|
||||||
|
const [host, bidder] = userSessions;
|
||||||
|
if (!host || !bidder) {
|
||||||
|
throw new Error('not enough bidders');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turnDaemon) {
|
||||||
|
await turnDaemon.lifecycle.stop('integration-test');
|
||||||
|
await turnDaemon.close();
|
||||||
|
await turnDaemonLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniquePair = await findUniqueItemPair();
|
||||||
|
const slotField = resolveSlotField(uniquePair.slot);
|
||||||
|
if (!slotField) {
|
||||||
|
throw new Error('unsupported item slot');
|
||||||
|
}
|
||||||
|
const state = await prisma.worldState.findFirstOrThrow();
|
||||||
|
const config =
|
||||||
|
state.config && typeof state.config === 'object' && !Array.isArray(state.config) ? state.config : {};
|
||||||
|
const configConst =
|
||||||
|
config.const && typeof config.const === 'object' && !Array.isArray(config.const) ? config.const : {};
|
||||||
|
await prisma.worldState.update({
|
||||||
|
where: { id: state.id },
|
||||||
|
data: {
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 4,
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
const: {
|
||||||
|
...configConst,
|
||||||
|
// 표준 Ref 시나리오는 GameConst 기본값을 상속한다. 오래된
|
||||||
|
// Core snapshot의 문자열 빈 객체도 같은 입력으로 검증한다.
|
||||||
|
allItems: '{}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.auction.updateMany({
|
||||||
|
where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] } },
|
||||||
|
data: { status: 'CANCELED', finishedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const session of [host, bidder]) {
|
||||||
|
const row = await prisma.general.findUniqueOrThrow({
|
||||||
|
where: { id: session.generalId },
|
||||||
|
select: { meta: true },
|
||||||
|
});
|
||||||
|
const meta = row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta) ? row.meta : {};
|
||||||
|
await prisma.general.update({
|
||||||
|
where: { id: session.generalId },
|
||||||
|
data: {
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
itemInventory: serializeItemInventory(
|
||||||
|
createItemInventoryFromSlots({ horse: null, weapon: null, book: null, item: null })
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.inheritancePoint.upsert({
|
||||||
|
where: { userId_key: { userId: session.userId, key: 'previous' } },
|
||||||
|
update: { value: 100_000 },
|
||||||
|
create: { userId: session.userId, key: 'previous', value: 100_000 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.general.updateMany({
|
||||||
|
where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput,
|
||||||
|
data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||||
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
|
profile: 'che',
|
||||||
|
profileName: 'che:908',
|
||||||
|
databaseUrl: gameDatabaseUrl,
|
||||||
|
gatewayDatabaseUrl,
|
||||||
|
});
|
||||||
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: host.accessToken });
|
||||||
|
const bidderClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder.accessToken });
|
||||||
|
const inheritStatus = await hostClient.inherit.getStatus.query();
|
||||||
|
expect(inheritStatus.availableUnique.length).toBeGreaterThan(80);
|
||||||
|
expect(inheritStatus.availableUnique).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ key: uniquePair.keyA })])
|
||||||
|
);
|
||||||
|
|
||||||
|
const opened = await hostClient.inherit.openUniqueAuction.mutate({
|
||||||
|
itemId: uniquePair.keyA,
|
||||||
|
amount: 5_000,
|
||||||
|
});
|
||||||
|
const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } });
|
||||||
|
expect(auction).toMatchObject({
|
||||||
|
type: 'UNIQUE_ITEM',
|
||||||
|
targetCode: uniquePair.keyA,
|
||||||
|
hostGeneralId: host.generalId,
|
||||||
|
status: 'OPEN',
|
||||||
|
});
|
||||||
|
const timerKeys = buildAuctionTimerKeys(gameServer.config.profileName);
|
||||||
|
await expect(redis.zScore(timerKeys.timerKey, String(auction.id))).resolves.not.toBeNull();
|
||||||
|
|
||||||
|
await bidderClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5_100 });
|
||||||
|
await expect(
|
||||||
|
prisma.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId: host.userId, key: 'previous' } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 100_000 });
|
||||||
|
|
||||||
|
const finalizeAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() - 1_000);
|
||||||
|
const finalizeTick = turnDaemon.world.dateToGameTick(finalizeAt);
|
||||||
|
await prisma.auction.update({
|
||||||
|
where: { id: auction.id },
|
||||||
|
data: {
|
||||||
|
closeAt: finalizeAt,
|
||||||
|
closeTick: BigInt(finalizeTick),
|
||||||
|
status: 'FINALIZING',
|
||||||
|
finalizingAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
|
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
|
expect(result).toMatchObject({ type: 'auctionFinalize', ok: true });
|
||||||
|
|
||||||
|
await expect(prisma.auction.findUniqueOrThrow({ where: { id: auction.id } })).resolves.toMatchObject({
|
||||||
|
status: 'FINISHED',
|
||||||
|
});
|
||||||
|
const winner = await prisma.general.findUniqueOrThrow({
|
||||||
|
where: { id: bidder.generalId },
|
||||||
|
select: { weaponCode: true, bookCode: true, horseCode: true, itemCode: true },
|
||||||
|
});
|
||||||
|
expect(Object.values(winner)).toContain(uniquePair.keyA);
|
||||||
|
await expect(
|
||||||
|
prisma.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId: bidder.userId, key: 'previous' } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 94_900 });
|
||||||
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user