From b2b6723c4d3e823b81807fa8ffd9d6219e1bb44a Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 23 Sep 2026 04:02:22 +0000 Subject: [PATCH] =?UTF-8?q?=ED=86=B5=EC=9D=BC=20=ED=9B=84=20=EC=84=A4?= =?UTF-8?q?=EB=AC=B8=20=EC=9D=91=EB=8B=B5=EA=B3=BC=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=20=EB=B3=B4=EC=83=81=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통일 완료 상태의 설문 보상 명령만 데몬 큐에서 선점하도록 하고, 시계 중단·복구 경계와 유니크 지급을 검증한다. --- .../src/lifecycle/databaseCommandQueue.ts | 8 +++++- .../databaseCommandQueue.integration.test.ts | 27 +++++++++++++++++++ app/game-engine/test/voteReward.test.ts | 5 +++- 3 files changed, 38 insertions(+), 2 deletions(-) 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(),