refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GameClock } from '@sammo-ts/common';
|
||||
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
readTurnRuntimeReady,
|
||||
createRedisConnector,
|
||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
|
||||
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
||||
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
||||
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
|
||||
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
|
||||
|
||||
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
|
||||
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
|
||||
@@ -62,6 +65,158 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
await clean();
|
||||
});
|
||||
|
||||
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
|
||||
const profile = 'recovery-startup';
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: profile,
|
||||
currentYear: 199,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 3600,
|
||||
clockBaseTime: new Date('0199-01-01T00:00:00Z'),
|
||||
clockTick: repeated ? BigInt(4 * T) : 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date(Date.now() - 4 * 3_600_000),
|
||||
lastTurnTick: repeated ? BigInt(4 * T) : 0n,
|
||||
...(repeated
|
||||
? {
|
||||
clockRecoveryStartTick: 0n,
|
||||
clockRecoveryEndTick: BigInt(8 * T),
|
||||
clockRecoveryStartWallAt: new Date(Date.now() - 6 * 3_600_000),
|
||||
}
|
||||
: {}),
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
},
|
||||
});
|
||||
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
|
||||
try {
|
||||
const token = await lease.acquire();
|
||||
expect(token).not.toBeNull();
|
||||
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
|
||||
const authority = {
|
||||
kind: 'DAEMON' as const,
|
||||
profileName: profile,
|
||||
ownerId: token!.ownerId,
|
||||
fencingEpoch: token!.fencingEpoch,
|
||||
};
|
||||
if (!repeated) {
|
||||
await prepareRealtimeRecovery(db, authority, { paused: true });
|
||||
const paused = await db.worldState.findFirstOrThrow();
|
||||
expect(paused.clockPhase).toBe('SUSPENDED');
|
||||
expect(paused.clockTick).toBe(0n);
|
||||
expect((await db.clockSuspension.findFirstOrThrow()).status).toBe('SUSPENDED');
|
||||
await prepareRealtimeRecovery(db, authority, { paused: true });
|
||||
expect((await db.worldState.findFirstOrThrow()).clockPhase).toBe('SUSPENDED');
|
||||
}
|
||||
await prepareRealtimeRecovery(db, authority);
|
||||
const pending = await db.worldState.findFirstOrThrow();
|
||||
expect(pending.clockPhase).toBe('RECONCILING');
|
||||
const recoveredWindow = readTurnRecovery(pending)!;
|
||||
expect(recoveredWindow).not.toBeNull();
|
||||
expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 12 : 8) * T);
|
||||
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
|
||||
await applyNextClockProjection({ db, redis: redis.client, workerId: profile });
|
||||
await lease.markClockReady();
|
||||
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(true);
|
||||
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
|
||||
await lease.acquire();
|
||||
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
|
||||
await prepareRealtimeRecovery(db, {
|
||||
kind: 'DAEMON',
|
||||
profileName: profile,
|
||||
ownerId: token!.ownerId,
|
||||
fencingEpoch: token!.fencingEpoch,
|
||||
});
|
||||
const reloaded = await db.worldState.findFirstOrThrow();
|
||||
expect(readTurnRecovery(reloaded)).toEqual(readTurnRecovery(pending));
|
||||
expect(reloaded.clockRevision).toBe(pending.clockRevision);
|
||||
} finally {
|
||||
await lease.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([4, 12, 13, 23, 24])(
|
||||
'persists recovery for %i turns and reloads the same normal boundary',
|
||||
async (turns) => {
|
||||
const now = new Date();
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'turn-recovery',
|
||||
currentYear: 199,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 3600,
|
||||
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: now,
|
||||
lastTurnTick: 0n,
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
},
|
||||
});
|
||||
const generalTick = T + 199_020;
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: 26,
|
||||
name: '냥냥',
|
||||
turnTick: BigInt(generalTick),
|
||||
turnTime: new Date('2026-01-01T01:00:19.902Z'),
|
||||
meta: { purchasedPhase: true },
|
||||
},
|
||||
});
|
||||
const authority = { kind: 'OFFLINE' as const, profileName: 'recovery-test', reason: 'fixture' };
|
||||
const suspension = await startClockSuspension({
|
||||
db,
|
||||
suspensionId: 'recovery-test',
|
||||
source: 'MAINTENANCE',
|
||||
policy: turns === 4 ? 'EXACT' : 'RECOVER_TURNS',
|
||||
authority,
|
||||
});
|
||||
const resumedAt = new Date(suspension.cutWallAt.getTime() + turns * 3_600_000);
|
||||
const plan = await reconcileClockSuspension({
|
||||
db,
|
||||
suspensionId: suspension.suspensionId,
|
||||
authority,
|
||||
testResumeWallAt: resumedAt,
|
||||
upgradeMaintenancePolicy: true,
|
||||
});
|
||||
expect(plan.shiftTicks).toBe(Math.floor(turns / 12) * 12 * T);
|
||||
await applyNextClockProjection({ db, redis: redis.client, workerId: 'recovery-test' });
|
||||
const row = await db.worldState.findFirstOrThrow();
|
||||
const recovery = readTurnRecovery(row);
|
||||
const reloaded = new GameClock({
|
||||
baseTime: row.clockBaseTime!,
|
||||
tick: Number(row.clockTick),
|
||||
wallAnchor: row.clockWallAnchor!,
|
||||
mode: 'realtime',
|
||||
turnSeconds: row.tickSeconds,
|
||||
recovery,
|
||||
});
|
||||
expect(row.currentMonth).toBe(4);
|
||||
expect(row.lastTurnTick).toBe(BigInt(plan.shiftTicks));
|
||||
const general = await db.general.findUniqueOrThrow({ where: { id: 26 } });
|
||||
expect(general.turnTick).toBe(BigInt(generalTick + plan.shiftTicks));
|
||||
expect(general.turnTime.toISOString().slice(14)).toBe('00:19.902Z');
|
||||
expect(general.meta).toEqual({ purchasedPhase: true });
|
||||
if (turns % 12 === 0) {
|
||||
expect(recovery).toBeNull();
|
||||
} else {
|
||||
expect(recovery).not.toBeNull();
|
||||
const end = reloaded.tickToWallDate(recovery!.endTick);
|
||||
expect(reloaded.nowTick(end)).toBe(recovery!.endTick);
|
||||
expect(reloaded.executionRate(new Date(end.getTime() - 1))).toBe(2);
|
||||
expect(reloaded.executionRate(end)).toBe(1);
|
||||
expect(reloaded.tickToWallDate(recovery!.endTick + 199_020).getTime() - end.getTime()).toBe(19_902);
|
||||
}
|
||||
const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority });
|
||||
expect(retry.recovery).toEqual(plan.recovery);
|
||||
expect(retry.catchUpTicks).toBe(plan.catchUpTicks);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([3_142_625, 6 * 3_600_000 + 3_142_625])(
|
||||
'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload',
|
||||
async (gapMilliseconds) => {
|
||||
|
||||
@@ -190,12 +190,12 @@ describe('in-memory scenario general pool availability', () => {
|
||||
const before = world.captureState();
|
||||
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
|
||||
|
||||
world.shiftSchedule(15, claimedAt);
|
||||
world.shiftSchedule(20, claimedAt);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
|
||||
reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000),
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
},
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { GAME_TICKS_PER_TURN, planTurnRecovery } from '@sammo-ts/common';
|
||||
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
|
||||
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
|
||||
@@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
|
||||
};
|
||||
|
||||
describe('runtime clock shift', () => {
|
||||
it('runs two real monthly cycles per normal interval, survives reload, and returns to one cycle', async () => {
|
||||
const base = new Date('2026-07-30T10:00:00Z');
|
||||
const wallAt = (minutes: number) => new Date(base.getTime() + minutes * 60_000);
|
||||
const plan = planTurnRecovery({
|
||||
observedTick: 0,
|
||||
normalTick: 4 * GAME_TICKS_PER_TURN,
|
||||
wallNow: wallAt(40),
|
||||
turnSeconds: 600,
|
||||
});
|
||||
let world = buildWorld({
|
||||
clockBaseTime: base,
|
||||
clockTick: 0,
|
||||
clockWallAnchor: wallAt(40),
|
||||
clockRecovery: plan.recovery,
|
||||
clockMode: 'realtime',
|
||||
clockPhase: 'RUNNING',
|
||||
lastTurnTick: 0,
|
||||
});
|
||||
for (const [id, milliseconds] of [
|
||||
[1, 19_902],
|
||||
[2, 42_001],
|
||||
]) {
|
||||
world.updateGeneral(id!, {
|
||||
turnTime: new Date(base.getTime() + milliseconds!),
|
||||
turnTick: milliseconds! * 60,
|
||||
});
|
||||
}
|
||||
const executions: Array<[number, number, boolean]> = [];
|
||||
const processorForWorld = () =>
|
||||
new InMemoryTurnProcessor(world, {
|
||||
afterExecuteGeneral: async (general, result) => {
|
||||
executions.push([world.getState().currentMonth, general.id, result.ok]);
|
||||
},
|
||||
});
|
||||
let processor = processorForWorld();
|
||||
for (let minutes = 45; minutes <= 80; minutes += 5) {
|
||||
const now = wallAt(minutes);
|
||||
const target = world.getGameNow(now);
|
||||
world.advanceGameClockTo(target, now);
|
||||
const result = await processor.run(target, { budgetMs: 10_000, maxGenerals: 10, catchUpCap: 1 });
|
||||
expect(result).toMatchObject({ processedGenerals: 2, processedTurns: 1, partial: false });
|
||||
if (minutes === 60) {
|
||||
const snapshot = world.captureState();
|
||||
world = buildWorld();
|
||||
world.restoreState(snapshot);
|
||||
processor = processorForWorld();
|
||||
}
|
||||
}
|
||||
expect(executions).toEqual(
|
||||
Array.from({ length: 8 }, (_, month) => [
|
||||
[month + 1, 1, true],
|
||||
[month + 1, 2, true],
|
||||
]).flat()
|
||||
);
|
||||
expect(world.getState().currentMonth).toBe(9);
|
||||
expect(world.getGeneralById(1)!.turnTime).toEqual(new Date(wallAt(80).getTime() + 19_902));
|
||||
const next = await processor.run(world.getGameNow(wallAt(90)), {
|
||||
budgetMs: 10_000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
expect(next).toMatchObject({ processedGenerals: 2, processedTurns: 1 });
|
||||
expect(world.getState().currentMonth).toBe(10);
|
||||
});
|
||||
it('preserves the scenario config when the raw world config is unavailable', () => {
|
||||
const world = buildWorld();
|
||||
|
||||
@@ -99,7 +164,7 @@ describe('runtime clock shift', () => {
|
||||
['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'],
|
||||
['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'],
|
||||
] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => {
|
||||
const world = buildWorld();
|
||||
const world = buildWorld({ tickSeconds: 900 });
|
||||
world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 });
|
||||
world.queueNeutralAuction({
|
||||
registrationKey: 'test',
|
||||
@@ -132,7 +197,7 @@ describe('runtime clock shift', () => {
|
||||
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it.each([0, 1.5, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
|
||||
it.each([0, 1.5, 15, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
|
||||
const world = buildWorld();
|
||||
expect(() => world.shiftSchedule(delta)).toThrow();
|
||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z');
|
||||
@@ -140,7 +205,7 @@ describe('runtime clock shift', () => {
|
||||
});
|
||||
|
||||
it('keeps legacy wall-clock metadata independent from the process timezone', () => {
|
||||
const world = buildWorld();
|
||||
const world = buildWorld({ tickSeconds: 900 });
|
||||
|
||||
world.shiftSchedule(-15);
|
||||
|
||||
@@ -212,7 +277,7 @@ describe('runtime clock shift', () => {
|
||||
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
|
||||
});
|
||||
|
||||
it('preserves the formal wall opening when PREOPEN game display dates are shifted', () => {
|
||||
it('moves the formal opening together with an explicit whole-turn schedule shift', () => {
|
||||
const openAt = new Date('2026-09-06T00:00:00.000Z');
|
||||
const now = new Date('2026-09-05T00:00:00.000Z');
|
||||
const world = buildWorld({
|
||||
@@ -223,11 +288,13 @@ describe('runtime clock shift', () => {
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'PREOPEN',
|
||||
});
|
||||
world.shiftSchedule(15, now);
|
||||
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: openAt });
|
||||
world.shiftSchedule(20, now);
|
||||
const shiftedOpen = new Date(openAt.getTime() + 20 * 60_000);
|
||||
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: shiftedOpen });
|
||||
expect(world.promotePreopenAtOpening(now)).toBe(false);
|
||||
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:15:00.000Z'));
|
||||
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
|
||||
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:20:00.000Z'));
|
||||
expect(world.promotePreopenAtOpening(openAt)).toBe(false);
|
||||
expect(world.promotePreopenAtOpening(shiftedOpen)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects gameplay commits while the durable clock is suspended', async () => {
|
||||
@@ -309,7 +376,7 @@ describe('runtime clock shift', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs an already accumulated realtime projection lag during a long rebase', () => {
|
||||
it('preserves the normal game-to-wall mapping during a whole-year rebase', () => {
|
||||
const base = new Date('2026-07-30T10:00:00.000Z');
|
||||
const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
|
||||
const resumedAt = new Date('2026-07-30T11:50:00.000Z');
|
||||
@@ -325,7 +392,7 @@ describe('runtime clock shift', () => {
|
||||
|
||||
expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z');
|
||||
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
|
||||
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
|
||||
expect(world.getGameNow(resumedAt)).toEqual(new Date('2026-07-30T11:15:00.000Z'));
|
||||
});
|
||||
|
||||
it('does not lose realtime elapsed time when an overdue target is committed later', () => {
|
||||
|
||||
@@ -244,7 +244,7 @@ integration('runtime clock shift persistence', () => {
|
||||
type: 'shiftSchedule',
|
||||
requestId,
|
||||
actionId,
|
||||
deltaMinutes: -15,
|
||||
deltaMinutes: -20,
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
@@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z');
|
||||
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55:00.000Z');
|
||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:40:00.000Z');
|
||||
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:50:00.000Z');
|
||||
expect(await stateStore.loadCheckpoint()).toMatchObject({
|
||||
turnTime: '2099-07-30T09:45:00.000Z',
|
||||
turnTime: '2099-07-30T09:40:00.000Z',
|
||||
generalId: 0,
|
||||
});
|
||||
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z');
|
||||
// 예정된 재개 경계까지 대기하며, 그 뒤 첫 장수의 시각을 다시 계산한다.
|
||||
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:40:00.000Z');
|
||||
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
|
||||
expect(storedWorld.meta).toMatchObject({
|
||||
lastTurnTime: '2099-07-30T09:45:00.000Z',
|
||||
starttime: '2099-06-30 23:45:00',
|
||||
lastTurnTime: '2099-07-30T09:40:00.000Z',
|
||||
starttime: '2099-06-30 23:40:00',
|
||||
});
|
||||
expect(storedWorld.clockTick).toBe(0n);
|
||||
expect(storedWorld.lastTurnTick).toBe(0n);
|
||||
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
|
||||
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z');
|
||||
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:00:00.000Z');
|
||||
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
|
||||
const storedAuctions = await db.auction.findMany({
|
||||
where: { id: { in: auctionRows.map((auction) => auction.id) } },
|
||||
});
|
||||
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
|
||||
expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([
|
||||
'2099-07-30T09:45:00.000Z',
|
||||
'2099-07-30T09:40:00.000Z',
|
||||
'2099-07-30T11:00:00.000Z',
|
||||
'2099-07-30T12:00:00.000Z',
|
||||
'2099-07-30T13:00:00.000Z',
|
||||
@@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => {
|
||||
type: 'shiftSchedule',
|
||||
ok: true,
|
||||
actionId,
|
||||
deltaMinutes: -15,
|
||||
deltaMinutes: -20,
|
||||
shiftedGenerals: 2,
|
||||
shiftedAuctions: 1,
|
||||
},
|
||||
|
||||
@@ -611,7 +611,7 @@ integration('unification finalization transaction', () => {
|
||||
const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } });
|
||||
expect(suspension).toMatchObject({
|
||||
source: 'UNIFICATION_WAIT',
|
||||
policy: 'EXACT',
|
||||
policy: 'TURN_BOUNDARY',
|
||||
status: 'SUSPENDED',
|
||||
sourceRevision: 1n,
|
||||
targetRevision: 2n,
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
tickSeconds: 60,
|
||||
lastTurnTime: liveLastTurnTime,
|
||||
clockBaseTime: new Date('2026-08-19T12:00:00.000Z'),
|
||||
clockTick: 19_980_000_000,
|
||||
clockTick: 19_944_000_000,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: liveWallAnchor,
|
||||
lastTurnTick: 19_944_000_000,
|
||||
@@ -202,7 +202,7 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
actorUserId: recipient.userId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'messageRespond',
|
||||
processingGameTick: 19_980_000_000n,
|
||||
processingGameTick: 19_944_000_000n,
|
||||
})),
|
||||
},
|
||||
messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) },
|
||||
@@ -214,11 +214,11 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
id: 1014,
|
||||
mailbox: recipient.id,
|
||||
type: 'private',
|
||||
time: world.gameTickToDate(19_980_000_000),
|
||||
time: world.gameTickToDate(19_944_000_000),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
actionType: 'raiseInvader',
|
||||
actionStatus: 'PENDING',
|
||||
createdGameTick: 19_980_000_000n,
|
||||
createdGameTick: 19_944_000_000n,
|
||||
expiresGameTick: null,
|
||||
message,
|
||||
},
|
||||
@@ -256,13 +256,22 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
gapTicks: 108_000_000,
|
||||
catchUpTicks: 0,
|
||||
shiftTicks: 108_000_000,
|
||||
alignedTick: 20_088_000_000,
|
||||
alignedTick: 20_052_000_000,
|
||||
resumeWallAt: acceptedAt,
|
||||
resumeAnchor: new Date('2026-08-20T06:25:00Z'),
|
||||
}),
|
||||
});
|
||||
const processor = new InMemoryTurnProcessor(world);
|
||||
const clock = new ManualClock(acceptedAt.getTime());
|
||||
vi.spyOn(queue, 'waitFor').mockImplementation(async (milliseconds) => {
|
||||
await clock.sleepMs(milliseconds ?? 0);
|
||||
const pending = await queue.drain();
|
||||
for (const command of pending.slice(1)) queue.enqueue(command);
|
||||
return pending[0] ?? null;
|
||||
});
|
||||
const runWallTimes: Date[] = [];
|
||||
const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => {
|
||||
runWallTimes.push(new Date(clock.nowMs()));
|
||||
const result = await processor.run(...args);
|
||||
if (world.getState().currentMonth === 4) {
|
||||
queue.enqueue({ type: 'shutdown', reason: 'resumed monthly boundary verified' });
|
||||
@@ -307,7 +316,7 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
expect(commandDb.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(commandDb.messageAction.updateMany).toHaveBeenCalledWith({
|
||||
where: { messageId: { in: [1014] }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: 20_088_000_000n },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: 20_052_000_000n },
|
||||
});
|
||||
expect(world.getState()).toMatchObject({
|
||||
currentYear: 226,
|
||||
@@ -324,11 +333,11 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
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',
|
||||
'2026-08-20T06:25:00.000Z',
|
||||
]);
|
||||
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-20T06:25:00.000Z');
|
||||
expect(run).toHaveBeenCalled();
|
||||
expect(runWallTimes.every((time) => time >= new Date('2026-08-20T06:25:00Z'))).toBe(true);
|
||||
const nextMonth = addMinutes(liveLastTurnTime, 4);
|
||||
expect(run.mock.calls.every(([targetTime]) => targetTime <= nextMonth)).toBe(true);
|
||||
expect(run.mock.calls.at(-1)![0]).toEqual(nextMonth);
|
||||
expect(lifecycle.getStatus().lastTurnTime).toBe(nextMonth.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user