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