fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정

This commit is contained in:
2026-09-05 00:55:33 +00:00
parent 1d53e6f226
commit 3437cd99f4
25 changed files with 458 additions and 71 deletions
@@ -33,6 +33,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
async drain(): Promise<TurnDaemonCommand[]> {
const local = this.localQueue.splice(0, this.localQueue.length);
// 종료 직후 실행하지 않을 명령을 PROCESSING으로 선점하면 새 daemon은
// lease 만료까지 기다려야 한다. 종료 batch에서는 DB 명령을 가져오지 않는다.
if (local.some((command) => command.type === 'shutdown')) {
return local;
}
const remote = await this.claimPending();
return local.concat(remote);
}
@@ -116,7 +121,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
});
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
const gameplayAllowed =
!world ||
world.clockPhase === 'PREOPEN' ||
world.clockPhase === 'RUNNING' ||
world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended =
@@ -158,6 +158,20 @@ export class TurnDaemonLifecycle {
continue;
}
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (gameClock?.phase && gameClock.phase !== 'RUNNING' && gameClock.phase !== 'MANUAL') {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) {
await this.runOnce(this.pendingRun);
this.pendingRun = null;
@@ -169,23 +183,6 @@ export class TurnDaemonLifecycle {
await this.clock.sleepMs(200);
continue;
}
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (
gameClock?.phase &&
gameClock.phase !== 'RUNNING' &&
gameClock.phase !== 'MANUAL'
) {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that
+5 -1
View File
@@ -1711,7 +1711,11 @@ export class InMemoryTurnWorld {
// Rebasing is also the explicit resume checkpoint. Realtime mode
// must not replay the operational downtime after an administrator
// deliberately delays or accelerates the game schedule.
clockWallAnchor: new Date(wallNow.getTime()),
// 가오픈의 anchor는 별도 예약된 정식 오픈이다. 표시 좌표를 옮기는
// 작업이 그 미래 경계를 현재 시각으로 당겨 게임을 시작시키면 안 된다.
clockWallAnchor: new Date(
previousClock.phase === 'PREOPEN' ? previousClock.wallAnchor.getTime() : wallNow.getTime()
),
lastTurnTime: nextLastTurnTime,
meta: nextMeta,
};
@@ -96,7 +96,7 @@ export const normalizeJoinSpecialityCode = (value: unknown): string | null =>
export const resolveLegacyPenalty = (
rawPenalty: Record<string, unknown> | undefined,
profileId: string,
acceptedAt: Date
requestedAtWall: Date
): Record<string, unknown> => {
if (!rawPenalty) {
return {};
@@ -105,7 +105,7 @@ export const resolveLegacyPenalty = (
...asRecord(rawPenalty.any),
...asRecord(rawPenalty[profileId]),
};
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
const acceptedAtSeconds = requestedAtWall.getTime() / 1000;
const result: Record<string, unknown> = {};
for (const [key, rawEntry] of Object.entries(merged)) {
const entry = asRecord(rawEntry);
@@ -654,12 +654,7 @@ export const createGeneralFromJoin = async (options: {
const generalId = world.getNextGeneralId();
const rng = new RandUtil(
new LiteHashDRBG(
buildJoinCreateGeneralSeed(
hiddenSeed,
input.seedOwnerIdentity,
world.dateToGameTick(acceptedAt),
generalId
)
buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt), generalId)
)
);
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
@@ -805,7 +800,9 @@ export const createGeneralFromJoin = async (options: {
meta: {},
},
lastTurn: { command: DEFAULT_TURN_ACTION },
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt),
// 계정 제재 expire는 Ref member의 Unix wall timestamp다. 가오픈의
// 음수 GAME 좌표로 비교하면 현실에서 만료된 제재가 다시 적용된다.
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, operationalAcceptedAt),
inheritancePoints: {
previous: finalInheritancePoint,
},
@@ -1,7 +1,22 @@
import { asNumber, asRecord } from '@sammo-ts/common';
import type { TurnWorldState } from './types.js';
const DEFAULT_MIN_TURNS = 2;
/** 표시용 opentime과 재투영된 lastTurnTime 대신 오픈 phase와 실행 cursor로 판정한다. */
export const hasStartedForPrestartActions = (
state: Pick<TurnWorldState, 'clockPhase' | 'lastTurnTick' | 'lastTurnTime' | 'meta'>
): boolean => {
if (state.clockPhase === 'PREOPEN') return false;
if (state.clockPhase === 'COMPLETED' || state.clockPhase === 'SUSPENDED' || state.clockPhase === 'RECONCILING') {
return true;
}
// Ref의 turntime == opentime 경계는 아직 허용하고 최초 월 진행 이후 닫는다.
if (state.lastTurnTick !== undefined) return state.lastTurnTick > 0;
const opentime = typeof state.meta.opentime === 'string' ? new Date(state.meta.opentime) : null;
return opentime !== null && state.lastTurnTime.getTime() > opentime.getTime();
};
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
const value = meta.prestart_delete_after;
if (typeof value !== 'string' || !value.trim()) {
+21 -10
View File
@@ -70,7 +70,12 @@ import {
} from './selectPoolService.js';
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import {
buildPrestartDeleteAfter,
formatPrestartDeleteAfter,
readPrestartDeleteAfter,
hasStartedForPrestartActions,
} from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
@@ -344,8 +349,14 @@ async function handleJoinCreateGeneral(
throw new Error('Join world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try {
return {
type: 'joinCreateGeneral',
@@ -463,7 +474,10 @@ async function handleSelectPoolCreate(
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = acceptedAt;
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try {
return {
type: 'selectPoolCreate',
@@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus(
const db = requireCommandDatabase(ctx);
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = ctx.world.getState();
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) {
return {
type: 'ensureDieOnPrestartStatus',
generalId: command.generalId,
@@ -1643,8 +1656,7 @@ async function handleDieOnPrestart(
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
if (hasStartedForPrestartActions(worldState)) {
return {
type: 'dieOnPrestart',
ok: false,
@@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate(
await assertImmediateGeneralActionActor(ctx, command, general);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
if (hasStartedForPrestartActions(worldState)) {
return {
type: 'buildNationCandidate',
ok: false,