test(clock): exercise suspended lifecycle actions
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user