test(clock): exercise suspended lifecycle actions

This commit is contained in:
2026-09-03 17:29:18 +00:00
parent 000e7e8fa5
commit 0aa549a6c5
7 changed files with 1292 additions and 11 deletions
@@ -119,6 +119,18 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended =
world?.clockPhase === 'SUSPENDED' &&
Boolean(
await transaction.clockSuspension.findFirst({
where: {
status: 'SUSPENDED',
source: 'MAINTENANCE',
sourceRevision: world.clockRevision,
},
select: { id: true },
})
);
const rows = await transaction.$queryRaw<
Array<{
sequence: bigint;
@@ -151,6 +163,30 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
OR (
${maintenanceSuspended}
AND "event_type" IN (
'inheritanceAction',
'dropItem',
'changePermission',
'appoint',
'setNationSetting',
'setNpcPolicy'
)
)
OR (
${maintenanceSuspended}
AND "event_type" = 'messageRespond'
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
AND EXISTS (
SELECT 1
FROM "message_action" AS pending_action
WHERE pending_action."message_id" = ("input_event"."payload" ->> 'messageId')::integer
AND pending_action."status" = 'PENDING'
AND pending_action."clock_revision" = ${currentRevision}
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
)
)
OR (
${world?.clockPhase === 'SUSPENDED'}
AND "event_type" = 'messageRespond'
@@ -17,6 +17,7 @@ export interface GatewayProfileGate {
}
const DEFAULT_CACHE_MS = 2000;
const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as const;
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({
@@ -55,8 +56,11 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
async markPaused(error?: unknown): Promise<void> {
const message = error instanceof Error ? error.message : error ? String(error) : null;
try {
await prisma.gatewayProfile.update({
where: { profileName: options.profileName },
await prisma.gatewayProfile.updateMany({
where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
},
data: {
status: 'PAUSED',
lastError: message,
@@ -18,10 +18,26 @@ integration('database command queue', () => {
const cleanupFixtures = async (): Promise<void> => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'integration:engine:' } } });
await db.clockProjectionOutbox.deleteMany({
where: { suspensionId: { in: ['integration-queue-revision-8-9', 'integration-unification-wait'] } },
where: {
suspensionId: {
in: [
'integration-queue-revision-8-9',
'integration-maintenance-suspension',
'integration-unification-wait',
],
},
},
});
await db.clockSuspension.deleteMany({
where: { id: { in: ['integration-queue-revision-8-9', 'integration-unification-wait'] } },
where: {
id: {
in: [
'integration-queue-revision-8-9',
'integration-maintenance-suspension',
'integration-unification-wait',
],
},
},
});
await db.message.deleteMany({ where: { mailbox: 991_199 } });
await db.worldState.deleteMany({
@@ -524,6 +540,116 @@ integration('database command queue', () => {
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
});
it('dequeues fenced immediate user mutations during a maintenance suspension', async () => {
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
await db.worldState.update({
where: { id: world.id },
data: { clockPhase: 'SUSPENDED', clockRevision: 23n, deadlineGeneration: 9n, clockTick: 777n },
});
await db.clockSuspension.create({
data: {
id: 'integration-maintenance-suspension',
worldStateId: world.id,
source: 'MAINTENANCE',
policy: 'EXACT',
status: 'SUSPENDED',
sourceRevision: 23n,
targetRevision: 24n,
cutTick: 777n,
cutWallAt: new Date(),
rateTicksPerSecond: 60_000,
},
});
const commands: TurnDaemonCommand[] = [
{
type: 'inheritanceAction',
requestId: 'integration:engine:suspended-inheritance',
userId: 'user-7',
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 },
},
{
type: 'dropItem',
requestId: 'integration:engine:suspended-drop-item',
userId: 'user-7',
generalId: 7,
itemType: 'weapon',
},
{
type: 'changePermission',
requestId: 'integration:engine:suspended-permission',
userId: 'user-7',
generalId: 7,
isAmbassador: true,
targetGeneralIds: [8],
},
{
type: 'appoint',
requestId: 'integration:engine:suspended-appoint',
userId: 'user-7',
generalId: 7,
destGeneralId: 8,
destCityId: 1,
officerLevel: 2,
},
{
type: 'setNationSetting',
requestId: 'integration:engine:suspended-nation-setting',
userId: 'user-7',
generalId: 7,
nationId: 1,
mutation: { kind: 'rate', amount: 20 },
},
{
type: 'setNpcPolicy',
requestId: 'integration:engine:suspended-npc-policy',
userId: 'user-7',
generalId: 7,
nationId: 1,
expectedUpdatedAt: null,
mutation: { kind: 'nationPriority', priority: ['develop'] },
},
];
await db.inputEvent.createMany({
data: commands.map((command) => ({
requestId: command.requestId!,
target: 'ENGINE' as const,
eventType: command.type,
actorUserId: 'userId' in command ? command.userId : null,
payload: command as GamePrisma.InputJsonValue,
})),
});
const blockedRequestId = 'integration:engine:suspended-vacation-still-gated';
await db.inputEvent.create({
data: {
requestId: blockedRequestId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-7',
payload: {
type: 'vacation',
requestId: blockedRequestId,
userId: 'user-7',
generalId: 7,
},
},
});
const claimed = await new DatabaseTurnDaemonCommandQueue(db).drain();
expect(claimed.map(({ type }) => type)).toEqual(commands.map(({ type }) => type));
for (const command of commands) {
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: command.requestId! } })).resolves.toMatchObject({
status: 'PROCESSING',
processingGameTick: 777n,
processingClockRevision: 23n,
processingDeadlineGeneration: 9n,
});
}
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: blockedRequestId } })).resolves.toMatchObject({
status: 'PENDING',
processingClockRevision: null,
});
});
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
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createGatewayAdminActionConsumer } from '../src/turn/gatewayAdminActions.js';
import { createGatewayProfileGate } from '../src/turn/gatewayProfileGate.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -109,4 +110,35 @@ integration('gateway runtime action consumer', () => {
expect(handler).toHaveBeenCalledTimes(2);
expect(onActionApplied).toHaveBeenCalledTimes(1);
});
it('does not overwrite a terminal operator status while reporting a daemon error', async () => {
const gate = await createGatewayProfileGate({
databaseUrl: databaseUrl!,
gatewayDatabaseUrl: databaseUrl!,
profileName,
});
try {
await db.gatewayProfile.update({
where: { profileName },
data: { status: 'RUNNING', lastError: null },
});
await gate.markPaused(new Error('running failure'));
expect(await db.gatewayProfile.findUniqueOrThrow({ where: { profileName } })).toMatchObject({
status: 'PAUSED',
lastError: 'running failure',
});
await db.gatewayProfile.update({
where: { profileName },
data: { status: 'STOPPED', lastError: null },
});
await gate.markPaused(new Error('late shutdown failure'));
expect(await db.gatewayProfile.findUniqueOrThrow({ where: { profileName } })).toMatchObject({
status: 'STOPPED',
lastError: null,
});
} finally {
await gate.close();
}
});
});
@@ -306,11 +306,27 @@ export const buildTournamentRuntimeKeys = (profileName: string): string[] => [
`sammo:${profileName}:tournament:source-revision`,
];
export const buildGameClockRuntimeKeys = (profileName: string): string[] => [
`sammo:${profileName}:clock:active-revision`,
`sammo:${profileName}:clock:deadline-generation`,
`sammo:${profileName}:clock:phase`,
];
export const buildProfileResetRuntimeKeys = (profileName: string): string[] => [
...buildTournamentRuntimeKeys(profileName),
...buildGameClockRuntimeKeys(profileName),
];
export const clearTournamentRuntimeKeys = async (
redis: { del(keys: string[]): Promise<number> },
profileName: string
): Promise<number> => redis.del(buildTournamentRuntimeKeys(profileName));
export const clearProfileResetRuntimeKeys = async (
redis: { del(keys: string[]): Promise<number> },
profileName: string
): Promise<number> => redis.del(buildProfileResetRuntimeKeys(profileName));
const buildServerId = (profileName: string, now: Date, installOperationId?: string): string => {
const year = String(now.getFullYear()).slice(-2);
const month = String(now.getMonth() + 1).padStart(2, '0');
@@ -965,6 +981,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private readonly profileReadinessTimeoutMs: number;
private readonly now: () => Date;
private readonly fetchImpl: typeof fetch;
/** Backwards-compatible injection name; the default clears all season-owned RESET keys. */
private readonly clearTournamentRuntimeState: (profileName: string) => Promise<void>;
private readonly cancelGame: typeof defaultCancelGame;
private readonly transitionProfileClockOverride?: GatewayOrchestratorOptions['transitionProfileClock'];
@@ -2779,7 +2796,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const connector = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
await connector.connect();
try {
await clearTournamentRuntimeKeys(connector.client, profileName);
await clearProfileResetRuntimeKeys(connector.client, profileName);
} finally {
await connector.disconnect();
}
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildGameClockRuntimeKeys,
buildProfileResetRuntimeKeys,
buildTournamentRuntimeKeys,
clearProfileResetRuntimeKeys,
clearTournamentRuntimeKeys,
} from '../src/orchestrator/gatewayOrchestrator.js';
@@ -32,4 +35,31 @@ describe('tournament reset state', () => {
expect(deleted).toBe(5);
expect(calls).toEqual([buildTournamentRuntimeKeys('che:1010')]);
});
it('clears stale clock authority together with tournament projection on season reset', async () => {
expect(buildGameClockRuntimeKeys('hwe:default')).toEqual([
'sammo:hwe:default:clock:active-revision',
'sammo:hwe:default:clock:deadline-generation',
'sammo:hwe:default:clock:phase',
]);
expect(buildProfileResetRuntimeKeys('hwe:default')).toEqual([
...buildTournamentRuntimeKeys('hwe:default'),
...buildGameClockRuntimeKeys('hwe:default'),
]);
const calls: string[][] = [];
const deleted = await clearProfileResetRuntimeKeys(
{
del: async (keys) => {
calls.push(keys);
return keys.length;
},
},
'hwe:default'
);
expect(deleted).toBe(8);
expect(calls).toEqual([buildProfileResetRuntimeKeys('hwe:default')]);
expect(calls[0]).not.toContain('sammo:che:default:clock:phase');
});
});