fix: 게임 시계 완료 감사 경계 보강
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -283,6 +283,7 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>,
|
||||
actorUserId = command.userId
|
||||
): Promise<void> => {
|
||||
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),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -263,6 +263,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
const createInputEvent = async (
|
||||
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
|
||||
): Promise<void> => {
|
||||
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),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user