feat: 통일 대기 시계를 원자적 reconciliation으로 전환
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user