가오픈 장수 턴의 오픈 전 적체 방지

This commit is contained in:
2026-09-03 00:36:56 +00:00
parent b1984669d4
commit 97a8b1daef
7 changed files with 101 additions and 6 deletions
+10
View File
@@ -656,6 +656,16 @@ export class InMemoryTurnWorld {
return this.getGameClock().now(wallNow);
}
getRunnableGameNow(wallNow: Date): Date {
const clock = this.getGameClock();
// PREOPEN still needs negative game ticks for cooldowns, but executable
// turn schedules must not precede the wall-clock opening boundary.
if (clock.mode === 'realtime' && wallNow.getTime() < clock.wallAnchor.getTime()) {
return clock.now(clock.wallAnchor);
}
return clock.now(wallNow);
}
dateToGameTick(date: Date): number {
return this.getGameClock().dateToTick(date);
}
@@ -383,7 +383,9 @@ export const resolveJoinTurnTime = (
}
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
if (turnTime.getTime() <= acceptedAt.getTime()) {
turnTime = new Date(turnTime.getTime() + tickSeconds * 1000);
const termMs = tickSeconds * 1000;
const missedTerms = Math.floor((acceptedAt.getTime() - turnTime.getTime()) / termMs) + 1;
turnTime = new Date(turnTime.getTime() + missedTerms * termMs);
}
return turnTime;
};
@@ -523,9 +525,11 @@ export const createGeneralFromJoin = async (options: {
worldState: WorldStateRow;
input: JoinCreateGeneralInput;
acceptedAt: Date;
turnScheduleAt?: Date;
operationalAcceptedAt: Date;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options;
const turnScheduleAt = options.turnScheduleAt ?? acceptedAt;
await lockJoinMutation(db, input.userId);
await assertGeneralIdSnapshotMatches(db, world);
@@ -707,7 +711,7 @@ export const createGeneralFromJoin = async (options: {
const turnTime = resolveJoinTurnTime(
rng,
worldState,
acceptedAt,
turnScheduleAt,
world.getState().lastTurnTime,
input.inheritTurntimeZone
);
+11 -3
View File
@@ -651,11 +651,18 @@ const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => {
return now;
};
const buildInitialTurnTime = (rng: RandUtil, worldState: WorldStateRow, now: Date): Date => {
export const buildInitialTurnTime = (
rng: Pick<RandUtil, 'nextRangeInt'>,
worldState: WorldStateRow,
now: Date,
notBefore: Date = now
): Date => {
const termSeconds = resolveTurnTermMinutes(worldState) * 60;
const seconds = rng.nextRangeInt(0, termSeconds - 1);
const microseconds = rng.nextRangeInt(0, 999_999);
return new Date(resolveTurnTimeBase(worldState, now).getTime() + seconds * 1000 + microseconds / 1000);
const base = resolveTurnTimeBase(worldState, now);
const safeBase = base.getTime() < notBefore.getTime() ? notBefore : base;
return new Date(safeBase.getTime() + seconds * 1000 + microseconds / 1000);
};
const appendSelectionLogs = async (options: {
@@ -702,6 +709,7 @@ export const createGeneralFromSelectionPool = async (options: {
uniqueName: string;
personality: string;
now?: Date;
turnScheduleAt?: Date;
operationalAcceptedAt: Date;
seedOwnerIdentity?: string | number;
ownerPicture?: string;
@@ -761,7 +769,7 @@ export const createGeneralFromSelectionPool = async (options: {
);
}
const city = rng.choice(cities);
const turnTime = buildInitialTurnTime(rng, worldState, now);
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
const age = 20;
const specialityAges = resolveSpecialityAges(worldState, age);
const nextChangeAt = new Date(
@@ -340,6 +340,7 @@ async function handleJoinCreateGeneral(
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
try {
return {
type: 'joinCreateGeneral',
@@ -377,6 +378,7 @@ async function handleJoinCreateGeneral(
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
},
acceptedAt,
turnScheduleAt,
operationalAcceptedAt,
})),
};
@@ -455,6 +457,7 @@ async function handleSelectPoolCreate(
: command.acceptedGameAt !== undefined
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
try {
return {
type: 'selectPoolCreate',
@@ -471,6 +474,7 @@ async function handleSelectPoolCreate(
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
now: acceptedAt,
turnScheduleAt,
operationalAcceptedAt,
})),
};
@@ -80,6 +80,27 @@ describe('generic join deterministic contracts', () => {
]);
});
it('moves an inherited turn phase past the runnable opening boundary', () => {
const rng = {
nextRangeInt(min: number) {
return min;
},
};
const openingGameAt = new Date('2026-07-30T10:00:00.000Z');
const preopenCursor = new Date(openingGameAt.getTime() - 30 * 60_000);
const turnTime = resolveJoinTurnTime(
rng,
{ tickSeconds: 60 } as Parameters<typeof resolveJoinTurnTime>[1],
openingGameAt,
preopenCursor,
0
);
expect(turnTime.getTime()).toBeGreaterThan(openingGameAt.getTime());
expect((turnTime.getTime() - preopenCursor.getTime()) % 60_000).toBe(0);
});
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
@@ -174,6 +174,26 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
});
it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => {
const gameBase = new Date('2026-07-30T10:00:00.000Z');
const openAt = new Date('2026-09-02T23:30:00.000Z');
const world = buildWorld({
clockBaseTime: gameBase,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: openAt,
lastTurnTick: 0,
});
const preopenAt = new Date('2026-09-02T23:03:00.000Z');
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
new Date(gameBase.getTime() + 60_000)
);
});
it.each([
[5, 6],
[10, 3],
@@ -4,7 +4,11 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { reserveSelectionPool, resolveSelectionPoolUserIcon } from '../src/turn/selectPoolService.js';
import {
buildInitialTurnTime,
reserveSelectionPool,
resolveSelectionPoolUserIcon,
} from '../src/turn/selectPoolService.js';
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
interface TestPoolRow {
@@ -28,6 +32,30 @@ interface PoolWhere {
const acceptedAt = new Date('0200-05-01T00:00:00.000Z');
describe('selection-pool initial turn scheduling', () => {
it('does not inherit a turntime base from before the opening boundary', () => {
const openingGameAt = new Date('0200-01-01T00:00:00.000Z');
const preopenGameAt = new Date(openingGameAt.getTime() - 30 * 60_000);
const rng = {
nextRangeInt(min: number) {
return min;
},
};
const turnTime = buildInitialTurnTime(
rng,
{
tickSeconds: 300,
meta: { turntime: preopenGameAt.toISOString() },
} as unknown as Parameters<typeof buildInitialTurnTime>[1],
preopenGameAt,
openingGameAt
);
expect(turnTime.getTime()).toBeGreaterThanOrEqual(openingGameAt.getTime());
});
});
const buildRows = (): TestPoolRow[] =>
Array.from({ length: 29 }, (_, index) => {
const uniqueName = `P${String(index + 1).padStart(2, '0')}`;