fix: 게임 시계 완료 감사 경계 보강
This commit is contained in:
@@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
|
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 },
|
meta: { killturn: 24, scenarioMeta },
|
||||||
};
|
};
|
||||||
const lifecycleGeneral: TurnGeneral = {
|
const lifecycleGeneral: TurnGeneral = {
|
||||||
@@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
|
|||||||
currentYear: lifecycleState.currentYear,
|
currentYear: lifecycleState.currentYear,
|
||||||
currentMonth: lifecycleState.currentMonth,
|
currentMonth: lifecycleState.currentMonth,
|
||||||
tickSeconds: lifecycleState.tickSeconds,
|
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,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -299,6 +299,14 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
|
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 },
|
meta: { killturn: 24, scenarioMeta },
|
||||||
};
|
};
|
||||||
const buildGeneral = (options: {
|
const buildGeneral = (options: {
|
||||||
@@ -381,6 +389,14 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
currentYear: state.currentYear,
|
currentYear: state.currentYear,
|
||||||
currentMonth: state.currentMonth,
|
currentMonth: state.currentMonth,
|
||||||
tickSeconds: state.tickSeconds,
|
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,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: state.meta as GamePrisma.InputJsonValue,
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ const state: TurnWorldState = {
|
|||||||
currentMonth: 4,
|
currentMonth: 4,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
|
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 },
|
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 4,
|
currentMonth: 4,
|
||||||
tickSeconds: 600,
|
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,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: state.meta as GamePrisma.InputJsonValue,
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ const respondToRaiseInvader = async (options: {
|
|||||||
authority: options.clockOperationAuthority,
|
authority: options.clockOperationAuthority,
|
||||||
});
|
});
|
||||||
world.applyClockReconciliation(alignment);
|
world.applyClockReconciliation(alignment);
|
||||||
|
const alignedState = world.getState();
|
||||||
const args = asRecord(payload.option).args;
|
const args = asRecord(payload.option).args;
|
||||||
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
|
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
|
||||||
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
|
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
|
||||||
@@ -315,14 +316,14 @@ const respondToRaiseInvader = async (options: {
|
|||||||
await handler(
|
await handler(
|
||||||
args,
|
args,
|
||||||
{
|
{
|
||||||
year: state.currentYear,
|
year: alignedState.currentYear,
|
||||||
month: state.currentMonth,
|
month: alignedState.currentMonth,
|
||||||
startyear: asNumber(state.meta.startYear, state.currentYear),
|
startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear),
|
||||||
currentEventID: 0,
|
currentEventID: 0,
|
||||||
// Ref uses the frozen game_env.turntime while unification is paused.
|
// The exact reconciliation snapshot is the authority even when the
|
||||||
// `now` is the realtime game projection and can be hours ahead after
|
// turn rate stays unchanged. Using the pre-reconciliation cursor here
|
||||||
// a long response wait, delaying every newly summoned invader turn.
|
// would create immediately overdue invader turns after a long wait.
|
||||||
turnTime: state.lastTurnTime,
|
turnTime: alignedState.lastTurnTime,
|
||||||
},
|
},
|
||||||
event
|
event
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1193,12 +1193,17 @@ export const createDatabaseTurnHooks = async (
|
|||||||
clock_phase: string;
|
clock_phase: string;
|
||||||
clock_revision: bigint;
|
clock_revision: bigint;
|
||||||
deadline_generation: bigint;
|
deadline_generation: bigint;
|
||||||
|
clock_initialized: boolean;
|
||||||
opening_reached: boolean;
|
opening_reached: boolean;
|
||||||
}>
|
}>
|
||||||
>(GamePrisma.sql`
|
>(GamePrisma.sql`
|
||||||
SELECT clock_phase,
|
SELECT clock_phase,
|
||||||
clock_revision,
|
clock_revision,
|
||||||
deadline_generation,
|
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
|
clock_wall_anchor <= CURRENT_TIMESTAMP AS opening_reached
|
||||||
FROM world_state
|
FROM world_state
|
||||||
WHERE id = ${state.id}
|
WHERE id = ${state.id}
|
||||||
@@ -1211,33 +1216,43 @@ export const createDatabaseTurnHooks = async (
|
|||||||
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
|
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
|
||||||
const expectedRevision = BigInt(state.clockRevision ?? 1);
|
const expectedRevision = BigInt(state.clockRevision ?? 1);
|
||||||
const expectedGeneration = BigInt(state.deadlineGeneration ?? 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 stateMeta = asRecord(state.meta);
|
||||||
const unificationSuspensionId =
|
const unificationSuspensionId =
|
||||||
typeof stateMeta.unificationClockSuspensionId === 'string'
|
typeof stateMeta.unificationClockSuspensionId === 'string'
|
||||||
? stateMeta.unificationClockSuspensionId
|
? stateMeta.unificationClockSuspensionId
|
||||||
: null;
|
: null;
|
||||||
const openingPhaseTransition =
|
const openingPhaseTransition =
|
||||||
durableClock.clock_phase === 'PREOPEN' && expectedPhase === 'RUNNING' && durableClock.opening_reached;
|
durableClock.clock_initialized &&
|
||||||
|
durablePhase === 'PREOPEN' &&
|
||||||
|
expectedPhase === 'RUNNING' &&
|
||||||
|
durableClock.opening_reached;
|
||||||
const unificationSuspensionTransition =
|
const unificationSuspensionTransition =
|
||||||
durableClock.clock_phase === 'RUNNING' &&
|
durableClock.clock_initialized &&
|
||||||
|
durablePhase === 'RUNNING' &&
|
||||||
expectedPhase === 'SUSPENDED' &&
|
expectedPhase === 'SUSPENDED' &&
|
||||||
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 &&
|
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 &&
|
||||||
Boolean(unificationSuspensionId);
|
Boolean(unificationSuspensionId);
|
||||||
const completionPhaseTransition =
|
const completionPhaseTransition =
|
||||||
durableClock.clock_phase === 'RUNNING' &&
|
durableClock.clock_initialized &&
|
||||||
|
durablePhase === 'RUNNING' &&
|
||||||
expectedPhase === 'COMPLETED' &&
|
expectedPhase === 'COMPLETED' &&
|
||||||
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2;
|
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2;
|
||||||
if (
|
if (
|
||||||
(!openingPhaseTransition &&
|
(!openingPhaseTransition &&
|
||||||
!unificationSuspensionTransition &&
|
!unificationSuspensionTransition &&
|
||||||
!completionPhaseTransition &&
|
!completionPhaseTransition &&
|
||||||
durableClock.clock_phase !== expectedPhase) ||
|
durablePhase !== expectedPhase) ||
|
||||||
durableClock.clock_revision !== expectedRevision ||
|
durableClock.clock_revision !== expectedRevision ||
|
||||||
durableClock.deadline_generation !== expectedGeneration
|
durableClock.deadline_generation !== expectedGeneration
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` +
|
`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;
|
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
|
|||||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>,
|
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>,
|
||||||
actorUserId = command.userId
|
actorUserId = command.userId
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
const clock = world.getGameClockState();
|
||||||
await db.inputEvent.create({
|
await db.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
requestId: command.requestId!,
|
requestId: command.requestId!,
|
||||||
@@ -294,6 +295,12 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
|
|||||||
leaseUntil: new Date('2026-07-31T09:30:00.000Z'),
|
leaseUntil: new Date('2026-07-31T09:30:00.000Z'),
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
payload: command as GamePrisma.InputJsonValue,
|
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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
const createInputEvent = async (
|
const createInputEvent = async (
|
||||||
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
|
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
const clock = world.getGameClockState();
|
||||||
await db.inputEvent.create({
|
await db.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
requestId: command.requestId!,
|
requestId: command.requestId!,
|
||||||
@@ -274,6 +275,12 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
leaseUntil: new Date('2026-08-24T01:00:00.000Z'),
|
leaseUntil: new Date('2026-08-24T01:00:00.000Z'),
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
payload: command as GamePrisma.InputJsonValue,
|
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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,9 +99,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await hooks?.close();
|
await hooks?.close();
|
||||||
if (db) {
|
if (db) {
|
||||||
await db.$executeRawUnsafe(
|
await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`);
|
||||||
`ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`
|
|
||||||
);
|
|
||||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
await db.inputEvent.deleteMany({ where: { requestId } });
|
||||||
await db.logEntry.deleteMany({ where: { text: { startsWith: '[read-model-journal]' } } });
|
await db.logEntry.deleteMany({ where: { text: { startsWith: '[read-model-journal]' } } });
|
||||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
@@ -187,6 +185,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
|||||||
currentMonth: 2,
|
currentMonth: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const commandClock = world.getGameClockState();
|
||||||
await db.inputEvent.create({
|
await db.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -194,6 +193,12 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
|||||||
eventType: 'shiftSchedule',
|
eventType: 'shiftSchedule',
|
||||||
status: 'PROCESSING',
|
status: 'PROCESSING',
|
||||||
payload: {},
|
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 = {
|
const directResult: TurnDaemonCommandResult = {
|
||||||
@@ -250,9 +255,9 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
|||||||
{ domain: 'records.history', entityId: 0, revision: 1n },
|
{ domain: 'records.history', entityId: 0, revision: 1n },
|
||||||
]);
|
]);
|
||||||
await expect(db.readModelOutbox.count()).resolves.toBe(3);
|
await expect(db.readModelOutbox.count()).resolves.toBe(3);
|
||||||
await expect(db.logEntry.count({ where: { text: { startsWith: '[read-model-journal] direct' } } })).resolves.toBe(
|
await expect(
|
||||||
3
|
db.logEntry.count({ where: { text: { startsWith: '[read-model-journal] direct' } } })
|
||||||
);
|
).resolves.toBe(3);
|
||||||
|
|
||||||
world.updateWorldMeta({ queueProbe: 1 });
|
world.updateWorldMeta({ queueProbe: 1 });
|
||||||
await hooks.hooks.flushChanges?.(turnRunResult(world));
|
await hooks.hooks.flushChanges?.(turnRunResult(world));
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ describe('HWE-shaped unification invader resume', () => {
|
|||||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] },
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] },
|
||||||
});
|
});
|
||||||
|
const addGeneral = vi.spyOn(world, 'addGeneral');
|
||||||
const reservedTurns = new InMemoryReservedTurnStore(
|
const reservedTurns = new InMemoryReservedTurnStore(
|
||||||
{
|
{
|
||||||
generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
|
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.listNations().filter((entry) => entry.name.startsWith('ⓞ'))).toHaveLength(1);
|
||||||
expect(world.listGenerals().filter((entry) => entry.npcState === 9)).toHaveLength(10);
|
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).toHaveBeenCalledTimes(2);
|
||||||
expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([
|
expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([
|
||||||
'2026-08-20T06:24:58.611Z',
|
'2026-08-20T06:24:58.611Z',
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ future anchored realtime profiles as `PREOPEN`, and other profiles as
|
|||||||
`RUNNING`. Existing DateTime columns remain projections while tick columns are
|
`RUNNING`. Existing DateTime columns remain projections while tick columns are
|
||||||
authoritative.
|
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
|
No active participant remains `FORBID`. Tournament writes carry
|
||||||
tick/revision/generation coordinates and are revision-fenced in Redis.
|
tick/revision/generation coordinates and are revision-fenced in Redis.
|
||||||
Unification wait uses the same durable ledger and outbox boundary; the former
|
Unification wait uses the same durable ledger and outbox boundary; the former
|
||||||
|
|||||||
@@ -139,3 +139,43 @@ mean deployment or production validation.
|
|||||||
state into the next file.
|
state into the next file.
|
||||||
- User-test deployment and public runtime evidence remain deliberately open:
|
- User-test deployment and public runtime evidence remain deliberately open:
|
||||||
Git push is not deployment, and no deployment was authorized in this work.
|
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.
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import path from 'node:path';
|
|||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const schemaPath = path.join(root, 'packages/infra/prisma/game.prisma');
|
const schemaPath = path.join(root, 'packages/infra/prisma/game.prisma');
|
||||||
const inventoryPath = path.join(root, 'docs/architecture/game-clock-participants.json');
|
const inventoryPath = path.join(root, 'docs/architecture/game-clock-participants.json');
|
||||||
const [schema, inventoryText] = await Promise.all([
|
const [schema, inventoryText] = await Promise.all([readFile(schemaPath, 'utf8'), readFile(inventoryPath, 'utf8')]);
|
||||||
readFile(schemaPath, 'utf8'),
|
|
||||||
readFile(inventoryPath, 'utf8'),
|
|
||||||
]);
|
|
||||||
const inventory = JSON.parse(inventoryText);
|
const inventory = JSON.parse(inventoryText);
|
||||||
const covered = new Set(inventory.coveredFields ?? []);
|
const covered = new Set(inventory.coveredFields ?? []);
|
||||||
const policies = new Set(inventory.policies ?? []);
|
const policies = new Set(inventory.policies ?? []);
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
const participantKeys = new Set();
|
||||||
|
|
||||||
if (inventory.tickPerTurn !== 36_000_000) {
|
if (inventory.tickPerTurn !== 36_000_000) {
|
||||||
failures.push(`tickPerTurn must remain 36000000, found ${inventory.tickPerTurn}`);
|
failures.push(`tickPerTurn must remain 36000000, found ${inventory.tickPerTurn}`);
|
||||||
@@ -41,9 +39,16 @@ for (const field of discovered) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const participant of inventory.participants ?? []) {
|
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)) {
|
if (!policies.has(participant.policy)) {
|
||||||
failures.push(`participant ${participant.key} has unknown policy ${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)) {
|
if (!participant.owner || !Array.isArray(participant.authorityFields)) {
|
||||||
failures.push(`participant ${participant.key} is missing owner or authorityFields`);
|
failures.push(`participant ${participant.key} is missing owner or authorityFields`);
|
||||||
}
|
}
|
||||||
@@ -52,19 +57,54 @@ for (const requiredKey of [
|
|||||||
'world-clock',
|
'world-clock',
|
||||||
'turn-cursor',
|
'turn-cursor',
|
||||||
'general-next-turn',
|
'general-next-turn',
|
||||||
|
'general-recent-war-occurrence',
|
||||||
|
'auction-open-occurrence',
|
||||||
'auction-deadline',
|
'auction-deadline',
|
||||||
|
'auction-finalizing-recovery',
|
||||||
|
'message-occurrence',
|
||||||
'message-expiry',
|
'message-expiry',
|
||||||
|
'vote-start-occurrence',
|
||||||
'vote-end-deadline',
|
'vote-end-deadline',
|
||||||
'select-pool-reservation',
|
'select-pool-reservation',
|
||||||
'npc-selection-window',
|
'npc-selection-window',
|
||||||
|
'accepted-command-coordinate',
|
||||||
'tournament-deadlines',
|
'tournament-deadlines',
|
||||||
|
'movable-json-rule-anchors',
|
||||||
'unification-wait',
|
'unification-wait',
|
||||||
|
'clock-operation-ledger',
|
||||||
]) {
|
]) {
|
||||||
if (!(inventory.participants ?? []).some((participant) => participant.key === requiredKey)) {
|
if (!participantKeys.has(requiredKey)) {
|
||||||
failures.push(`required participant is missing: ${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) {
|
if (failures.length > 0) {
|
||||||
console.error(failures.join('\n'));
|
console.error(failures.join('\n'));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
} from '../src/turn-differential/referenceSnapshot.js';
|
} from '../src/turn-differential/referenceSnapshot.js';
|
||||||
|
|
||||||
const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
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 databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
const persistence = describe.skipIf(!databaseUrl);
|
const persistence = describe.skipIf(!databaseUrl);
|
||||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
|
|||||||
Reference in New Issue
Block a user