중단 후 대기와 2배속 복구 경계 및 전체 공지 적용

This commit is contained in:
2026-09-07 14:37:55 +00:00
parent d57fd3e553
commit c39527bdd7
12 changed files with 541 additions and 69 deletions
@@ -1,9 +1,18 @@
import { createHash } from 'node:crypto';
import { GameClock, parseGameClockPhase } from '@sammo-ts/common';
import {
ChangeJournal,
GameClock,
formatServerDateTime,
parseGameClockPhase,
readTurnRecovery,
} from '@sammo-ts/common';
import { MESSAGE_MAILBOX_PUBLIC, resolveMessageTargetIcon, sendMessage } from '@sammo-ts/logic';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
persistMessageEnvelope,
writeReadModelChangeJournal,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
@@ -302,6 +311,47 @@ export const applyNextClockProjection = async (options: {
throw new Error('Clock projection final RUNNING transition fence failed.');
}
const appliedAt = await readDbWall(transaction);
// RUNNING 전이와 같은 transaction에 남겨 재시도 시 전체 공지를 중복 발송하지 않는다.
const recovery = readTurnRecovery(world);
const journal = new ChangeJournal();
journal.mark('world.content').mark('map.world');
if (recovery) {
const recoveredClock = new GameClock({
baseTime: world.clockBaseTime!,
tick: Number(world.clockTick),
wallAnchor: world.clockWallAnchor!,
mode: 'realtime',
turnSeconds: world.tickSeconds,
recovery,
});
const endsAt = recoveredClock.tickToWallDate(recovery.endTick);
const system = {
generalId: 0,
generalName: '시스템',
nationId: 0,
nationName: '',
color: '#000000',
icon: resolveMessageTargetIcon(),
};
await sendMessage(
{ insertMessage: (draft) => persistMessageEnvelope(transaction, draft) },
{
msgType: 'public',
src: system,
dest: system,
text: `서버 재개에 따른 2배속 복구 시간: ${formatServerDateTime(recovery.startWallAt)} ~ ${formatServerDateTime(endsAt)} (한국 시각). 시작 전까지 대기하며, 종료 시 정상 속도로 진행합니다.`,
time: appliedAt,
validUntil: new Date('9999-12-31T00:00:00Z'),
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: endsAt.toISOString(),
},
}
);
journal.mark('messages.mailbox', MESSAGE_MAILBOX_PUBLIC);
}
await writeReadModelChangeJournal(transaction, journal.snapshot());
await transaction.clockProjectionOutbox.update({
where: { id: outbox.id },
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { immediateRecoveryLimitSeconds } from '@sammo-ts/common';
import type { GamePrismaClient } from '@sammo-ts/infra';
import {
readClockDatabaseWall,
@@ -27,8 +28,12 @@ export const prepareRealtimeRecovery = async (
if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return;
const now = await readClockDatabaseWall(db);
// 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다.
// 한 턴 미만의 장애는 잔여 구간 실행만 필요하므로 새 좌표 세대를 만들지 않는다.
if (!options.paused && now.getTime() - world.clockWallAnchor.getTime() < world.tickSeconds * 1_000) return;
// 짧은 중단만 즉시 처리한다. 기준값과 같으면 대기 후 복구한다.
if (
!options.paused &&
now.getTime() - world.clockWallAnchor.getTime() < immediateRecoveryLimitSeconds(world.tickSeconds) * 1_000
)
return;
const suspensionId = `recovery-${randomUUID()}`;
await startClockSuspension({
db,