통일 후 설문 응답과 유니크 보상 처리를 허용

통일 완료 상태의 설문 보상 명령만 데몬 큐에서 선점하도록 하고, 시계 중단·복구 경계와 유니크 지급을 검증한다.
This commit is contained in:
2026-09-23 04:02:22 +00:00
parent 16b320dabc
commit b2b6723c4d
3 changed files with 38 additions and 2 deletions
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks'; import { performance } from 'node:perf_hooks';
import { asRecord } from '@sammo-ts/common';
import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra'; import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra';
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js'; import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
@@ -119,7 +120,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
const claimCoordinate = await readInputEventClockCoordinate(transaction); const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({ const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' }, 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이 별도로 지킨다. // 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
@@ -129,6 +130,10 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
world.clockPhase === 'RUNNING' || world.clockPhase === 'RUNNING' ||
world.clockPhase === 'MANUAL'; world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED'; 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 currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended = const maintenanceSuspended =
world?.clockPhase === 'SUSPENDED' && world?.clockPhase === 'SUSPENDED' &&
@@ -176,6 +181,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect' 'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect'
) )
) )
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
OR ( OR (
${suspendedTournamentBetCommand} ${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
@@ -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 () => { 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 existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld const world = existingWorld
+4 -1
View File
@@ -414,6 +414,7 @@ describe('voteReward command', () => {
it.each([ it.each([
['PREOPEN', new Date('2026-08-27T10:30:00.000Z'), 1, 'preopen-seed'], ['PREOPEN', new Date('2026-08-27T10:30:00.000Z'), 1, 'preopen-seed'],
['OPEN', new Date('2026-08-27T11:00:05.000Z'), 2, 'open-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)( ] as const)(
'awards a survey unique from the Ref default pool at coefficient 1 during %s', 'awards a survey unique from the Ref default pool at coefficient 1 during %s',
async (_phase, wallNow, voteId, hiddenSeed) => { async (_phase, wallNow, voteId, hiddenSeed) => {
@@ -428,12 +429,14 @@ describe('voteReward command', () => {
clockTick: 36_000_000, clockTick: 36_000_000,
clockMode: 'realtime', clockMode: 'realtime',
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
...(_phase === 'COMPLETED' ? { clockPhase: 'COMPLETED' as const } : {}),
meta: { meta: {
hiddenSeed, hiddenSeed,
scenarioId: 0, scenarioId: 0,
initYear: 180, initYear: 180,
initMonth: 1, initMonth: 1,
scenarioMeta: { startYear: 180 }, scenarioMeta: { startYear: 180 },
...(_phase === 'COMPLETED' ? { isunited: 2, isUnited: 2 } : {}),
preopenAt: '2026-08-27 19:30:00', preopenAt: '2026-08-27 19:30:00',
opentime: '2026-08-27 20:00:00', opentime: '2026-08-27 20:00:00',
}, },
@@ -442,7 +445,7 @@ describe('voteReward command', () => {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
}); });
const acceptedGameTick = world.dateToGameTick(world.getGameNow(wallNow)); 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 = { const commandDb = {
...actorBindingDb(), ...actorBindingDb(),