오류 정지 중 가입 턴을 고정하고 화면 이동 실패 복구 지원
This commit is contained in:
@@ -142,11 +142,12 @@ export class TurnDaemonLifecycle {
|
|||||||
private async runLoop(): Promise<void> {
|
private async runLoop(): Promise<void> {
|
||||||
await this.initializeState();
|
await this.initializeState();
|
||||||
while (!this.stopping) {
|
while (!this.stopping) {
|
||||||
|
// 정지 시계 동기화보다 먼저 claim하면 가입에 벽시계 경과가 섞인다.
|
||||||
|
const gatePaused = (await this.pauseGate?.()) ?? false;
|
||||||
await this.drainCommands();
|
await this.drainCommands();
|
||||||
if (this.stopping) {
|
if (this.stopping) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const gatePaused = (await this.pauseGate?.()) ?? false;
|
|
||||||
if (this.errorPaused && !gatePaused) {
|
if (this.errorPaused && !gatePaused) {
|
||||||
this.errorPaused = false;
|
this.errorPaused = false;
|
||||||
this.status.lastError = undefined;
|
this.status.lastError = undefined;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface GatewayProfileGateOptions {
|
|||||||
|
|
||||||
export interface GatewayProfileGate {
|
export interface GatewayProfileGate {
|
||||||
shouldPause(): Promise<boolean>;
|
shouldPause(): Promise<boolean>;
|
||||||
|
isExplicitlyPaused(): boolean;
|
||||||
markPaused(error?: unknown): Promise<void>;
|
markPaused(error?: unknown): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -30,12 +31,14 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
|||||||
const prisma = connector.prisma;
|
const prisma = connector.prisma;
|
||||||
let lastCheckedAt = 0;
|
let lastCheckedAt = 0;
|
||||||
let cachedPause = false;
|
let cachedPause = false;
|
||||||
|
let cachedStatus: GatewayProfileStatus | null = null;
|
||||||
|
|
||||||
const loadStatus = async (): Promise<boolean> => {
|
const loadStatus = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const profile = await prisma.gatewayProfile.findUnique({
|
const profile = await prisma.gatewayProfile.findUnique({
|
||||||
where: { profileName: options.profileName },
|
where: { profileName: options.profileName },
|
||||||
});
|
});
|
||||||
|
cachedStatus = (profile?.status as GatewayProfileStatus | undefined) ?? null;
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -46,6 +49,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isExplicitlyPaused: () => cachedStatus === 'PAUSED',
|
||||||
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
|
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
|
||||||
async shouldPause(): Promise<boolean> {
|
async shouldPause(): Promise<boolean> {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
@@ -57,6 +61,9 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
|||||||
return cachedPause;
|
return cachedPause;
|
||||||
},
|
},
|
||||||
async markPaused(error?: unknown): Promise<void> {
|
async markPaused(error?: unknown): Promise<void> {
|
||||||
|
cachedPause = true;
|
||||||
|
cachedStatus = 'PAUSED';
|
||||||
|
lastCheckedAt = performance.now();
|
||||||
const failure = error ? describeRuntimeError(error) : null;
|
const failure = error ? describeRuntimeError(error) : null;
|
||||||
const message = failure?.message ?? null;
|
const message = failure?.message ?? null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -920,6 +920,19 @@ export class InMemoryTurnWorld {
|
|||||||
return clock.now(wallNow);
|
return clock.now(wallNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInitialGeneralTurnTime(processingGameTick: number): Date {
|
||||||
|
const clock = this.getGameClock();
|
||||||
|
const tick =
|
||||||
|
clock.phase === 'PREOPEN'
|
||||||
|
? Math.max(0, processingGameTick)
|
||||||
|
: clock.phase === 'SUSPENDED' || clock.phase === 'COMPLETED'
|
||||||
|
? clock.tick
|
||||||
|
: processingGameTick;
|
||||||
|
// 오류 정지 직전에 접수된 가입도 정지된 시각보다 미래에 배치하지 않는다.
|
||||||
|
// 접수 tick 자체는 RNG/감사 원장의 좌표로 보존한다.
|
||||||
|
return clock.tickToDate(tick);
|
||||||
|
}
|
||||||
|
|
||||||
dateToGameTick(date: Date): number {
|
dateToGameTick(date: Date): number {
|
||||||
return this.getGameClock().dateToTick(date);
|
return this.getGameClock().dateToTick(date);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { GameClockPhase } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
/** Gateway의 실행 gate와 durable 시계를 명령 claim 전에 맞춘다. */
|
||||||
|
export const createRuntimePauseGate = (options: {
|
||||||
|
assertLease(): void;
|
||||||
|
shouldPause(): Promise<boolean>;
|
||||||
|
getPhase(): GameClockPhase;
|
||||||
|
isExplicitlyPaused(): boolean;
|
||||||
|
prepareRecovery(options: { paused: boolean }): Promise<void>;
|
||||||
|
synchronize(): Promise<unknown>;
|
||||||
|
}): (() => Promise<boolean>) => {
|
||||||
|
let lastPaused: boolean | null = null;
|
||||||
|
return async () => {
|
||||||
|
options.assertLease();
|
||||||
|
const paused = await options.shouldPause();
|
||||||
|
const phase = options.getPhase();
|
||||||
|
// 오류 정지는 Gateway 상태만 PAUSED로 바꿀 수 있다. 그대로 두면 가입은
|
||||||
|
// 흐르는 접수 시각을 쓰고, 재개 시 정수 턴 이동까지 중복 적용받는다.
|
||||||
|
// PREOPEN은 예정된 대기이므로 오픈 시각을 바꾸지 않는다.
|
||||||
|
if (paused && options.isExplicitlyPaused() && phase === 'RUNNING') {
|
||||||
|
await options.prepareRecovery({ paused: true });
|
||||||
|
} else if (!paused && phase === 'SUSPENDED') {
|
||||||
|
// 이 runtime이 만든 RECOVERY 정지는 재기동 없이도 재개한다.
|
||||||
|
// MAINTENANCE/통일 대기의 재개 권한은 기존 운영 경계에 남는다.
|
||||||
|
await options.prepareRecovery({ paused: false });
|
||||||
|
}
|
||||||
|
if (lastPaused !== paused || phase === 'SUSPENDED' || phase === 'RECONCILING') {
|
||||||
|
await options.synchronize();
|
||||||
|
}
|
||||||
|
lastPaused = paused;
|
||||||
|
return paused;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createRuntimePauseGate } from './runtimePauseGate.js';
|
||||||
|
|
||||||
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
|
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
@@ -716,6 +717,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
let stopClockProjectionWorker = () => {};
|
let stopClockProjectionWorker = () => {};
|
||||||
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
|
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
|
||||||
let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined;
|
let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined;
|
||||||
|
let prepareClockRecovery: DatabaseTurnHooks['prepareRealtimeRecovery'] | undefined;
|
||||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||||
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
||||||
const monthlyActionModules = await loadActionModuleBundle(
|
const monthlyActionModules = await loadActionModuleBundle(
|
||||||
@@ -941,11 +943,16 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
onRunError: async (error) => {
|
onRunError: async (error) => {
|
||||||
await dbHooks.hooks.onRunError?.(error);
|
await dbHooks.hooks.onRunError?.(error);
|
||||||
await gatewayGate?.markPaused(error);
|
await gatewayGate?.markPaused(error);
|
||||||
|
if (!turnDaemonLease?.isLost() && world.getGameClockState().phase === 'RUNNING') {
|
||||||
|
// 같은 command batch의 다음 가입도 정지된 시각을 보게 한다.
|
||||||
|
await dbHooks.prepareRealtimeRecovery({ paused: true });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt;
|
takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt;
|
||||||
applyClockProjection = dbHooks.applyClockProjection;
|
applyClockProjection = dbHooks.applyClockProjection;
|
||||||
synchronizeClockAuthority = dbHooks.synchronizeClockAuthority;
|
synchronizeClockAuthority = dbHooks.synchronizeClockAuthority;
|
||||||
|
prepareClockRecovery = dbHooks.prepareRealtimeRecovery;
|
||||||
close = async () => {
|
close = async () => {
|
||||||
if (auctionBidder) {
|
if (auctionBidder) {
|
||||||
await auctionBidder.close();
|
await auctionBidder.close();
|
||||||
@@ -1060,7 +1067,6 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
maxGenerals: 200,
|
maxGenerals: 200,
|
||||||
catchUpCap: 1,
|
catchUpCap: 1,
|
||||||
};
|
};
|
||||||
let lastObservedGatewayPause: boolean | null = null;
|
|
||||||
|
|
||||||
const lifecycle = new TurnDaemonLifecycle(
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
{
|
{
|
||||||
@@ -1071,23 +1077,18 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
stateStore,
|
stateStore,
|
||||||
processor,
|
processor,
|
||||||
hooks,
|
hooks,
|
||||||
pauseGate: async () => {
|
pauseGate: createRuntimePauseGate({
|
||||||
if (turnDaemonLease?.isLost()) {
|
assertLease: () => {
|
||||||
// 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
|
if (turnDaemonLease?.isLost()) throw turnDaemonLease.getLossError();
|
||||||
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
|
},
|
||||||
throw turnDaemonLease.getLossError();
|
shouldPause: async () => (await pauseGate?.()) ?? false,
|
||||||
}
|
getPhase: () => world.getGameClockState().phase,
|
||||||
const gatewayPaused = (await pauseGate?.()) ?? false;
|
isExplicitlyPaused: () => gatewayGate?.isExplicitlyPaused() ?? false,
|
||||||
const phase = world.getGameClockState().phase;
|
prepareRecovery: async (recoveryOptions) => {
|
||||||
const phaseNeedsSync = gatewayPaused
|
await prepareClockRecovery?.(recoveryOptions);
|
||||||
? phase !== 'SUSPENDED'
|
},
|
||||||
: phase === 'SUSPENDED' || phase === 'RECONCILING';
|
synchronize: async () => synchronizeClockAuthority?.(),
|
||||||
if (synchronizeClockAuthority && (lastObservedGatewayPause !== gatewayPaused || phaseNeedsSync)) {
|
}),
|
||||||
await synchronizeClockAuthority();
|
|
||||||
}
|
|
||||||
lastObservedGatewayPause = gatewayPaused;
|
|
||||||
return gatewayPaused;
|
|
||||||
},
|
|
||||||
commandHandler,
|
commandHandler,
|
||||||
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
||||||
// The exclusive fixture runner aborts the entire in-memory runtime
|
// The exclusive fixture runner aborts the entire in-memory runtime
|
||||||
|
|||||||
@@ -354,9 +354,7 @@ async function handleJoinCreateGeneral(
|
|||||||
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
|
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
|
||||||
}
|
}
|
||||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||||
const turnScheduleAt = ctx.world.gameTickToDate(
|
const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick);
|
||||||
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'joinCreateGeneral',
|
type: 'joinCreateGeneral',
|
||||||
@@ -475,9 +473,7 @@ async function handleSelectPoolCreate(
|
|||||||
}
|
}
|
||||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||||
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
|
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
|
||||||
const turnScheduleAt = ctx.world.gameTickToDate(
|
const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick);
|
||||||
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts
|
|||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
readTurnRuntimeReady,
|
readTurnRuntimeReady,
|
||||||
|
readInputEventClockCoordinate,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
|
|
||||||
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
||||||
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
||||||
|
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
|
||||||
|
import { resolveJoinTurnTime } from '../src/turn/joinCreateGeneralService.js';
|
||||||
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
|
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
@@ -263,6 +266,85 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('freezes joins in a live PAUSED gate and applies outage recovery only once', async () => {
|
||||||
|
const profile = 'live-pause-join';
|
||||||
|
const base = new Date('2026-09-11T23:00:00Z');
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
scenarioCode: profile,
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 60,
|
||||||
|
clockBaseTime: base,
|
||||||
|
clockTick: 0n,
|
||||||
|
lastTurnTick: 0n,
|
||||||
|
clockMode: 'realtime',
|
||||||
|
clockPhase: 'RUNNING',
|
||||||
|
clockWallAnchor: new Date(Date.now() - 115 * 60_000),
|
||||||
|
clockRevision: 1n,
|
||||||
|
deadlineGeneration: 1n,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
|
||||||
|
try {
|
||||||
|
const token = (await lease.acquire())!;
|
||||||
|
await lease.markClockReady();
|
||||||
|
const authority = {
|
||||||
|
kind: 'DAEMON' as const,
|
||||||
|
profileName: profile,
|
||||||
|
ownerId: token.ownerId,
|
||||||
|
fencingEpoch: token.fencingEpoch,
|
||||||
|
};
|
||||||
|
let phase: 'RUNNING' | 'SUSPENDED' = 'RUNNING';
|
||||||
|
const gate = createRuntimePauseGate({
|
||||||
|
assertLease: () => {},
|
||||||
|
shouldPause: async () => true,
|
||||||
|
isExplicitlyPaused: () => true,
|
||||||
|
getPhase: () => phase,
|
||||||
|
prepareRecovery: async (options) => {
|
||||||
|
await prepareRealtimeRecovery(db, authority, options);
|
||||||
|
phase = 'SUSPENDED';
|
||||||
|
},
|
||||||
|
synchronize: async () => {},
|
||||||
|
});
|
||||||
|
const beforePause = await db.$transaction((tx) => readInputEventClockCoordinate(tx));
|
||||||
|
expect(beforePause.gameTick).toBeGreaterThan(BigInt(100 * T));
|
||||||
|
await gate();
|
||||||
|
const accepted = await db.$transaction((tx) => readInputEventClockCoordinate(tx));
|
||||||
|
expect(accepted.gameTick).toBe(0n);
|
||||||
|
const pausedWorld = await db.worldState.findFirstOrThrow();
|
||||||
|
const draws = [26, 753000];
|
||||||
|
const turnTime = resolveJoinTurnTime(
|
||||||
|
{ nextRangeInt: () => draws.shift()! },
|
||||||
|
pausedWorld,
|
||||||
|
accepted.gameAt,
|
||||||
|
base,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(turnTime.toISOString()).toBe('2026-09-11T23:00:26.753Z');
|
||||||
|
await db.general.create({
|
||||||
|
data: { id: 768, name: 'pause-join', turnTick: BigInt(26_753 * 600), turnTime },
|
||||||
|
});
|
||||||
|
await gate();
|
||||||
|
expect(await db.clockSuspension.count()).toBe(1);
|
||||||
|
const suspension = await db.clockSuspension.findFirstOrThrow();
|
||||||
|
const plan = await reconcileClockSuspension({
|
||||||
|
db,
|
||||||
|
authority,
|
||||||
|
suspensionId: suspension.id,
|
||||||
|
testResumeWallAt: new Date(suspension.cutWallAt.getTime() + 115 * 60_000),
|
||||||
|
});
|
||||||
|
expect(plan.shiftTicks).toBe(108 * T);
|
||||||
|
const joined = await db.general.findUniqueOrThrow({ where: { id: 768 } });
|
||||||
|
expect(joined.turnTime.toISOString()).toBe('2026-09-12T00:48:26.753Z');
|
||||||
|
expect(joined.turnTick! - BigInt(plan.alignedTick)).toBe(BigInt(26_753 * 600));
|
||||||
|
await reconcileClockSuspension({ db, authority, suspensionId: suspension.id });
|
||||||
|
expect((await db.general.findUniqueOrThrow({ where: { id: 768 } })).turnTime).toEqual(joined.turnTime);
|
||||||
|
} finally {
|
||||||
|
await lease.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
|
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
|
||||||
const profile = 'recovery-startup';
|
const profile = 'recovery-startup';
|
||||||
await db.worldState.create({
|
await db.worldState.create({
|
||||||
|
|||||||
@@ -239,6 +239,20 @@ describe('runtime clock shift', () => {
|
|||||||
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
|
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['SUSPENDED', 'COMPLETED'] as const)('keeps a queued join within the frozen %s clock', (phase) => {
|
||||||
|
const base = new Date('2026-09-11T23:00:00Z');
|
||||||
|
const world = buildWorld({
|
||||||
|
clockBaseTime: base,
|
||||||
|
clockTick: 0,
|
||||||
|
clockMode: 'realtime',
|
||||||
|
clockPhase: phase,
|
||||||
|
clockWallAnchor: base,
|
||||||
|
lastTurnTick: 0,
|
||||||
|
});
|
||||||
|
expect(world.getInitialGeneralTurnTime(103 * 36_000_000)).toEqual(base);
|
||||||
|
expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(base);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => {
|
it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => {
|
||||||
const gameBase = new Date('2026-07-30T10:00:00.000Z');
|
const gameBase = new Date('2026-07-30T10:00:00.000Z');
|
||||||
const openAt = new Date('2026-09-02T23:30:00.000Z');
|
const openAt = new Date('2026-09-02T23:30:00.000Z');
|
||||||
@@ -254,6 +268,7 @@ describe('runtime clock shift', () => {
|
|||||||
|
|
||||||
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
|
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
|
||||||
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
|
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
|
||||||
|
expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(gameBase);
|
||||||
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
|
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
|
||||||
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
|
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
|
||||||
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
|
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { GameClockPhase } from '@sammo-ts/common';
|
||||||
|
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
|
||||||
|
|
||||||
|
describe('runtime pause clock boundary', () => {
|
||||||
|
it('freezes a live error pause before commands, resumes without restart, and does not freeze twice', async () => {
|
||||||
|
let phase: GameClockPhase = 'RUNNING';
|
||||||
|
let paused = true;
|
||||||
|
const calls: string[] = [];
|
||||||
|
const gate = createRuntimePauseGate({
|
||||||
|
assertLease: () => calls.push('lease'),
|
||||||
|
shouldPause: async () => paused,
|
||||||
|
isExplicitlyPaused: () => paused,
|
||||||
|
getPhase: () => phase,
|
||||||
|
prepareRecovery: async ({ paused }) => {
|
||||||
|
calls.push(paused ? 'freeze' : 'resume');
|
||||||
|
phase = paused ? 'SUSPENDED' : 'RECONCILING';
|
||||||
|
},
|
||||||
|
synchronize: async () => calls.push('sync'),
|
||||||
|
});
|
||||||
|
expect(await gate()).toBe(true);
|
||||||
|
expect(phase).toBe('SUSPENDED');
|
||||||
|
expect(calls).toEqual(['lease', 'freeze', 'sync']);
|
||||||
|
await gate();
|
||||||
|
expect(calls.filter((call) => call === 'freeze')).toHaveLength(1);
|
||||||
|
paused = false;
|
||||||
|
expect(await gate()).toBe(false);
|
||||||
|
expect(phase).toBe('RECONCILING');
|
||||||
|
expect(calls.slice(-3)).toEqual(['lease', 'resume', 'sync']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['PREOPEN', 'RUNNING', 'COMPLETED'] as const)(
|
||||||
|
'preserves the planned opening when the Gateway is PREOPEN and the clock is %s',
|
||||||
|
async (phase) => {
|
||||||
|
const prepareRecovery = vi.fn();
|
||||||
|
const gate = createRuntimePauseGate({
|
||||||
|
assertLease: () => {},
|
||||||
|
shouldPause: async () => true,
|
||||||
|
isExplicitlyPaused: () => false,
|
||||||
|
getPhase: () => phase,
|
||||||
|
prepareRecovery,
|
||||||
|
synchronize: async () => {},
|
||||||
|
});
|
||||||
|
expect(await gate()).toBe(true);
|
||||||
|
expect(prepareRecovery).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not touch the clock after lease loss', async () => {
|
||||||
|
const prepareRecovery = vi.fn();
|
||||||
|
const gate = createRuntimePauseGate({
|
||||||
|
assertLease: () => {
|
||||||
|
throw new Error('lease lost');
|
||||||
|
},
|
||||||
|
shouldPause: async () => true,
|
||||||
|
isExplicitlyPaused: () => true,
|
||||||
|
getPhase: () => 'RUNNING',
|
||||||
|
prepareRecovery,
|
||||||
|
synchronize: async () => {},
|
||||||
|
});
|
||||||
|
await expect(gate()).rejects.toThrow('lease lost');
|
||||||
|
expect(prepareRecovery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,13 +19,15 @@ describe('TurnDaemonLifecycle', () => {
|
|||||||
const now = new Date('2026-09-09T17:30:00Z');
|
const now = new Date('2026-09-09T17:30:00Z');
|
||||||
const error = new TurnDaemonLeaseLostError('che:default');
|
const error = new TurnDaemonLeaseLostError('che:default');
|
||||||
const processor = { run: vi.fn() };
|
const processor = { run: vi.fn() };
|
||||||
|
const queue = new InMemoryControlQueue();
|
||||||
|
const drain = vi.spyOn(queue, 'drain');
|
||||||
const onRunError = vi.fn(async () => {
|
const onRunError = vi.fn(async () => {
|
||||||
if (reportFails) throw new Error('gateway unavailable');
|
if (reportFails) throw new Error('gateway unavailable');
|
||||||
});
|
});
|
||||||
const lifecycle = new TurnDaemonLifecycle(
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
{
|
{
|
||||||
clock: new ManualClock(now.getTime()),
|
clock: new ManualClock(now.getTime()),
|
||||||
controlQueue: new InMemoryControlQueue(),
|
controlQueue: queue,
|
||||||
processor,
|
processor,
|
||||||
getNextTickTime: (value) => addMinutes(value, 5),
|
getNextTickTime: (value) => addMinutes(value, 5),
|
||||||
stateStore: {
|
stateStore: {
|
||||||
@@ -45,6 +47,7 @@ describe('TurnDaemonLifecycle', () => {
|
|||||||
await expect(lifecycle.start()).rejects.toBe(error);
|
await expect(lifecycle.start()).rejects.toBe(error);
|
||||||
expect(onRunError).toHaveBeenCalledExactlyOnceWith(error);
|
expect(onRunError).toHaveBeenCalledExactlyOnceWith(error);
|
||||||
expect(processor.run).not.toHaveBeenCalled();
|
expect(processor.run).not.toHaveBeenCalled();
|
||||||
|
expect(drain).not.toHaveBeenCalled();
|
||||||
expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message });
|
expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6639,3 +6639,124 @@ for (const width of [1200, 390]) {
|
|||||||
expect(state.operations.filter((op) => op === 'messages.respond')).toHaveLength(0);
|
expect(state.operations.filter((op) => op === 'messages.respond')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1280, height: 900 },
|
||||||
|
{ name: 'mobile', width: 390, height: 844 },
|
||||||
|
]) {
|
||||||
|
for (const target of [
|
||||||
|
{ id: 'finance', chunk: 'NationStratFinanView', path: '/nation/finance' },
|
||||||
|
{ id: 'nation-cities', chunk: 'NationCitiesView', path: '/nation/cities' },
|
||||||
|
]) {
|
||||||
|
test(`recovers a stalled and failed ${target.id} navigation on ${viewport.name}`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(!productionBundle, 'Tests actual production dynamic import failure.');
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 12,
|
||||||
|
permission: 4,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installRealtimeHarness(page);
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||||
|
const ops = operationNames(route);
|
||||||
|
if (!ops.some((op) => ['nation.getStratFinan', 'nation.getCityOverview'].includes(op))) {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = ops.map((op) =>
|
||||||
|
response(
|
||||||
|
op === 'nation.getStratFinan'
|
||||||
|
? {
|
||||||
|
editable: true,
|
||||||
|
nationMsg: '',
|
||||||
|
scoutMsg: '',
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 12,
|
||||||
|
year: 185,
|
||||||
|
month: 1,
|
||||||
|
nationsList: [],
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
income: { gold: { city: 100, war: 0 }, rice: { city: 100, wall: 0 } },
|
||||||
|
outcome: 0,
|
||||||
|
policy: { rate: 20, bill: 100, secretLimit: 3, blockScout: false, blockWar: false },
|
||||||
|
warSettingCnt: { remain: 5, inc: 2, max: 10 },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
me: { officerLevel: 12 },
|
||||||
|
nation: { name: '검증국', color: '#008000' },
|
||||||
|
cities: [],
|
||||||
|
generals: [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(result.length === 1 ? result[0] : result),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('./');
|
||||||
|
const link = page.locator(`a[data-navigation-id="${target.id}"]:visible`).first();
|
||||||
|
await expect(link).toBeVisible();
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const hold = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
let blocked = false;
|
||||||
|
await page.route(`**/${target.chunk}-*.js`, async (route) => {
|
||||||
|
if (blocked) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocked = true;
|
||||||
|
await hold;
|
||||||
|
await route.abort('failed');
|
||||||
|
});
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||||
|
await link.click();
|
||||||
|
const notice = page.getByTestId('game-navigation-notice');
|
||||||
|
await expect(notice).toContainText('화면을 여는 중');
|
||||||
|
await expect(notice).toContainText('시간이 걸리고', { timeout: 12_000 });
|
||||||
|
expect(new URL(page.url()).pathname).toBe(`${basePath}/`);
|
||||||
|
release();
|
||||||
|
await expect(notice).toContainText('화면을 불러오지 못했습니다');
|
||||||
|
await expect(notice.locator('a')).toHaveAttribute('href', `${basePath}${target.path}`);
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
const geometry = await notice.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
position: style.position,
|
||||||
|
color: style.color,
|
||||||
|
background: style.backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.x + geometry.width).toBeLessThanOrEqual(viewport.width);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('navigation-failed.png') });
|
||||||
|
await writeFile(
|
||||||
|
testInfo.outputPath('navigation.json'),
|
||||||
|
JSON.stringify({ geometry, pageErrors, url: page.url(), viewport })
|
||||||
|
);
|
||||||
|
await writeFile(testInfo.outputPath('navigation.html'), await page.content());
|
||||||
|
await notice.locator('a').click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${target.path}$`));
|
||||||
|
await expect(notice).toBeHidden();
|
||||||
|
await expect(page.locator('main')).toContainText(target.id === 'finance' ? '내무부' : '세 력 도 시');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('navigation-recovered.png') });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh';
|
|||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
|
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
|
||||||
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
||||||
|
import GameNavigationNotice from './components/ui/GameNavigationNotice.vue';
|
||||||
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
|
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
|
||||||
|
|
||||||
useDeploymentVersionNotice();
|
useDeploymentVersionNotice();
|
||||||
@@ -13,6 +14,7 @@ useClockDisplayRefresh();
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
<GameServerConnectionNotice />
|
<GameServerConnectionNotice />
|
||||||
<GameFeedbackLayer />
|
<GameFeedbackLayer />
|
||||||
|
<GameNavigationNotice />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { routeNavigation } from '../../utils/routeNavigation';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
v-if="routeNavigation.state !== 'idle'"
|
||||||
|
class="game-navigation-notice"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="game-navigation-notice"
|
||||||
|
>
|
||||||
|
<span v-if="routeNavigation.state === 'loading'">화면을 여는 중입니다.</span>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="routeNavigation.state === 'failed'">화면을 불러오지 못했습니다.</span>
|
||||||
|
<span v-else>화면을 여는 데 시간이 걸리고 있습니다.</span>
|
||||||
|
<a :href="routeNavigation.href">이 페이지 다시 열기</a>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.game-navigation-notice {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2050;
|
||||||
|
bottom: max(16px, env(safe-area-inset-bottom));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #8a7765;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #251b15;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.game-navigation-notice a {
|
||||||
|
color: #ffd59a;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
|||||||
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
import { installRouteNavigation } from '../utils/routeNavigation';
|
||||||
|
|
||||||
const MainView = () => import('../views/MainView.vue');
|
const MainView = () => import('../views/MainView.vue');
|
||||||
const PublicView = () => import('../views/PublicView.vue');
|
const PublicView = () => import('../views/PublicView.vue');
|
||||||
@@ -389,6 +390,8 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
installRouteNavigation(router);
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
|
export const routeNavigation = reactive({ href: '', state: 'idle' as 'idle' | 'loading' | 'slow' | 'failed' });
|
||||||
|
|
||||||
|
export const installRouteNavigation = (router: Router): void => {
|
||||||
|
let pendingPath = '';
|
||||||
|
let visibleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let slowTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const clearTimers = () => {
|
||||||
|
clearTimeout(visibleTimer);
|
||||||
|
clearTimeout(slowTimer);
|
||||||
|
};
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
clearTimers();
|
||||||
|
pendingPath = to.fullPath;
|
||||||
|
routeNavigation.href = router.resolve(to).href;
|
||||||
|
routeNavigation.state = 'idle';
|
||||||
|
visibleTimer = setTimeout(() => (routeNavigation.state = 'loading'), 350);
|
||||||
|
slowTimer = setTimeout(() => (routeNavigation.state = 'slow'), 10_000);
|
||||||
|
});
|
||||||
|
router.afterEach((to) => {
|
||||||
|
if (to.fullPath !== pendingPath) return;
|
||||||
|
clearTimers();
|
||||||
|
routeNavigation.state = 'idle';
|
||||||
|
pendingPath = '';
|
||||||
|
});
|
||||||
|
router.onError((_error, to) => {
|
||||||
|
if (to.fullPath !== pendingPath) return;
|
||||||
|
clearTimers();
|
||||||
|
// 실패한 dynamic import는 같은 탭에서 캐시된다. RouterLink 재클릭 대신
|
||||||
|
// 원래 목적지의 문서를 새로 받아 모듈 캐시와 세션 초기화를 다시 시작한다.
|
||||||
|
routeNavigation.state = 'failed';
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user