feat: 통일 대기 시계를 원자적 reconciliation으로 전환

This commit is contained in:
2026-09-03 10:18:44 +00:00
parent a9f23703d9
commit 69ac6028df
22 changed files with 1300 additions and 251 deletions
@@ -1,9 +1,12 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { GameClock } from '@sammo-ts/common';
import {
createGamePostgresConnector,
createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
@@ -22,20 +25,7 @@ describeIntegration('durable clock reconciliation', () => {
let disconnect: (() => Promise<void>) | undefined;
let redis: RedisConnector;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
db = connector.prisma;
disconnect = connector.disconnect;
redis = createRedisConnector({ url: process.env.REDIS_URL! });
await redis.connect();
});
afterAll(async () => {
await redis.disconnect();
await disconnect?.();
});
beforeEach(async () => {
const clean = async (): Promise<void> => {
await redis.client.flushDb();
await db.$transaction([
db.clockProjectionOutbox.deleteMany(),
@@ -54,6 +44,24 @@ describeIntegration('durable clock reconciliation', () => {
db.turnDaemonLease.deleteMany(),
db.worldState.deleteMany(),
]);
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
db = connector.prisma;
disconnect = connector.disconnect;
redis = createRedisConnector({ url: process.env.REDIS_URL! });
await redis.connect();
});
afterAll(async () => {
await clean();
await redis.disconnect();
await disconnect?.();
});
beforeEach(async () => {
await clean();
});
it('preserves every remaining deadline and occurrence across a 65m17.250s exact gap', async () => {
@@ -246,9 +254,9 @@ describeIntegration('durable clock reconciliation', () => {
);
await redis.client.set('sammo:clock-test:clock:active-revision', '1');
expect(
await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-success' })
).toBe('APPLIED');
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-success' })).toBe(
'APPLIED'
);
expect(await redis.client.get('sammo:clock-test:clock:active-revision')).toBe('2');
expect(await redis.client.get('sammo:clock-test:clock:deadline-generation')).toBe('8');
expect(await redis.client.get('sammo:clock-test:clock:phase')).toBe('RUNNING');
@@ -355,10 +363,85 @@ describeIntegration('durable clock reconciliation', () => {
expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'FAILED', attempts: 1 });
await db.clockProjectionOutbox.updateMany({ data: { availableAt: new Date(0) } });
expect(
await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-restart' })
).toBe('RECOVERED');
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-restart' })).toBe(
'RECOVERED'
);
expect(await db.worldState.findFirstOrThrow()).toMatchObject({ clockPhase: 'RUNNING', clockRevision: 4n });
expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'APPLIED', attempts: 2 });
});
it('uses DB wall time despite host drift and does not deadlock with a general-access writer', async () => {
const [dbWall] = await db.$queryRaw<Array<{ now: Date }>>(GamePrisma.sql`
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS now
`);
const baseTime = new Date('2026-03-01T00:00:00.000Z');
const clock = new GameClock({
baseTime,
tick: 42,
mode: 'realtime',
wallAnchor: dbWall!.now,
turnSeconds: 600,
phase: 'RUNNING',
revision: 1,
});
const world = await db.worldState.create({
data: {
scenarioCode: 'clock-drift-deadlock-test',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: baseTime,
clockTick: 42n,
clockMode: 'realtime',
clockWallAnchor: dbWall!.now,
lastTurnTick: 42n,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
await db.general.create({
data: { id: 1, name: 'lock-general', turnTick: 100n, turnTime: clock.tickToDate(100) },
});
let releaseWriter!: () => void;
let signalWriterLocked!: () => void;
const writerLocked = new Promise<void>((resolve) => {
signalWriterLocked = resolve;
});
const writerRelease = new Promise<void>((resolve) => {
releaseWriter = resolve;
});
const writer = db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
signalWriterLocked();
await writerRelease;
await transaction.$queryRaw(GamePrisma.sql`
SELECT id FROM world_state WHERE id = ${world.id} FOR UPDATE
`);
});
await writerLocked;
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(dbWall!.now.getTime() + 12 * 60 * 60_000);
try {
const suspensionPromise = startClockSuspension({
db,
suspensionId: 'clock-host-drift-deadlock',
source: 'MAINTENANCE',
authority: { kind: 'OFFLINE', profileName: 'clock-drift-deadlock-test', reason: 'fixture' },
});
releaseWriter();
const suspension = await Promise.race([
Promise.all([writer, suspensionPromise]).then(([, result]) => result),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('general-access/clock-operation deadlock')), 5_000)
),
]);
expect(Math.abs(suspension.cutWallAt.getTime() - dbWall!.now.getTime())).toBeLessThan(5_000);
expect(suspension.cutTick).toBeGreaterThanOrEqual(42);
expect(suspension.cutTick).toBeLessThan(42 + 60 * 60_000);
} finally {
dateNow.mockRestore();
releaseWriter();
}
});
});
@@ -27,7 +27,13 @@ integration('database command queue', () => {
beforeEach(async () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'integration:engine:' } } });
await db.clockSuspension.deleteMany({ where: { id: 'integration-queue-revision-8-9' } });
await db.clockProjectionOutbox.deleteMany({
where: { suspensionId: { in: ['integration-queue-revision-8-9', 'integration-unification-wait'] } },
});
await db.clockSuspension.deleteMany({
where: { id: { in: ['integration-queue-revision-8-9', 'integration-unification-wait'] } },
});
await db.message.deleteMany({ where: { mailbox: 991_199 } });
await db.worldState.updateMany({ data: { clockPhase: 'RUNNING' } });
});
@@ -350,4 +356,103 @@ integration('database command queue', () => {
processingDeadlineGeneration: 4n,
});
});
it('dequeues only the invader decision while an UNIFICATION_WAIT suspension is active', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
? await db.worldState.update({
where: { id: existingWorld.id },
data: { clockPhase: 'SUSPENDED', clockRevision: 31n, deadlineGeneration: 7n, clockTick: 900n },
})
: await db.worldState.create({
data: {
scenarioCode: 'queue-unification-clock-test',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
clockPhase: 'SUSPENDED',
clockRevision: 31n,
deadlineGeneration: 7n,
clockTick: 900n,
},
});
const message = await db.message.create({
data: {
mailbox: 991_199,
type: 'private',
src: 0,
dest: 991_199,
time: new Date(),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
message: { option: { action: 'raiseInvader', used: false } },
},
});
await db.clockSuspension.create({
data: {
id: 'integration-unification-wait',
worldStateId: world.id,
source: 'UNIFICATION_WAIT',
policy: 'EXACT',
status: 'SUSPENDED',
sourceRevision: 31n,
targetRevision: 32n,
cutTick: 900n,
cutWallAt: new Date(),
rateTicksPerSecond: 60_000,
},
});
const messageRequestId = 'integration:engine:unification-message';
const gameplayRequestId = 'integration:engine:unification-gameplay';
await db.inputEvent.createMany({
data: [
{
requestId: messageRequestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: 'user-991199',
acceptedGameTick: 900n,
acceptedClockRevision: 31n,
acceptedDeadlineGeneration: 7n,
payload: {
type: 'messageRespond',
requestId: messageRequestId,
userId: 'user-991199',
generalId: 991_199,
messageId: message.id,
response: true,
},
},
{
requestId: gameplayRequestId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-991199',
acceptedGameTick: 900n,
acceptedClockRevision: 31n,
acceptedDeadlineGeneration: 7n,
payload: {
type: 'vacation',
requestId: gameplayRequestId,
userId: 'user-991199',
generalId: 991_199,
},
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([
{
type: 'messageRespond',
requestId: messageRequestId,
userId: 'user-991199',
generalId: 991_199,
messageId: message.id,
response: true,
},
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
).resolves.toMatchObject({ status: 'PENDING' });
});
});
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { GAME_TICKS_PER_TURN, GameClock, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { createAuctionBidder } from '../src/auction/bidder.js';
@@ -14,6 +14,10 @@ import { createMergeInheritPointRankHandler } from '../src/turn/monthlyUniqueInh
import { loadPendingUnificationAuctionCancellations } from '../src/turn/unificationAuctionCancellation.js';
import { createUnificationHandler } from '../src/turn/unificationHandler.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { reconcileClockSuspensionInTransaction } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -22,15 +26,26 @@ const serverId = 'che_unification_atomicity_fixture';
const profileName = 'che';
const userId = 'unification-atomicity-user';
const legacyOfficerPicture = 'users/core/a369f064a434262b025bd2ebc70c60d5.jpg?=20260814';
const invaderCityId = fixtureId + 1;
const invaderNationId = fixtureId + 1;
const invaderGeneralIds = Array.from({ length: 10 }, (_, index) => fixtureId + 1 + index);
integration('unification finalization transaction', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
const cleanup = async (): Promise<void> => {
await db.clockProjectionOutbox.deleteMany({
where: { suspension: { worldState: { scenarioCode: 'unification-atomicity-fixture' } } },
});
await db.clockSuspension.deleteMany({
where: { worldState: { scenarioCode: 'unification-atomicity-fixture' } },
});
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'unification-clock:' } } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await db.message.deleteMany({ where: { mailbox: fixtureId } });
await db.auction.deleteMany({ where: { hostGeneralId: fixtureId } });
await db.event.deleteMany({ where: { id: fixtureId } });
await db.event.deleteMany({ where: { id: { in: [fixtureId, fixtureId + 1, fixtureId + 2] } } });
await db.unificationFinalization.deleteMany({ where: { serverId } });
await db.yearbookHistory.deleteMany({ where: { profileName: serverId } });
await db.emperor.deleteMany({ where: { serverId } });
@@ -44,10 +59,15 @@ integration('unification finalization transaction', () => {
await db.logEntry.deleteMany({
where: { OR: [{ generalId: fixtureId }, { year: 190, month: 7 }] },
});
await db.rankData.deleteMany({ where: { generalId: fixtureId } });
await db.general.deleteMany({ where: { id: fixtureId } });
await db.city.deleteMany({ where: { id: fixtureId } });
await db.nation.deleteMany({ where: { id: fixtureId } });
await db.generalTurn.deleteMany({ where: { generalId: { in: invaderGeneralIds } } });
await db.nationTurn.deleteMany({ where: { nationId: invaderNationId } });
await db.diplomacy.deleteMany({
where: { OR: [{ srcNationId: invaderNationId }, { destNationId: invaderNationId }] },
});
await db.rankData.deleteMany({ where: { generalId: { in: [fixtureId, ...invaderGeneralIds] } } });
await db.general.deleteMany({ where: { id: { in: [fixtureId, ...invaderGeneralIds] } } });
await db.city.deleteMany({ where: { id: { in: [fixtureId, invaderCityId] } } });
await db.nation.deleteMany({ where: { id: { in: [fixtureId, invaderNationId] } } });
await db.worldState.deleteMany({ where: { scenarioCode: 'unification-atomicity-fixture' } });
};
@@ -90,7 +110,31 @@ integration('unification finalization transaction', () => {
id: fixtureId,
name: '원자도시',
nationId: fixtureId,
level: 1,
level: 3,
population: 1_000,
populationMax: 2_000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
supplyState: 1,
frontState: 0,
region: 1,
meta: { state: 0 },
},
});
await db.city.create({
data: {
id: invaderCityId,
name: '남만',
nationId: fixtureId,
level: 4,
population: 1_000,
populationMax: 2_000,
agriculture: 100,
@@ -249,6 +293,9 @@ integration('unification finalization transaction', () => {
season: 1,
scenarioId: 2,
refreshLimit: 2,
maxGeneralsPerMinute: 1,
lastGeneralId: fixtureId,
lastNationId: fixtureId,
scenarioMeta: {
title: '원자성 시나리오',
startYear: 190,
@@ -311,6 +358,38 @@ integration('unification finalization transaction', () => {
expect.objectContaining({ inheritSpentTrackedAmount: 50 }),
]);
const clockBaseTime = new Date('0190-01-01T00:00:00.000Z');
const clockWallAnchor = new Date('2030-01-01T00:00:00.000Z');
const fixtureClock = new GameClock({
baseTime: clockBaseTime,
tick: 0,
mode: 'realtime',
wallAnchor: clockWallAnchor,
turnSeconds: 600,
phase: 'RUNNING',
revision: 1,
});
const initialClockTick = fixtureClock.dateToTick(new Date('0190-06-01T00:00:00.000Z'));
await db.worldState.update({
where: { id: worldRow.id },
data: {
clockBaseTime,
clockTick: BigInt(initialClockTick),
clockMode: 'realtime',
clockWallAnchor,
lastTurnTick: BigInt(initialClockTick),
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
await db.auction.updateMany({
where: { id: { in: [uniqueAuction.id, resourceAuction.id] } },
data: {
openTick: BigInt(initialClockTick),
closeTick: BigInt(fixtureClock.dateToTick(futureCloseAt)),
},
});
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
let world: InMemoryTurnWorld | null = null;
const actions = new Map<string, MonthlyEventActionHandler>();
@@ -326,7 +405,8 @@ integration('unification finalization transaction', () => {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: composeCalendarHandlers(events, unification.handler),
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName });
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 });
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName, reservedTurns });
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world!.captureState(),
@@ -343,6 +423,7 @@ integration('unification finalization transaction', () => {
const beforeFailedTurn = world.captureState();
await expect(
stateManager.transaction(async () => {
world!.advanceGameClockTo(new Date('0190-07-01T00:00:00.000Z'), clockWallAnchor);
await world!.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
expect(world!.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 });
expect(world!.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1);
@@ -381,6 +462,7 @@ integration('unification finalization transaction', () => {
},
});
await stateManager.transaction(async () => {
world!.advanceGameClockTo(new Date('0190-07-01T00:00:00.000Z'), clockWallAnchor);
await world!.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
await hooks.hooks.flushChanges?.(runResult);
});
@@ -512,7 +594,190 @@ integration('unification finalization transaction', () => {
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
currentYear: 190,
currentMonth: 7,
clockPhase: 'SUSPENDED',
});
const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } });
expect(suspension).toMatchObject({
source: 'UNIFICATION_WAIT',
policy: 'EXACT',
status: 'SUSPENDED',
sourceRevision: 1n,
targetRevision: 2n,
});
const invaderPrompt = (await db.message.findMany({ where: { mailbox: fixtureId } })).find((row) => {
const payload = row.message as { option?: { action?: unknown } };
return payload.option?.action === 'raiseInvader';
});
expect(invaderPrompt).toBeDefined();
const requestId = 'unification-clock:raise-invader';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: userId,
payload: {
type: 'messageRespond',
requestId,
userId,
generalId: fixtureId,
messageId: invaderPrompt!.id,
response: true,
},
status: 'PROCESSING',
acceptedGameTick: suspension.cutTick,
acceptedClockRevision: suspension.sourceRevision,
acceptedDeadlineGeneration: 1n,
processingAt: new Date(),
processingGameTick: suspension.cutTick,
processingClockRevision: suspension.sourceRevision,
processingDeadlineGeneration: 999n,
lockedBy: 'unification-clock-fixture',
leaseUntil: new Date(Date.now() + 60_000),
attempts: 1,
},
});
const resumeWallAt = new Date(suspension.cutWallAt.getTime() + 36 * 60 * 60_000);
await db.turnDaemonLease.create({
data: {
profile: profileName,
ownerId: 'unification-clock-fixture',
fencingEpoch: 1n,
leaseUntil: new Date(Date.now() + 60_000),
},
});
const commandHandler = createTurnDaemonCommandHandler({
world,
reservedTurns,
scenarioMeta: loaded.snapshot.scenarioMeta,
map: loaded.snapshot.map,
loadArchivedNationMaxId: async () => fixtureId,
reconcileUnificationWait: (input) =>
reconcileClockSuspensionInTransaction({
...input,
allowUnificationWait: true,
testResumeWallAt: resumeWallAt,
}),
});
const command = {
type: 'messageRespond' as const,
requestId,
userId,
generalId: fixtureId,
messageId: invaderPrompt!.id,
response: true,
};
const executeCommand = () =>
hooks.hooks.executeCommand!(requestId, async (context) => {
const result = await commandHandler.handle(command, {
...context,
clockOperationAuthority: {
kind: 'DAEMON',
profileName,
ownerId: 'unification-clock-fixture',
fencingEpoch: 1n,
},
});
if (!result) throw new Error('Fixture command was not handled.');
return result;
});
const beforeFailedInvader = world.captureState();
await expect(stateManager.transaction(executeCommand)).rejects.toThrow(
'Input event processing clock fence changed before commit'
);
expect(world.captureState()).toEqual(beforeFailedInvader);
expect(await db.nation.count({ where: { id: invaderNationId } })).toBe(0);
expect(await db.clockProjectionOutbox.count({ where: { suspensionId: suspension.id } })).toBe(0);
expect(await db.clockSuspension.findUniqueOrThrow({ where: { id: suspension.id } })).toMatchObject({
status: 'SUSPENDED',
sourceRevision: 1n,
});
await db.inputEvent.update({
where: { requestId },
data: { processingDeadlineGeneration: 1n },
});
const commandResult = await stateManager.transaction(executeCommand);
expect(commandResult).toMatchObject({ type: 'messageRespond', ok: true, action: 'raiseInvader' });
const reconciledWorld = await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } });
const appliedSuspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: suspension.id } });
expect(reconciledWorld).toMatchObject({
clockPhase: 'RECONCILING',
clockRevision: 2n,
deadlineGeneration: 2n,
tickSeconds: 1_200,
meta: expect.objectContaining({ isUnited: 1, isunited: 1 }),
});
expect(appliedSuspension).toMatchObject({
status: 'RECONCILING',
gapTicks: BigInt(36 * 60 * 60 * 60_000),
shiftTicks: BigInt(36 * 60 * 60 * 60_000),
});
expect(await db.nation.findUniqueOrThrow({ where: { id: invaderNationId } })).toMatchObject({
name: 'ⓞ남만족',
capitalCityId: invaderCityId,
});
expect(await db.general.count({ where: { id: { in: invaderGeneralIds } } })).toBe(10);
const invaderTurns = await db.general.findMany({
where: { id: { in: invaderGeneralIds } },
select: { turnTick: true },
orderBy: { id: 'asc' },
});
expect(
invaderTurns.every((entry) => entry.turnTick !== null && entry.turnTick > reconciledWorld.clockTick!)
).toBe(true);
expect(
invaderTurns.every(
(entry) =>
entry.turnTick !== null &&
entry.turnTick <= reconciledWorld.clockTick! + BigInt(GAME_TICKS_PER_TURN)
)
).toBe(true);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
processingClockRevision: 1n,
processingDeadlineGeneration: 1n,
});
expect(
await db.clockProjectionOutbox.findFirstOrThrow({ where: { suspensionId: suspension.id } })
).toMatchObject({ status: 'PENDING', targetRevision: 2n });
if (process.env.REDIS_URL) {
const redis = createRedisConnector({ url: process.env.REDIS_URL });
await redis.connect();
const prefix = `sammo:${profileName}`;
try {
await redis.client.del([
`${prefix}:clock:active-revision`,
`${prefix}:clock:deadline-generation`,
`${prefix}:clock:projection-checksum`,
`${prefix}:clock:phase`,
`${prefix}:auction:timer`,
`${prefix}:tournament:state`,
]);
await redis.client.set(`${prefix}:clock:active-revision`, '1');
await expect(
applyNextClockProjection({ db, redis: redis.client, workerId: 'unification-clock-fixture' })
).resolves.toBe('APPLIED');
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
});
world.completeClockReconciliation();
expect(world.getGameClockState().phase).toBe('RUNNING');
} finally {
await redis.client.del([
`${prefix}:clock:active-revision`,
`${prefix}:clock:deadline-generation`,
`${prefix}:clock:projection-checksum`,
`${prefix}:clock:phase`,
`${prefix}:auction:timer`,
`${prefix}:tournament:state`,
]);
await redis.disconnect();
}
}
} finally {
await hooks.close();
}
@@ -149,6 +149,8 @@ describe('unification handler', () => {
currentMonth: 6,
tickSeconds: 600,
lastTurnTime: new Date('0190-06-01T00:00:00.000Z'),
clockMode: 'realtime',
clockPhase: 'RUNNING',
meta: { serverId: 'server-1', refreshLimit: 2 },
};
const snapshot: TurnWorldSnapshot = {
@@ -203,6 +205,8 @@ describe('unification handler', () => {
currentGeneralCount: 1,
},
});
expect(world.getGameClockState().phase).toBe('SUSPENDED');
expect(world.getState().meta.unificationClockSuspensionId).toMatch(/^unification-wait-[a-f0-9]{32}$/);
expect(world.getGeneralById(1)).toMatchObject({
inheritancePoints: { previous: 150, unifier: 2007, tournament: 11 },
meta: { inherit_earned_dyn: 2162.1, inherit_earned: 2167.1, inherit_spent: 20 },
@@ -147,6 +147,9 @@ describe('HWE-shaped unification invader resume', () => {
clockMode: 'realtime',
clockWallAnchor: liveWallAnchor,
lastTurnTick: 19_944_000_000,
clockPhase: 'SUSPENDED',
clockRevision: 1,
deadlineGeneration: 1,
meta: {
hiddenSeed: 'hwe-invader-resume-fixture',
serverId: 'hwe:default_snapshot',
@@ -154,6 +157,7 @@ describe('HWE-shaped unification invader resume', () => {
isunited: 2,
refreshLimit: 3_000,
maxGeneralsPerMinute: 1_000,
unificationClockSuspensionId: 'unification-wait-fixture',
},
};
const snapshot: TurnWorldSnapshot = {
@@ -232,6 +236,18 @@ describe('HWE-shaped unification invader resume', () => {
},
map,
loadArchivedNationMaxId: async () => 57,
reconcileUnificationWait: async () => ({
suspensionId: 'unification-wait-fixture',
phase: 'RECONCILING',
sourceRevision: 1,
targetRevision: 2,
deadlineGeneration: 2,
gapTicks: 108_000_000,
catchUpTicks: 0,
shiftTicks: 108_000_000,
alignedTick: 20_088_000_000,
resumeWallAt: acceptedAt,
}),
});
const processor = new InMemoryTurnProcessor(world);
const clock = new ManualClock(acceptedAt.getTime());
@@ -253,7 +269,19 @@ describe('HWE-shaped unification invader resume', () => {
processor: { run },
commandHandler,
hooks: {
executeCommand: async (_commandRequestId, execute) => execute({ db: commandDb }),
executeCommand: async (_commandRequestId, execute) => {
const result = await execute({
db: commandDb,
clockOperationAuthority: {
kind: 'DAEMON',
profileName: 'hwe:default-snapshot',
ownerId: 'fixture-daemon',
fencingEpoch: 1n,
},
});
world.completeClockReconciliation();
return result;
},
},
},
{
@@ -264,9 +292,7 @@ describe('HWE-shaped unification invader resume', () => {
await lifecycle.start();
expect(commandDb.inputEvent.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { requestId } })
);
expect(commandDb.inputEvent.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { requestId } }));
expect(commandDb.$queryRaw).toHaveBeenCalledOnce();
expect(world.getState()).toMatchObject({
currentYear: 226,