fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user