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,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();
}