diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index 887abfbe..bdca37b3 100644 --- a/app/game-engine/src/lifecycle/databaseCommandQueue.ts +++ b/app/game-engine/src/lifecycle/databaseCommandQueue.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; +import { asRecord } from '@sammo-ts/common'; import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra'; import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js'; @@ -119,7 +120,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T const claimCoordinate = await readInputEventClockCoordinate(transaction); const world = await transaction.worldState.findFirst({ orderBy: { id: 'asc' }, - select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true }, + select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true }, }); // 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다. // 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다. @@ -129,6 +130,10 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL'; const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED'; + const worldMeta = asRecord(world?.meta); + const postUnificationSurvey = + (world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED') && + Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0) >= 2; const currentRevision = world?.clockRevision ?? null; const maintenanceSuspended = world?.clockPhase === 'SUSPENDED' && @@ -176,6 +181,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T 'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect' ) ) + OR (${postUnificationSurvey} AND "event_type" = 'voteReward') OR ( ${suspendedTournamentBetCommand} AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 3f474184..95040467 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -450,6 +450,33 @@ integration('database command queue', () => { } ); + it.each([ + ['SUSPENDED', 2, true], + ['COMPLETED', 2, true], + ['COMPLETED', 3, true], + ['SUSPENDED', 0, false], + ['RECONCILING', 2, false], + ] as const)('claims survey rewards in phase %s with unification state %i: %s', async (phase, united, allowed) => { + await db.worldState.updateMany({ data: { clockPhase: phase, meta: { isunited: united } } }); + const requestId = `integration:engine:post-unification-vote:${phase}:${united}`; + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: 'voteReward', + actorUserId: 'user-7', + payload: { type: 'voteReward', requestId, userId: 'user-7', voteId: 1, generalId: 7, selection: [0] }, + }, + }); + const commands = await new DatabaseTurnDaemonCommandQueue(db).drain(); + expect(commands.map((command) => command.type)).toEqual(allowed ? ['voteReward'] : []); + expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({ + status: allowed ? 'PROCESSING' : 'PENDING', + attempts: allowed ? 1 : 0, + processingGameTick: allowed ? 123n : null, + }); + }); + it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => { const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } }); const world = existingWorld diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index d809023d..a3996e92 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -414,6 +414,7 @@ describe('voteReward command', () => { it.each([ ['PREOPEN', new Date('2026-08-27T10:30:00.000Z'), 1, 'preopen-seed'], ['OPEN', new Date('2026-08-27T11:00:05.000Z'), 2, 'open-seed'], + ['COMPLETED', new Date('2026-08-27T11:00:05.000Z'), 2, 'open-seed'], ] as const)( 'awards a survey unique from the Ref default pool at coefficient 1 during %s', async (_phase, wallNow, voteId, hiddenSeed) => { @@ -428,12 +429,14 @@ describe('voteReward command', () => { clockTick: 36_000_000, clockMode: 'realtime', clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'), + ...(_phase === 'COMPLETED' ? { clockPhase: 'COMPLETED' as const } : {}), meta: { hiddenSeed, scenarioId: 0, initYear: 180, initMonth: 1, scenarioMeta: { startYear: 180 }, + ...(_phase === 'COMPLETED' ? { isunited: 2, isUnited: 2 } : {}), preopenAt: '2026-08-27 19:30:00', opentime: '2026-08-27 20:00:00', }, @@ -442,7 +445,7 @@ describe('voteReward command', () => { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, }); const acceptedGameTick = world.dateToGameTick(world.getGameNow(wallNow)); - expect(acceptedGameTick).toBe(_phase === 'PREOPEN' ? 36_000_000 : 36_300_000); + expect(acceptedGameTick).toBe(_phase === 'OPEN' ? 36_300_000 : 36_000_000); const commandDb = { ...actorBindingDb(),