From d133db32eba61a989475d3f4ad6e940d74116364 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 00:52:22 +0000 Subject: [PATCH] fix: repair existing test failures --- app/game-api/src/auction/scheduler.ts | 10 +- app/game-api/src/auction/worker.ts | 7 +- app/game-api/src/daemon/databaseTransport.ts | 32 ++- .../test/auctionWorker.integration.test.ts | 13 +- app/game-api/test/auctionWorker.test.ts | 49 +++++ .../test/createGeneral.integration.test.ts | 2 +- .../test/npcPossession.integration.test.ts | 15 +- .../test/selectPool.integration.test.ts | 5 +- app/game-engine/src/auction/bidder.ts | 11 +- app/game-engine/src/turn/commandRegistry.ts | 1 + .../src/turn/npcPossessionService.ts | 1 + .../src/turn/worldCommandHandler.ts | 4 +- .../test/npcNationGrowthScenario.test.ts | 2 +- ...nificationFinalization.integration.test.ts | 16 +- app/gateway-api/src/index.ts | 1 + .../accountIconDailyCas.integration.test.ts | 1 + docs/architecture/game-clock.md | 16 ++ packages/common/src/turnDaemon/types.ts | 1 + .../logic/test/scenarios/domestic.test.ts | 2 +- .../test/auctionFlow.test.ts | 186 ++++++++++++------ .../test/initialization.test.ts | 39 ++-- ...sionSelectionReference.integration.test.ts | 6 +- .../test/orchestrator.e2e.test.ts | 9 +- .../test/tournamentLifecycle.test.ts | 73 ++++++- 24 files changed, 386 insertions(+), 116 deletions(-) diff --git a/app/game-api/src/auction/scheduler.ts b/app/game-api/src/auction/scheduler.ts index 4dea9339..eb83924e 100644 --- a/app/game-api/src/auction/scheduler.ts +++ b/app/game-api/src/auction/scheduler.ts @@ -26,6 +26,14 @@ export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, c return time.dateToTick(closeAt) ?? closeAt.getTime(); }; +export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => { + if (row.status === 'FINALIZING') { + // 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다. + return time.tick ?? time.now.getTime(); + } + return resolveAuctionTimerScore(time, row.closeAt, row.closeTick); +}; + export const seedAuctionTimers = async ( db: DatabaseClient, redis: RedisSortedSetClient, @@ -44,7 +52,7 @@ export const seedAuctionTimers = async ( const gameTime = await loadCurrentGameTime(db); const payload = rows.map((row) => ({ - score: resolveAuctionTimerScore(gameTime, row.closeAt, row.closeTick), + score: resolveAuctionSeedScore(gameTime, row), value: String(row.id), })); await redis.zAdd(keys.timerKey, payload); diff --git a/app/game-api/src/auction/worker.ts b/app/game-api/src/auction/worker.ts index 11c42065..a4a50b39 100644 --- a/app/game-api/src/auction/worker.ts +++ b/app/game-api/src/auction/worker.ts @@ -88,8 +88,9 @@ export const processDueAuctionId = async (options: { id: string; nowMs: number; nowTick?: number | null; + historyNowMs?: number; }): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => { - const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null } = options; + const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options; const auctionId = Number(id); if (!Number.isSafeInteger(auctionId) || auctionId < 1) { return 'IGNORED'; @@ -160,7 +161,8 @@ export const processDueAuctionId = async (options: { }); if (outcome.status === 'FINALIZING') { - await redis.zAdd(historyKey, [{ score: nowMs, value: id }]); + // history retention은 운영 경과시간 기준이며 게임의 논리 시각과 분리한다. + await redis.zAdd(historyKey, [{ score: historyNowMs, value: id }]); return 'FINALIZING'; } if (outcome.status === 'RESCHEDULED') { @@ -224,6 +226,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom id, nowMs: gameNowMs, nowTick: gameTime.tick, + historyNowMs: operationalNowMs, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown auction worker error'; diff --git a/app/game-api/src/daemon/databaseTransport.ts b/app/game-api/src/daemon/databaseTransport.ts index 6b3b0ded..7b961cda 100644 --- a/app/game-api/src/daemon/databaseTransport.ts +++ b/app/game-api/src/daemon/databaseTransport.ts @@ -4,6 +4,7 @@ import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js'; +import { loadCurrentGameTime } from '../services/gameClock.js'; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; @@ -20,6 +21,18 @@ const stableJson = (value: unknown): string => { } return JSON.stringify(value) ?? 'null'; }; +const commandIdentityJson = (value: unknown): string => { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Reflect.get(value, 'type') === 'npcPossessGeneral' + ) { + const { acceptedGameAt: _acceptedGameAt, ...identity } = value as Record; + return stableJson(identity); + } + return stableJson(value); +}; export class ConflictingTurnDaemonCommandError extends Error { constructor(readonly requestId: string) { @@ -57,6 +70,9 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { async sendCommand(command: TurnDaemonCommand): Promise { const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID(); const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand; + if (durableCommand.type === 'npcPossessGeneral') { + delete durableCommand.acceptedGameAt; + } if (command.type === 'npcPossessGeneral') { const existing = await this.db.inputEvent.findUnique({ where: { requestId }, @@ -65,7 +81,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { if (existing) { if ( existing.eventType !== command.type || - stableJson(existing.payload) !== stableJson(durableCommand) + commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand) ) { throw new ConflictingTurnDaemonCommandError(requestId); } @@ -82,11 +98,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))` ); const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000); + const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now; const token = await transaction.npcSelectionToken.findFirst({ where: { ownerUserId: command.userId, nonce: command.tokenNonce, - validUntil: { gte: acceptedAt }, + validUntil: { gte: acceptedGameAt }, }, select: { pickResult: true }, }); @@ -101,7 +118,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { ) { return '선택한 장수가 목록에 없습니다.'; } - await this.createInputEvent(transaction, durableCommand, requestId, acceptedAt); + const acceptedCommand: Extract = { + ...(durableCommand as Extract), + acceptedGameAt: acceptedGameAt.toISOString(), + }; + await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt); return null; }); if (rejectionReason) { @@ -123,7 +144,10 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { where: { requestId }, select: { eventType: true, payload: true }, }); - if (existing.eventType !== command.type || stableJson(existing.payload) !== stableJson(durableCommand)) { + if ( + existing.eventType !== command.type || + commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand) + ) { throw new ConflictingTurnDaemonCommandError(requestId); } } diff --git a/app/game-api/test/auctionWorker.integration.test.ts b/app/game-api/test/auctionWorker.integration.test.ts index 7e15e980..a5e0bf04 100644 --- a/app/game-api/test/auctionWorker.integration.test.ts +++ b/app/game-api/test/auctionWorker.integration.test.ts @@ -524,10 +524,12 @@ liveDescribe('auction worker durable recovery', () => { expect(reopened).toMatchObject({ status: 'OPEN' }); expect(reopened!.closeAt.getTime()).toBeGreaterThan(extensionAuction.closeAt.getTime()); - const secondCloseAt = new Date(Date.now() - 1_000); + const secondWallNow = new Date(); + const secondCloseAt = new Date(secondWallNow.getTime() - 1_000); + const secondCloseTick = world.dateToGameTick(secondCloseAt); await connector.prisma.auction.update({ where: { id: extensionAuction.id }, - data: { closeAt: secondCloseAt }, + data: { closeAt: secondCloseAt, closeTick: BigInt(secondCloseTick) }, }); const secondExtensionRequestId = requestIdFor({ id: extensionAuction.id, closeAt: secondCloseAt }); await processDueAuctionId({ @@ -536,7 +538,8 @@ liveDescribe('auction worker durable recovery', () => { timerKey: 'timer', historyKey: 'history', id: String(extensionAuction.id), - nowMs: Date.now(), + nowMs: world.getGameNow(secondWallNow).getTime(), + nowTick: world.dateToGameTick(secondWallNow), }); for (let attempt = 0; attempt < 200; attempt += 1) { @@ -601,6 +604,10 @@ liveDescribe('auction worker durable recovery', () => { { timeout: 15_000 }, async () => { const poisonedAuction = await createAuction('OPEN'); + await connector.prisma.auction.update({ + where: { id: poisonedAuction.id }, + data: { closeTick: 0n }, + }); const poisonedRequestId = requestIdFor(poisonedAuction); await connector.prisma.inputEvent.create({ data: { diff --git a/app/game-api/test/auctionWorker.test.ts b/app/game-api/test/auctionWorker.test.ts index 302b5be0..8c98cd83 100644 --- a/app/game-api/test/auctionWorker.test.ts +++ b/app/game-api/test/auctionWorker.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { GamePrismaClient } from '@sammo-ts/infra'; import { processDueAuctionId } from '../src/auction/worker.js'; +import { resolveAuctionSeedScore } from '../src/auction/scheduler.js'; const buildRedis = () => ({ zRangeByScore: vi.fn(async () => []), @@ -52,6 +53,34 @@ const buildDb = (options: { }; describe('auction worker clock-shift race', () => { + it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => { + const now = new Date('2026-07-30T12:00:00.000Z'); + const time = { + now, + tick: 36_000_000, + mode: 'manual' as const, + dateToTick: () => 72_000_000, + }; + const closeAt = new Date('2099-01-01T00:00:00.000Z'); + + expect( + resolveAuctionSeedScore(time, { + id: 7, + status: 'OPEN', + closeAt, + closeTick: 72_000_000n, + }) + ).toBe(72_000_000); + expect( + resolveAuctionSeedScore(time, { + id: 7, + status: 'FINALIZING', + closeAt, + closeTick: 72_000_000n, + }) + ).toBe(36_000_000); + }); + it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => { const redis = buildRedis(); const closeAt = new Date('2026-07-30T12:15:00.000Z'); @@ -125,6 +154,26 @@ describe('auction worker clock-shift race', () => { }); }); + it('records operational history time separately from logical settlement time', async () => { + const redis = buildRedis(); + const closeAt = new Date('2026-07-30T11:00:00.000Z'); + const { db } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } }); + const logicalNowMs = new Date('0190-01-01T00:00:00.000Z').getTime(); + const operationalNowMs = new Date('2026-07-30T12:00:00.000Z').getTime(); + + await processDueAuctionId({ + db, + redis, + timerKey: 'timer', + historyKey: 'history', + id: '7', + nowMs: logicalNowMs, + historyNowMs: operationalNowMs, + }); + + expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: operationalNowMs, value: '7' }]); + }); + it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => { const redis = buildRedis(); const closeAt = new Date('2026-07-30T11:00:00.000Z'); diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 4206f248..7efe4179 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -345,7 +345,7 @@ integration('generic general creation through the durable turn daemon', () => { attempts: 1, actorUserId: userId, }); - expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime()); + expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); const turnGridOffsetSeconds = ((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300; expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35); diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index fcf8e728..e27fbcbe 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -321,7 +321,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae attempts: 1, actorUserId: userId, }); - expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime()); + expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); const logs = await db.logEntry.findMany({ where: { OR: [ @@ -362,7 +362,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae }); }, 45_000); - it('keeps an accepted token through wall-clock expiry until the queued ENGINE event finishes', async () => { + it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => { const reservation = await appRouter .createCaller(buildContext('npc-possession-delayed-token', delayedAuth)) .join.listPossessCandidates({}); @@ -382,12 +382,17 @@ integration('mode 1 NPC possession through token reservation and the durable dae ).rejects.toMatchObject({ code: 'TIMEOUT' }); const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); - const acceptedSecond = new Date(Math.floor(event.createdAt.getTime() / 1000) * 1000); + const acceptedGameAt = new Date( + (event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time' + ); + expect(acceptedGameAt.toString()).not.toBe('Invalid Date'); await db.npcSelectionToken.update({ where: { ownerUserId: delayedUserId }, - data: { validUntil: acceptedSecond }, + data: { validUntil: acceptedGameAt }, + }); + await db.worldState.updateMany({ + data: { clockTick: { increment: 1 } }, }); - await new Promise((resolve) => setTimeout(resolve, 1_100)); await appRouter .createCaller(buildContext('npc-possession-cleanup-token', cleanupAuth)) diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 7515a0ed..fe11ef3f 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -434,6 +434,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { }, 30_000); it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => { + const logicalNowMs = runtime!.world.getGameNow(new Date()).getTime(); const reservation = await appRouter .createCaller(buildContext('select-pool-other-reserve', otherAuth)) .join.getSelectionPool(); @@ -449,7 +450,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { await db.selectPoolEntry.update({ where: { uniqueName: candidate.uniqueName }, - data: { reservedUntil: new Date(Date.now() - 60_000) }, + data: { reservedUntil: new Date(logicalNowMs - 60_000) }, }); await expect( appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({ @@ -476,7 +477,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { }; await db.selectPoolEntry.updateMany({ where: { ownerUserId: otherUserId, generalId: null }, - data: { reservedUntil: new Date(Date.now() + 60_000) }, + data: { reservedUntil: new Date(logicalNowMs + 60_000) }, }); const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId; const persistedAllocatorBefore = ( diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index dd07b0d7..1372c80b 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -27,6 +27,7 @@ interface AuctionRow { detail: unknown; status: AuctionStatus; closeAt: Date; + latestEventId: string; } interface AuctionBidRow { @@ -101,7 +102,8 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise => { const { db, world, worldState, userId, generalId, acceptedAt } = options; + // queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다. const tokenAcceptedAt = truncateToSeconds(acceptedAt); requireNpcPossessionWorld(worldState); await lockNpcPossession(db, userId); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 99493cf3..dae0c199 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -294,7 +294,9 @@ async function handleNpcPossessGeneral( throw new Error('NPC possession world state is missing.'); } const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); - const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); + const acceptedAt = command.acceptedGameAt + ? new Date(command.acceptedGameAt) + : ctx.world.getGameNow(operationalAcceptedAt); try { return { type: 'npcPossessGeneral', diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index ac0594b1..47c69ab4 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -563,5 +563,5 @@ describe('NPC 대형 시뮬레이션', () => { } throw error; } - }); + }, 30_000); }); diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index a3ed85cc..ac5897c8 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -144,7 +144,7 @@ integration('unification finalization transaction', () => { { userId, key: 'tournament', value: 11 }, ], }); - const futureCloseAt = new Date(Date.now() + 86_400_000); + const futureCloseAt = new Date('0190-07-02T00:00:00.000Z'); const uniqueAuction = await db.auction.create({ data: { type: 'UNIQUE_ITEM', @@ -243,11 +243,15 @@ integration('unification finalization transaction', () => { where: { generalId_type: { generalId: fixtureId, type: 'inherit_spent_dyn' } }, }) ).toMatchObject({ value: 50 }); - expect( - (await db.auctionBid.findMany({ where: { auctionId: uniqueAuction.id }, orderBy: { id: 'asc' } })).map( - (bid) => bid.meta - ) - ).toEqual([ + const persistedBids = await db.auctionBid.findMany({ + where: { auctionId: uniqueAuction.id }, + orderBy: { id: 'asc' }, + }); + expect(persistedBids.map((bid) => bid.eventAt.toISOString())).toEqual([ + '0190-07-01T00:00:00.000Z', + '0190-07-01T00:00:00.000Z', + ]); + expect(persistedBids.map((bid) => bid.meta)).toEqual([ expect.objectContaining({ inheritSpentTrackedAmount: 30 }), expect.objectContaining({ inheritSpentTrackedAmount: 50 }), ]); diff --git a/app/gateway-api/src/index.ts b/app/gateway-api/src/index.ts index 029eeecc..b425004f 100644 --- a/app/gateway-api/src/index.ts +++ b/app/gateway-api/src/index.ts @@ -13,6 +13,7 @@ export type JsonArray = GatewayPrisma.JsonArray; export * from './orchestrator/profileRepository.js'; export * from './orchestrator/gatewayReleaseRepository.js'; export * from './orchestrator/gatewayOrchestrator.js'; +export * from './orchestrator/seedProfileDatabase.js'; export * from './orchestrator/workspaceManager.js'; export * from './orchestrator/buildRunner.js'; export * from './orchestrator/processManager.js'; diff --git a/app/gateway-api/test/accountIconDailyCas.integration.test.ts b/app/gateway-api/test/accountIconDailyCas.integration.test.ts index 68da096a..3880bb36 100644 --- a/app/gateway-api/test/accountIconDailyCas.integration.test.ts +++ b/app/gateway-api/test/accountIconDailyCas.integration.test.ts @@ -41,6 +41,7 @@ integration('account icon daily PostgreSQL CAS', () => { picture: 'old.png', imageServer: 1, iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'), + createdAt: new Date('2026-07-30T09:00:00.000Z'), }, }); }); diff --git a/docs/architecture/game-clock.md b/docs/architecture/game-clock.md index a7416f24..94fa9b3b 100644 --- a/docs/architecture/game-clock.md +++ b/docs/architecture/game-clock.md @@ -36,3 +36,19 @@ DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 mi 재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은 무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고 DateTime fallback을 사용합니다. + +## 비동기 작업의 시계 경계 + +게임 규칙의 수락·입찰·예약 시각은 logical game time을 사용하지만 daemon +queue의 `InputEvent.createdAt`, worker history retention과 timeout은 운영 +벽시계를 사용합니다. NPC 빙의 enqueue는 현재 logical game time을 event +payload의 `acceptedGameAt`에 고정합니다. queue에 들어갈 때 유효했던 token은 +처리 전 game tick이 진행해도 이 저장된 논리 수락 시각으로 다시 검증합니다. + +경매 입찰은 같은 logical tick에서 여러 번 일어날 수 있습니다. bid 표시 +시각은 같은 game time을 보존하고, optimistic 경합 판정은 임의 UUID의 +사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker +재시작 시 `OPEN`은 `close_tick` deadline에, 이미 마감 판정이 끝난 +`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시 +재시도합니다. Redis history의 score는 보존 기간 계산을 위해 운영 벽시계를 +사용합니다. diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 3828dd10..b82f2432 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -246,6 +246,7 @@ export type TurnDaemonCommand = ownerLegacyPenalty?: Record; generalId: number; tokenNonce: number; + acceptedGameAt?: string; } | { type: 'selectPoolCreate'; diff --git a/packages/logic/test/scenarios/domestic.test.ts b/packages/logic/test/scenarios/domestic.test.ts index e341f34a..c0918e3a 100644 --- a/packages/logic/test/scenarios/domestic.test.ts +++ b/packages/logic/test/scenarios/domestic.test.ts @@ -8,7 +8,7 @@ import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; import { loadActionModuleBundle } from '../../src/actionModules/bundle.js'; describe('Domestic Affairs Scenario', () => { - it('should increase agriculture when executing "Farming" command', async () => { + it('should increase agriculture when executing "Farming" command', { timeout: 15_000 }, async () => { // 1. Setup World const mockNation: Nation = { id: 1, diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 156800ce..33984e73 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -8,7 +8,7 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { sealGatewayPassword } from '../src/passwordEnvelope.js'; import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; -import { createGatewayApiServer } from '@sammo-ts/gateway-api'; +import { createGatewayApiServer, seedProfileDatabase } from '@sammo-ts/gateway-api'; import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; import { createGameApiServer, @@ -17,6 +17,7 @@ import { DatabaseTurnDaemonTransport, } from '@sammo-ts/game-api'; import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { GAME_TICKS_PER_TURN, GameClock } from '@sammo-ts/common'; import { createGatewayPostgresConnector, createGamePostgresConnector, @@ -25,7 +26,13 @@ import { resolveRedisConfigFromEnv, GamePrisma, } from '@sammo-ts/infra'; -import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic'; +import { + buildNeutralResourceAuctionPlan, + createItemInventoryFromSlots, + ItemLoader, + ITEM_KEYS, + serializeItemInventory, +} from '@sammo-ts/logic'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -93,7 +100,8 @@ const truncateSchema = async (schema: string) => { await connector.connect(); try { const rows = (await connector.prisma.$queryRawUnsafe( - `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + `SELECT tablename FROM pg_tables + WHERE schemaname = '${schema}' AND tablename <> '_prisma_migrations'` )) as Array<{ tablename: string }>; if (rows.length === 0) { return; @@ -108,13 +116,15 @@ const truncateSchema = async (schema: string) => { const resetDatabase = async () => { await ensureSchema('public'); await ensureSchema('che'); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], { ...process.env, POSTGRES_SCHEMA: 'public', + GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url, }); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], { ...process.env, POSTGRES_SCHEMA: 'che', + DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'che' }).url, }); await truncateSchema('public'); await truncateSchema('che'); @@ -284,10 +294,11 @@ describe('auction integration flow', () => { }, }); - await gatewayClient.admin.profiles.installNow.mutate({ - profileName: 'che:908', - install: { - scenarioId: 908, + await seedProfileDatabase({ + scenarioId: 908, + databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url, + adminUser: bootstrap.user, + installOptions: { turnTermMinutes: 1, sync: false, fiction: 0, @@ -300,6 +311,15 @@ describe('auction integration flow', () => { autorunUser: null, }, }); + 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(); const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({ sessionToken: adminSessionRef.value ?? '', @@ -343,16 +363,6 @@ describe('auction integration flow', () => { }); } - 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(); - const waitForStatus = async (timeoutMs = 10_000) => { if (!gameClient) { throw new Error('game client missing'); @@ -461,7 +471,7 @@ describe('auction integration flow', () => { const keys = buildAuctionTimerKeys(gameServer.config.profileName); await seedAuctionTimers(prisma, redis, keys); const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); - expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + expect(Number(initialScore)).toBe(Number(auction.closeTick)); await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow( '자신이 연 경매에 입찰할 수 없습니다.' @@ -484,20 +494,21 @@ describe('auction integration flow', () => { const updatedAuction = await prisma.auction.findUnique({ where: { id: auction.id }, - select: { closeAt: true }, + select: { closeAt: true, closeTick: true }, }); expect(updatedAuction).not.toBeNull(); expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime()); const updatedScore = await redis.zScore(keys.timerKey, String(auction.id)); - expect(Number(updatedScore)).toBe(updatedAuction?.closeAt.getTime()); + expect(Number(updatedScore)).toBe(Number(updatedAuction?.closeTick)); - const finalizeAt = new Date(Date.now() - 1000); + const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000); + const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt); await prisma.auction.update({ where: { id: auction.id }, - data: { closeAt: finalizeAt, status: 'OPEN' }, + data: { closeAt: finalizeAt, closeTick: BigInt(finalizeTick), status: 'OPEN' }, }); - await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), 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( @@ -576,7 +587,9 @@ describe('auction integration flow', () => { }); const now = new Date(); - const initialCloseAt = new Date(now.getTime() + 10_000); + const logicalNow = turnDaemon!.world.getGameNow(now); + const initialCloseAt = new Date(logicalNow.getTime() + 10_000); + const initialCloseTick = turnDaemon!.world.dateToGameTick(initialCloseAt); const auction = await prisma.auction.create({ data: { type: 'UNIQUE_ITEM', @@ -586,17 +599,18 @@ describe('auction integration flow', () => { detail: { startBidAmount: 200, isReverse: false, - availableLatestBidCloseDate: new Date(now.getTime() + 10 * 60_000).toISOString(), + availableLatestBidCloseDate: new Date(logicalNow.getTime() + 10 * 60_000).toISOString(), }, status: 'OPEN', closeAt: initialCloseAt, + closeTick: BigInt(initialCloseTick), }, }); const keys = buildAuctionTimerKeys(gameServer.config.profileName); await seedAuctionTimers(prisma, redis, keys); const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); - expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + expect(Number(initialScore)).toBe(initialCloseTick); const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: ownerBidder.accessToken, @@ -617,16 +631,38 @@ describe('auction integration flow', () => { expect(updatedAuction).not.toBeNull(); expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime()); - await prisma.general.update({ - where: { id: validBidder.generalId }, - data: slotUpdate, - }); - if (turnDaemon) { await turnDaemon.lifecycle.stop('integration-test'); await turnDaemon.close(); await turnDaemonLoop; } + // Stop the snapshot before changing the fixture. Otherwise its shutdown + // flush can write the older inventory back over this direct DB update. + const validBidderRow = await prisma.general.findUniqueOrThrow({ + where: { id: validBidder.generalId }, + select: { meta: true }, + }); + const validBidderMeta = + validBidderRow.meta && typeof validBidderRow.meta === 'object' && !Array.isArray(validBidderRow.meta) + ? validBidderRow.meta + : {}; + const occupiedSlots = { + horse: null, + weapon: null, + book: null, + item: null, + [uniquePair.slot]: uniquePair.keyB, + }; + await prisma.general.update({ + where: { id: validBidder.generalId }, + data: { + ...slotUpdate, + meta: { + ...validBidderMeta, + itemInventory: serializeItemInventory(createItemInventoryFromSlots(occupiedSlots)), + }, + }, + }); const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; turnDaemon = await createTurnDaemonRuntime({ @@ -638,12 +674,13 @@ describe('auction integration flow', () => { turnDaemonLoop = turnDaemon.lifecycle.start(); await sleep(500); - const finalizeAt = new Date(Date.now() - 1000); + const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000); + const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt); await prisma.auction.update({ where: { id: auction.id }, - data: { closeAt: finalizeAt, status: 'OPEN' }, + data: { closeAt: finalizeAt, closeTick: BigInt(finalizeTick), status: 'OPEN' }, }); - await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), 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( @@ -684,14 +721,33 @@ describe('auction integration flow', () => { throw new Error('unsupported item slot'); } - await prisma.general.update({ - where: { id: bidderA.generalId }, - data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' }, - }); - await prisma.general.update({ - where: { id: bidderB.generalId }, - data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' }, - }); + if (turnDaemon) { + await turnDaemon.lifecycle.stop('integration-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + for (const bidder of [bidderA, bidderB]) { + const row = await prisma.general.findUniqueOrThrow({ + where: { id: bidder.generalId }, + select: { meta: true }, + }); + const meta = row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta) ? row.meta : {}; + await prisma.general.update({ + where: { id: bidder.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.general.updateMany({ where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput, data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput, @@ -708,11 +764,6 @@ describe('auction integration flow', () => { create: { userId: bidderB.userId, key: 'previous', value: 100_000 }, }); - if (turnDaemon) { - await turnDaemon.lifecycle.stop('integration-test'); - await turnDaemon.close(); - await turnDaemonLoop; - } const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; turnDaemon = await createTurnDaemonRuntime({ @@ -725,6 +776,7 @@ describe('auction integration flow', () => { await sleep(500); const now = new Date(); + const logicalNow = turnDaemon!.world.getGameNow(now); await prisma.$executeRaw( GamePrisma.sql` UPDATE auction @@ -745,11 +797,14 @@ describe('auction integration flow', () => { itemKey: uniquePair.keyA, amount: 5000, }); - const limitCloseAt = new Date(now.getTime() + 60_000); + const initialCloseAt = new Date(logicalNow.getTime() + 2000); + const initialCloseTick = turnDaemon!.world.dateToGameTick(initialCloseAt); + const limitCloseAt = new Date(logicalNow.getTime() + 60_000); await prisma.$executeRaw( GamePrisma.sql` UPDATE auction - SET close_at = ${new Date(now.getTime() + 2000)}, + SET close_at = ${initialCloseAt}, + close_tick = ${BigInt(initialCloseTick)}, detail = jsonb_set( jsonb_set( detail, @@ -780,12 +835,13 @@ describe('auction integration flow', () => { expect(afterBids).not.toBeNull(); expect(afterBids!.closeAt.getTime()).toBe(limitCloseAt.getTime()); - const finalizeAt = new Date(Date.now() - 1000); + const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000); + const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt); await prisma.auction.update({ where: { id: auction.id }, - data: { closeAt: finalizeAt, status: 'OPEN' }, + data: { closeAt: finalizeAt, closeTick: BigInt(finalizeTick), status: 'OPEN' }, }); - await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), 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( @@ -834,7 +890,9 @@ describe('auction integration flow', () => { turnTime: futureTurn, }, }); - const nationCount = await prisma.nation.count(); + // Ref's nation scan starts at 1; Core's internal nation 0 must not + // consume a monthly nation-power RNG draw. + const nationCount = await prisma.nation.count({ where: { id: { gt: 0 } } }); let hiddenSeed = ''; let expected = [] as ReturnType; for (let index = 0; index < 1_000; index += 1) { @@ -861,16 +919,27 @@ describe('auction integration flow', () => { worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta) ? worldState.meta : {}; + const clockTick = worldState.clockTick ?? 0n; + const previousTurnTick = clockTick - BigInt(GAME_TICKS_PER_TURN); + const gameClock = new GameClock({ + baseTime: worldState.clockBaseTime!, + tick: Number(clockTick), + mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime', + wallAnchor: worldState.clockWallAnchor!, + turnSeconds: 60, + }); + const previousTurnTime = gameClock.tickToDate(Number(previousTurnTick)); await prisma.worldState.update({ where: { id: worldState.id }, data: { currentYear: 180, currentMonth: 1, tickSeconds: 60, + lastTurnTick: previousTurnTick, meta: { ...worldMeta, hiddenSeed, - lastTurnTime: new Date(Date.now() - 61_000).toISOString(), + lastTurnTime: previousTurnTime.toISOString(), neutralAuctionRegistrationKey: null, }, }, @@ -925,10 +994,11 @@ describe('auction integration flow', () => { seedMonth: 1, neutralRegistrationKey: '180-02', }); - expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeGreaterThanOrEqual( + const logicalCreatedAt = turnDaemon!.world.getGameNow(row.createdAt); + expect(row.closeAt.getTime() - logicalCreatedAt.getTime()).toBeGreaterThanOrEqual( plan.closeTurnCnt * 60_000 - 2_000 ); - expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeLessThanOrEqual( + expect(row.closeAt.getTime() - logicalCreatedAt.getTime()).toBeLessThanOrEqual( plan.closeTurnCnt * 60_000 + 2_000 ); } diff --git a/tools/integration-tests/test/initialization.test.ts b/tools/integration-tests/test/initialization.test.ts index 7c91c544..f0dc818a 100644 --- a/tools/integration-tests/test/initialization.test.ts +++ b/tools/integration-tests/test/initialization.test.ts @@ -9,7 +9,7 @@ import { sealGatewayPassword } from '../src/passwordEnvelope.js'; import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; -import { createGatewayApiServer } from '@sammo-ts/gateway-api'; +import { createGatewayApiServer, seedProfileDatabase } from '@sammo-ts/gateway-api'; import { createGameApiServer } from '@sammo-ts/game-api'; import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; import { @@ -86,7 +86,8 @@ const truncateSchema = async (schema: string) => { await connector.connect(); try { const rows = (await connector.prisma.$queryRawUnsafe( - `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + `SELECT tablename FROM pg_tables + WHERE schemaname = '${schema}' AND tablename <> '_prisma_migrations'` )) as Array<{ tablename: string }>; if (rows.length === 0) { return; @@ -101,13 +102,15 @@ const truncateSchema = async (schema: string) => { const resetDatabase = async () => { await ensureSchema('public'); await ensureSchema('che'); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], { ...process.env, POSTGRES_SCHEMA: 'public', + GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url, }); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], { ...process.env, POSTGRES_SCHEMA: 'che', + DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'che' }).url, }); await truncateSchema('public'); await truncateSchema('che'); @@ -251,10 +254,11 @@ describe('integration initialization flow', () => { }, }); - await gatewayClient.admin.profiles.installNow.mutate({ - profileName: 'che:2', - install: { - scenarioId: 2, + await seedProfileDatabase({ + scenarioId: 2, + databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url, + adminUser: bootstrap.user, + installOptions: { turnTermMinutes: 1, sync: false, fiction: 0, @@ -267,6 +271,15 @@ describe('integration initialization flow', () => { autorunUser: null, }, }); + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:2', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); const publicMap = await gameClient.public.getCachedMap.query(); expect(publicMap.result).toBe(true); @@ -398,16 +411,6 @@ describe('integration initialization flow', () => { } } - const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; - const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; - turnDaemon = await createTurnDaemonRuntime({ - profile: 'che', - profileName: 'che:2', - databaseUrl: gameDatabaseUrl, - gatewayDatabaseUrl, - }); - turnDaemonLoop = turnDaemon.lifecycle.start(); - const waitForStatus = async (timeoutMs = 10_000) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts index 0cbf7548..37b71b55 100644 --- a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts +++ b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts @@ -76,7 +76,8 @@ type CoreReservationTrace = { const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); const databaseUrl = process.env.NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL; -const integration = describe.skipIf(!workspaceRoot || !databaseUrl || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); +const referenceEnabled = process.env.TURN_DIFFERENTIAL_REFERENCE === '1'; +const integration = describe.skipIf(!workspaceRoot || !databaseUrl || !referenceEnabled); const ownerUserId = 'npc-possession-differential-owner'; const reservedOwnerUserId = 'npc-possession-differential-reserved'; @@ -227,6 +228,9 @@ const assertDedicatedDatabase = (rawUrl: string): void => { }; integration('NPC possession selector Ref differential', () => { + if (!workspaceRoot || !databaseUrl || !referenceEnabled) { + return; + } let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; let worldState: GamePrisma.WorldStateGetPayload>; diff --git a/tools/integration-tests/test/orchestrator.e2e.test.ts b/tools/integration-tests/test/orchestrator.e2e.test.ts index dbd93c87..6337e726 100644 --- a/tools/integration-tests/test/orchestrator.e2e.test.ts +++ b/tools/integration-tests/test/orchestrator.e2e.test.ts @@ -93,7 +93,8 @@ const truncateSchema = async (schema: string) => { await connector.connect(); try { const rows = (await connector.prisma.$queryRawUnsafe( - `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + `SELECT tablename FROM pg_tables + WHERE schemaname = '${schema}' AND tablename <> '_prisma_migrations'` )) as Array<{ tablename: string }>; if (rows.length === 0) { return; @@ -108,13 +109,15 @@ const truncateSchema = async (schema: string) => { const resetDatabase = async (profile: string) => { await ensureSchema('public'); await ensureSchema(profile); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], { ...process.env, POSTGRES_SCHEMA: 'public', + GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url, }); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], { ...process.env, POSTGRES_SCHEMA: profile, + DATABASE_URL: resolvePostgresConfigFromEnv({ schema: profile }).url, }); await truncateSchema('public'); await truncateSchema(profile); diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index ffa8f4e2..c6e149f4 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -108,13 +108,15 @@ const truncateSchema = async (schema: string): Promise => { const resetServices = async (): Promise => { await ensureSchema('public'); await ensureSchema('che'); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], { ...process.env, POSTGRES_SCHEMA: 'public', + GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url, }); - await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], { ...process.env, POSTGRES_SCHEMA: 'che', + DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'che' }).url, }); await truncateSchema('public'); await truncateSchema('che'); @@ -346,6 +348,22 @@ describe('actual tournament lifecycle', () => { })), }); + // The in-memory world is a snapshot. Reload it after creating the users + // and tournament NPC fixtures so settlement can reward the same field + // that the API and Redis bracket expose. + await turnDaemon.lifecycle.stop('reload-tournament-fixtures'); + await turnDaemon.close(); + await turnDaemonLoop; + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:908', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + redisUrl: resolveRedisConfigFromEnv().url, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); + expect(await transport.requestStatus(10_000)).not.toBeNull(); + for (let attempt = 0; attempt < 36; attempt += 1) { const current = turnDaemon.world.getState().lastTurnTime; const next = new Date(current.getTime()); @@ -509,7 +527,54 @@ describe('actual tournament lifecycle', () => { ); expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true); const rewardEvent = settlementEvents.find((event) => event.eventType === 'tournamentReward'); - // Ref setGift(): 16*1 + 8*2 + 4*3 + both finalists*6 + winner*8 = 64 develcost. - expect((rewardEvent?.result as { totalGold?: number } | null)?.totalGold).toBe(currentDevelCost * 64); + const finalMatches = await store.getMatches(); + const rewardMultipliers = new Map(); + const addRewardTier = (ids: number[], multiplier: number): void => { + for (const id of new Set(ids)) { + rewardMultipliers.set(id, (rewardMultipliers.get(id) ?? 0) + multiplier); + } + }; + const roundOf16 = finalMatches.filter((match) => match.stage === 7); + const quarterfinals = finalMatches.filter((match) => match.stage === 8); + const final = finalMatches.find((match) => match.stage === 10 && typeof match.winnerId === 'number'); + expect(final).toBeDefined(); + addRewardTier( + roundOf16.flatMap((match) => [match.attackerId, match.defenderId]), + 1 + ); + addRewardTier( + roundOf16.flatMap((match) => (typeof match.winnerId === 'number' ? [match.winnerId] : [])), + 2 + ); + addRewardTier( + quarterfinals.flatMap((match) => (typeof match.winnerId === 'number' ? [match.winnerId] : [])), + 3 + ); + addRewardTier([final!.attackerId, final!.defenderId], 6); + addRewardTier([final!.winnerId!], 8); + + // Ref's tier multipliers apply to each bracket result. The daemon mutates + // only generals loaded in its world, so fixture-only/dummy entries remain missing. + const persistedRewardIds = new Set( + ( + await gameConnector.prisma.general.findMany({ + where: { id: { in: Array.from(rewardMultipliers.keys()) } }, + select: { id: true }, + }) + ).map(({ id }) => id) + ); + const loadedRewardIds = new Set( + Array.from(rewardMultipliers.keys()).filter((id) => turnDaemon!.world.getGeneralById(id) !== undefined) + ); + const expectedTotalGold = Array.from(rewardMultipliers).reduce( + (sum, [id, multiplier]) => sum + (loadedRewardIds.has(id) ? currentDevelCost * multiplier : 0), + 0 + ); + expect(persistedRewardIds.size).toBeGreaterThanOrEqual(loadedRewardIds.size); + expect(rewardEvent?.result).toMatchObject({ + rewarded: loadedRewardIds.size, + missing: rewardMultipliers.size - loadedRewardIds.size, + totalGold: expectedTotalGold, + }); }, 120_000); });