diff --git a/app/game-api/test/accountIconResetReconciler.integration.test.ts b/app/game-api/test/accountIconResetReconciler.integration.test.ts index 912d011f..56b0ec82 100644 --- a/app/game-api/test/accountIconResetReconciler.integration.test.ts +++ b/app/game-api/test/accountIconResetReconciler.integration.test.ts @@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = { currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('2026-07-31T10:00:00.000Z'), + clockBaseTime: new Date('2026-07-31T10:00:00.000Z'), + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'MANUAL', + clockRevision: 1, + deadlineGeneration: 1, meta: { killturn: 24, scenarioMeta }, }; const lifecycleGeneral: TurnGeneral = { @@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => { currentYear: lifecycleState.currentYear, currentMonth: lifecycleState.currentMonth, tickSeconds: lifecycleState.tickSeconds, + clockBaseTime: lifecycleState.clockBaseTime, + clockTick: BigInt(lifecycleState.clockTick ?? 0), + clockMode: lifecycleState.clockMode ?? 'manual', + clockWallAnchor: lifecycleState.clockWallAnchor, + lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0), + clockPhase: lifecycleState.clockPhase ?? 'MANUAL', + clockRevision: BigInt(lifecycleState.clockRevision ?? 1), + deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1), config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: lifecycleState.meta as GamePrisma.InputJsonValue, }, diff --git a/app/game-api/test/auctionWorker.integration.test.ts b/app/game-api/test/auctionWorker.integration.test.ts index a93c97a1..33fb3f49 100644 --- a/app/game-api/test/auctionWorker.integration.test.ts +++ b/app/game-api/test/auctionWorker.integration.test.ts @@ -299,6 +299,14 @@ liveDescribe('auction worker durable recovery', () => { currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('2026-07-31T11:00:00.000Z'), + clockBaseTime: new Date('2026-07-31T11:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'RUNNING', + clockRevision: 1, + deadlineGeneration: 1, meta: { killturn: 24, scenarioMeta }, }; const buildGeneral = (options: { @@ -381,6 +389,14 @@ liveDescribe('auction worker durable recovery', () => { currentYear: state.currentYear, currentMonth: state.currentMonth, tickSeconds: state.tickSeconds, + clockBaseTime: state.clockBaseTime, + clockTick: BigInt(state.clockTick ?? 0), + clockMode: state.clockMode ?? 'realtime', + clockWallAnchor: state.clockWallAnchor, + lastTurnTick: BigInt(state.lastTurnTick ?? 0), + clockPhase: state.clockPhase ?? 'RUNNING', + clockRevision: BigInt(state.clockRevision ?? 1), + deadlineGeneration: BigInt(state.deadlineGeneration ?? 1), config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue, }, diff --git a/app/game-api/test/inheritOwnerMessages.integration.test.ts b/app/game-api/test/inheritOwnerMessages.integration.test.ts index 495b54ae..d135c394 100644 --- a/app/game-api/test/inheritOwnerMessages.integration.test.ts +++ b/app/game-api/test/inheritOwnerMessages.integration.test.ts @@ -64,6 +64,14 @@ const state: TurnWorldState = { currentMonth: 4, tickSeconds: 600, lastTurnTime: new Date('2026-08-19T00:00:00.000Z'), + clockBaseTime: new Date('2026-08-19T00:00:00.000Z'), + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'MANUAL', + clockRevision: 1, + deadlineGeneration: 1, meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta }, }; @@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => { currentYear: 200, currentMonth: 4, tickSeconds: 600, + clockBaseTime: state.clockBaseTime, + clockTick: BigInt(state.clockTick ?? 0), + clockMode: state.clockMode ?? 'manual', + clockWallAnchor: state.clockWallAnchor, + lastTurnTick: BigInt(state.lastTurnTick ?? 0), + clockPhase: state.clockPhase ?? 'MANUAL', + clockRevision: BigInt(state.clockRevision ?? 1), + deadlineGeneration: BigInt(state.deadlineGeneration ?? 1), config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue, }, diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts index 7f0d8567..09a4bae1 100644 --- a/app/game-engine/src/turn/actionableMessageResponse.ts +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -300,6 +300,7 @@ const respondToRaiseInvader = async (options: { authority: options.clockOperationAuthority, }); world.applyClockReconciliation(alignment); + const alignedState = world.getState(); const args = asRecord(payload.option).args; if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) { return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' }; @@ -315,14 +316,14 @@ const respondToRaiseInvader = async (options: { await handler( args, { - year: state.currentYear, - month: state.currentMonth, - startyear: asNumber(state.meta.startYear, state.currentYear), + year: alignedState.currentYear, + month: alignedState.currentMonth, + startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear), currentEventID: 0, - // Ref uses the frozen game_env.turntime while unification is paused. - // `now` is the realtime game projection and can be hours ahead after - // a long response wait, delaying every newly summoned invader turn. - turnTime: state.lastTurnTime, + // The exact reconciliation snapshot is the authority even when the + // turn rate stays unchanged. Using the pre-reconciliation cursor here + // would create immediately overdue invader turns after a long wait. + turnTime: alignedState.lastTurnTime, }, event ); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 82b96171..e72bd746 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1193,12 +1193,17 @@ export const createDatabaseTurnHooks = async ( clock_phase: string; clock_revision: bigint; deadline_generation: bigint; + clock_initialized: boolean; opening_reached: boolean; }> >(GamePrisma.sql` SELECT clock_phase, clock_revision, deadline_generation, + clock_base_time IS NOT NULL + AND clock_tick IS NOT NULL + AND clock_wall_anchor IS NOT NULL + AND last_turn_tick IS NOT NULL AS clock_initialized, clock_wall_anchor <= CURRENT_TIMESTAMP AS opening_reached FROM world_state WHERE id = ${state.id} @@ -1211,33 +1216,43 @@ export const createDatabaseTurnHooks = async ( const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'); const expectedRevision = BigInt(state.clockRevision ?? 1); const expectedGeneration = BigInt(state.deadlineGeneration ?? 1); + // Match worldLoader's dual-read boundary: a legacy row is not an + // authoritative RUNNING clock merely because the newly-added phase + // column has its database default. Its first fenced flush installs + // the complete MANUAL snapshot atomically. + const durablePhase = durableClock.clock_initialized ? durableClock.clock_phase : 'MANUAL'; const stateMeta = asRecord(state.meta); const unificationSuspensionId = typeof stateMeta.unificationClockSuspensionId === 'string' ? stateMeta.unificationClockSuspensionId : null; const openingPhaseTransition = - durableClock.clock_phase === 'PREOPEN' && expectedPhase === 'RUNNING' && durableClock.opening_reached; + durableClock.clock_initialized && + durablePhase === 'PREOPEN' && + expectedPhase === 'RUNNING' && + durableClock.opening_reached; const unificationSuspensionTransition = - durableClock.clock_phase === 'RUNNING' && + durableClock.clock_initialized && + durablePhase === 'RUNNING' && expectedPhase === 'SUSPENDED' && Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 && Boolean(unificationSuspensionId); const completionPhaseTransition = - durableClock.clock_phase === 'RUNNING' && + durableClock.clock_initialized && + durablePhase === 'RUNNING' && expectedPhase === 'COMPLETED' && Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2; if ( (!openingPhaseTransition && !unificationSuspensionTransition && !completionPhaseTransition && - durableClock.clock_phase !== expectedPhase) || + durablePhase !== expectedPhase) || durableClock.clock_revision !== expectedRevision || durableClock.deadline_generation !== expectedGeneration ) { throw new Error( `Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` + - `found ${durableClock.clock_phase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.` + `found ${durablePhase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.` ); } const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null; diff --git a/app/game-engine/test/accountIconPersistence.integration.test.ts b/app/game-engine/test/accountIconPersistence.integration.test.ts index be3d9260..25eb7149 100644 --- a/app/game-engine/test/accountIconPersistence.integration.test.ts +++ b/app/game-engine/test/accountIconPersistence.integration.test.ts @@ -283,6 +283,7 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => { command: Extract, actorUserId = command.userId ): Promise => { + const clock = world.getGameClockState(); await db.inputEvent.create({ data: { requestId: command.requestId!, @@ -294,6 +295,12 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => { leaseUntil: new Date('2026-07-31T09:30:00.000Z'), attempts: 1, payload: command as GamePrisma.InputJsonValue, + acceptedGameTick: BigInt(clock.tick), + acceptedClockRevision: BigInt(clock.revision), + acceptedDeadlineGeneration: BigInt(clock.deadlineGeneration), + processingGameTick: BigInt(clock.tick), + processingClockRevision: BigInt(clock.revision), + processingDeadlineGeneration: BigInt(clock.deadlineGeneration), }, }); }; diff --git a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts index 24d47fad..65719036 100644 --- a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts +++ b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts @@ -263,6 +263,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => { const createInputEvent = async ( command: Extract ): Promise => { + const clock = world.getGameClockState(); await db.inputEvent.create({ data: { requestId: command.requestId!, @@ -274,6 +275,12 @@ integration('inheritance action PostgreSQL atomic persistence', () => { leaseUntil: new Date('2026-08-24T01:00:00.000Z'), attempts: 1, payload: command as GamePrisma.InputJsonValue, + acceptedGameTick: BigInt(clock.tick), + acceptedClockRevision: BigInt(clock.revision), + acceptedDeadlineGeneration: BigInt(clock.deadlineGeneration), + processingGameTick: BigInt(clock.tick), + processingClockRevision: BigInt(clock.revision), + processingDeadlineGeneration: BigInt(clock.deadlineGeneration), }, }); }; diff --git a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts index 6640a4f3..0b860f1e 100644 --- a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts +++ b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts @@ -99,9 +99,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { afterAll(async () => { await hooks?.close(); if (db) { - await db.$executeRawUnsafe( - `ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}` - ); + await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`); await db.inputEvent.deleteMany({ where: { requestId } }); await db.logEntry.deleteMany({ where: { text: { startsWith: '[read-model-journal]' } } }); await db.worldState.deleteMany({ where: { id: worldId } }); @@ -187,6 +185,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { currentMonth: 2, }); + const commandClock = world.getGameClockState(); await db.inputEvent.create({ data: { requestId, @@ -194,6 +193,12 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { eventType: 'shiftSchedule', status: 'PROCESSING', payload: {}, + acceptedGameTick: BigInt(commandClock.tick), + acceptedClockRevision: BigInt(commandClock.revision), + acceptedDeadlineGeneration: BigInt(commandClock.deadlineGeneration), + processingGameTick: BigInt(commandClock.tick), + processingClockRevision: BigInt(commandClock.revision), + processingDeadlineGeneration: BigInt(commandClock.deadlineGeneration), }, }); const directResult: TurnDaemonCommandResult = { @@ -250,9 +255,9 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { { domain: 'records.history', entityId: 0, revision: 1n }, ]); await expect(db.readModelOutbox.count()).resolves.toBe(3); - await expect(db.logEntry.count({ where: { text: { startsWith: '[read-model-journal] direct' } } })).resolves.toBe( - 3 - ); + await expect( + db.logEntry.count({ where: { text: { startsWith: '[read-model-journal] direct' } } }) + ).resolves.toBe(3); world.updateWorldMeta({ queueProbe: 1 }); await hooks.hooks.flushChanges?.(turnRunResult(world)); diff --git a/app/game-engine/test/unificationInvaderResume.test.ts b/app/game-engine/test/unificationInvaderResume.test.ts index a174b54b..1e0d1ce1 100644 --- a/app/game-engine/test/unificationInvaderResume.test.ts +++ b/app/game-engine/test/unificationInvaderResume.test.ts @@ -174,6 +174,7 @@ describe('HWE-shaped unification invader resume', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] }, }); + const addGeneral = vi.spyOn(world, 'addGeneral'); const reservedTurns = new InMemoryReservedTurnStore( { generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, @@ -301,6 +302,14 @@ describe('HWE-shaped unification invader resume', () => { }); expect(world.listNations().filter((entry) => entry.name.startsWith('ⓞ'))).toHaveLength(1); expect(world.listGenerals().filter((entry) => entry.npcState === 9)).toHaveLength(10); + const initialInvaderTurnTimes = addGeneral.mock.calls + .map(([general]) => general) + .filter((general) => general.npcState === 9) + .map((general) => general.turnTime.getTime()); + const alignedMonthlyBoundary = addMinutes(liveLastTurnTime, 3).getTime(); + expect(initialInvaderTurnTimes).toHaveLength(10); + expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary); + expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000); expect(run).toHaveBeenCalledTimes(2); expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([ '2026-08-20T06:24:58.611Z', diff --git a/docs/architecture/game-clock-reconciliation.md b/docs/architecture/game-clock-reconciliation.md index 54e51878..9cb093b8 100644 --- a/docs/architecture/game-clock-reconciliation.md +++ b/docs/architecture/game-clock-reconciliation.md @@ -131,6 +131,15 @@ future anchored realtime profiles as `PREOPEN`, and other profiles as `RUNNING`. Existing DateTime columns remain projections while tick columns are authoritative. +A row is considered an initialized authoritative clock only when +`clock_base_time`, `clock_tick`, `clock_wall_anchor`, and `last_turn_tick` are +all present. Before that boundary, the loader and ordinary turn-flush fence both +treat the row as legacy `MANUAL`; the first fenced flush installs the complete +snapshot atomically instead of trusting the new column's `RUNNING` database +default. Input-event acceptance does not use that compatibility fallback: an +API or worker may enqueue gameplay only after the authoritative clock is fully +initialized. + No active participant remains `FORBID`. Tournament writes carry tick/revision/generation coordinates and are revision-fenced in Redis. Unification wait uses the same durable ledger and outbox boundary; the former diff --git a/docs/developer/game-clock-reconciliation-plan.md b/docs/developer/game-clock-reconciliation-plan.md index 0de687f0..a56fc38b 100644 --- a/docs/developer/game-clock-reconciliation-plan.md +++ b/docs/developer/game-clock-reconciliation-plan.md @@ -139,3 +139,43 @@ mean deployment or production validation. state into the next file. - User-test deployment and public runtime evidence remain deliberately open: Git push is not deployment, and no deployment was authorized in this work. + +### 2026-09-03 - completion audit + +- The invader response now reads the exact post-alignment world snapshot even + when the turn rate is unchanged. The HWE-shaped regression asserts all ten + first turns are in the aligned next-month window rather than already due. +- The ordinary flush fence now uses the same incomplete-row dual-read rule as + `worldLoader`: a legacy row is `MANUAL` until all four clock snapshot fields + exist. Input-event acceptance remains fail-closed until initialization. +- Auction OPEN/FINALIZING recovery fixtures and direct PROCESSING input-event + fixtures now carry explicit phase/revision/generation coordinates. The + optional Ref-only troop parity suite is registered only in Ref mode, so the + Core conditional gate reports only tests it actually ran. +- `pnpm check:architecture` passed with 22 authoritative fields and all 18 + required DB/JSON participants. The gate now rejects duplicate or `FORBID` + participants, missing required participants, and unimplemented/duplicate + Redis participants. +- `CI=1 TURBO_CONCURRENCY=1 pnpm test:integration:conditional` passed 79 files + and 234 tests against isolated PostgreSQL/Redis schemas with zero skipped and + zero failed files. +- On the audited diff, `pnpm check:architecture` passed, full typecheck passed + 21/21 tasks, workspace test passed 12/12 tasks, build passed 26/26 tasks, and + workspace lint completed without errors. + +The required acceptance cases map to automated evidence as follows: + +| Contract | Automated evidence | +| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| PREOPEN signed tick, tick-zero opening, executable floor; RUNNING rewind monotonicity | `packages/common/test/gameClock.test.ts`, `app/game-engine/test/scenarioSeeder.test.ts`, `app/gateway-api/test/orchestratorOperations.test.ts` | +| Exact 24-hour and 65m17.250s gaps; ordering, remaining distance, occurrence history | `app/game-engine/test/clockReconciliation.integration.test.ts`, `packages/common/test/gameClock.test.ts` | +| DB commit/Redis failure and incomplete-status recovery | `app/game-engine/test/clockReconciliation.integration.test.ts`, `app/game-engine/test/unificationFinalization.integration.test.ts` | +| Auction OPEN/FINALIZING and tournament revision races | `app/game-api/test/auctionWorker.integration.test.ts`, `app/game-api/test/tournamentStoreRevision.integration.test.ts` | +| Pending commands crossing a clock revision | `app/game-engine/test/databaseCommandQueue.integration.test.ts`, `app/game-api/test/inputEventBoundary.integration.test.ts` | +| Delayed opening | `app/gateway-api/test/orchestratorOperations.test.ts`, `app/game-engine/test/scenarioSeeder.test.ts` | +| 36-hour unification wait, rate change, deterministic retry, future invader turns | `app/game-engine/test/unificationFinalization.integration.test.ts`, `app/game-engine/test/unificationInvaderResume.test.ts` | +| DB wall despite host drift; general-access lock ordering | `app/game-engine/test/clockReconciliation.integration.test.ts`, `app/game-engine/test/profileSchemaAdvisoryLock.integration.test.ts` | +| Generated exact-gap ordering/remaining/history invariants | `packages/common/test/gameClock.test.ts` | + +Deployment and public-runtime evidence remain outside this audit and are still +open pending explicit user-test deployment authorization. diff --git a/tools/check-game-clock-participants.mjs b/tools/check-game-clock-participants.mjs index 4aa20ed9..78692b79 100644 --- a/tools/check-game-clock-participants.mjs +++ b/tools/check-game-clock-participants.mjs @@ -5,14 +5,12 @@ import path from 'node:path'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const schemaPath = path.join(root, 'packages/infra/prisma/game.prisma'); const inventoryPath = path.join(root, 'docs/architecture/game-clock-participants.json'); -const [schema, inventoryText] = await Promise.all([ - readFile(schemaPath, 'utf8'), - readFile(inventoryPath, 'utf8'), -]); +const [schema, inventoryText] = await Promise.all([readFile(schemaPath, 'utf8'), readFile(inventoryPath, 'utf8')]); const inventory = JSON.parse(inventoryText); const covered = new Set(inventory.coveredFields ?? []); const policies = new Set(inventory.policies ?? []); const failures = []; +const participantKeys = new Set(); if (inventory.tickPerTurn !== 36_000_000) { failures.push(`tickPerTurn must remain 36000000, found ${inventory.tickPerTurn}`); @@ -41,9 +39,16 @@ for (const field of discovered) { } } for (const participant of inventory.participants ?? []) { + if (participantKeys.has(participant.key)) { + failures.push(`duplicate participant key: ${participant.key}`); + } + participantKeys.add(participant.key); if (!policies.has(participant.policy)) { failures.push(`participant ${participant.key} has unknown policy ${participant.policy}`); } + if (participant.policy === 'FORBID') { + failures.push(`active participant must not remain FORBID: ${participant.key}`); + } if (!participant.owner || !Array.isArray(participant.authorityFields)) { failures.push(`participant ${participant.key} is missing owner or authorityFields`); } @@ -52,19 +57,54 @@ for (const requiredKey of [ 'world-clock', 'turn-cursor', 'general-next-turn', + 'general-recent-war-occurrence', + 'auction-open-occurrence', 'auction-deadline', + 'auction-finalizing-recovery', + 'message-occurrence', 'message-expiry', + 'vote-start-occurrence', 'vote-end-deadline', 'select-pool-reservation', 'npc-selection-window', + 'accepted-command-coordinate', 'tournament-deadlines', + 'movable-json-rule-anchors', 'unification-wait', + 'clock-operation-ledger', ]) { - if (!(inventory.participants ?? []).some((participant) => participant.key === requiredKey)) { + if (!participantKeys.has(requiredKey)) { failures.push(`required participant is missing: ${requiredKey}`); } } +const redisKeyPatterns = new Set(); +for (const entry of inventory.redis ?? []) { + if (!entry.keyPattern) { + failures.push('Redis participant is missing keyPattern'); + continue; + } + if (redisKeyPatterns.has(entry.keyPattern)) { + failures.push(`duplicate Redis participant: ${entry.keyPattern}`); + } + redisKeyPatterns.add(entry.keyPattern); + if (!policies.has(entry.policy) || entry.policy === 'FORBID') { + failures.push(`Redis participant ${entry.keyPattern} has invalid policy ${entry.policy}`); + } + if (typeof entry.status !== 'string' || !entry.status.startsWith('implemented-')) { + failures.push(`Redis participant ${entry.keyPattern} is not implemented: ${entry.status ?? 'missing status'}`); + } +} +for (const requiredKeyPattern of [ + 'sammo:{profile}:clock:active-revision', + 'sammo:{profile}:auction:timer', + 'sammo:{profile}:tournament:state', +]) { + if (!redisKeyPatterns.has(requiredKeyPattern)) { + failures.push(`required Redis participant is missing: ${requiredKeyPattern}`); + } +} + if (failures.length > 0) { console.error(failures.join('\n')); process.exitCode = 1; diff --git a/tools/integration-tests/test/troopStaticEvent.integration.test.ts b/tools/integration-tests/test/troopStaticEvent.integration.test.ts index c9bbcc04..3853a46f 100644 --- a/tools/integration-tests/test/troopStaticEvent.integration.test.ts +++ b/tools/integration-tests/test/troopStaticEvent.integration.test.ts @@ -13,7 +13,10 @@ import { } from '../src/turn-differential/referenceSnapshot.js'; const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd()); -const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); +const integration = + workspaceRoot && process.env.TURN_DIFFERENTIAL_REFERENCE === '1' + ? describe + : (_name: string, _factory: () => void): void => undefined; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const persistence = describe.skipIf(!databaseUrl); const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };