가오픈 장수 턴의 오픈 전 적체 방지
This commit is contained in:
@@ -656,6 +656,16 @@ export class InMemoryTurnWorld {
|
|||||||
return this.getGameClock().now(wallNow);
|
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 {
|
dateToGameTick(date: Date): number {
|
||||||
return this.getGameClock().dateToTick(date);
|
return this.getGameClock().dateToTick(date);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,7 +383,9 @@ export const resolveJoinTurnTime = (
|
|||||||
}
|
}
|
||||||
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
|
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
|
||||||
if (turnTime.getTime() <= acceptedAt.getTime()) {
|
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;
|
return turnTime;
|
||||||
};
|
};
|
||||||
@@ -523,9 +525,11 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
worldState: WorldStateRow;
|
worldState: WorldStateRow;
|
||||||
input: JoinCreateGeneralInput;
|
input: JoinCreateGeneralInput;
|
||||||
acceptedAt: Date;
|
acceptedAt: Date;
|
||||||
|
turnScheduleAt?: Date;
|
||||||
operationalAcceptedAt: Date;
|
operationalAcceptedAt: Date;
|
||||||
}): Promise<{ ok: true; generalId: number }> => {
|
}): Promise<{ ok: true; generalId: number }> => {
|
||||||
const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options;
|
const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options;
|
||||||
|
const turnScheduleAt = options.turnScheduleAt ?? acceptedAt;
|
||||||
await lockJoinMutation(db, input.userId);
|
await lockJoinMutation(db, input.userId);
|
||||||
await assertGeneralIdSnapshotMatches(db, world);
|
await assertGeneralIdSnapshotMatches(db, world);
|
||||||
|
|
||||||
@@ -707,7 +711,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
const turnTime = resolveJoinTurnTime(
|
const turnTime = resolveJoinTurnTime(
|
||||||
rng,
|
rng,
|
||||||
worldState,
|
worldState,
|
||||||
acceptedAt,
|
turnScheduleAt,
|
||||||
world.getState().lastTurnTime,
|
world.getState().lastTurnTime,
|
||||||
input.inheritTurntimeZone
|
input.inheritTurntimeZone
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -651,11 +651,18 @@ const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => {
|
|||||||
return now;
|
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 termSeconds = resolveTurnTermMinutes(worldState) * 60;
|
||||||
const seconds = rng.nextRangeInt(0, termSeconds - 1);
|
const seconds = rng.nextRangeInt(0, termSeconds - 1);
|
||||||
const microseconds = rng.nextRangeInt(0, 999_999);
|
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: {
|
const appendSelectionLogs = async (options: {
|
||||||
@@ -702,6 +709,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
uniqueName: string;
|
uniqueName: string;
|
||||||
personality: string;
|
personality: string;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
|
turnScheduleAt?: Date;
|
||||||
operationalAcceptedAt: Date;
|
operationalAcceptedAt: Date;
|
||||||
seedOwnerIdentity?: string | number;
|
seedOwnerIdentity?: string | number;
|
||||||
ownerPicture?: string;
|
ownerPicture?: string;
|
||||||
@@ -761,7 +769,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const city = rng.choice(cities);
|
const city = rng.choice(cities);
|
||||||
const turnTime = buildInitialTurnTime(rng, worldState, now);
|
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
|
||||||
const age = 20;
|
const age = 20;
|
||||||
const specialityAges = resolveSpecialityAges(worldState, age);
|
const specialityAges = resolveSpecialityAges(worldState, age);
|
||||||
const nextChangeAt = new Date(
|
const nextChangeAt = new Date(
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ async function handleJoinCreateGeneral(
|
|||||||
}
|
}
|
||||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||||
|
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'joinCreateGeneral',
|
type: 'joinCreateGeneral',
|
||||||
@@ -377,6 +378,7 @@ async function handleJoinCreateGeneral(
|
|||||||
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
|
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
|
||||||
},
|
},
|
||||||
acceptedAt,
|
acceptedAt,
|
||||||
|
turnScheduleAt,
|
||||||
operationalAcceptedAt,
|
operationalAcceptedAt,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -455,6 +457,7 @@ async function handleSelectPoolCreate(
|
|||||||
: command.acceptedGameAt !== undefined
|
: command.acceptedGameAt !== undefined
|
||||||
? new Date(command.acceptedGameAt)
|
? new Date(command.acceptedGameAt)
|
||||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||||
|
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
@@ -471,6 +474,7 @@ async function handleSelectPoolCreate(
|
|||||||
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||||
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
|
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
|
||||||
now: acceptedAt,
|
now: acceptedAt,
|
||||||
|
turnScheduleAt,
|
||||||
operationalAcceptedAt,
|
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', () => {
|
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
|
||||||
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
||||||
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
||||||
|
|||||||
@@ -174,6 +174,26 @@ describe('runtime clock shift', () => {
|
|||||||
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
|
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([
|
it.each([
|
||||||
[5, 6],
|
[5, 6],
|
||||||
[10, 3],
|
[10, 3],
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
|||||||
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
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';
|
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
interface TestPoolRow {
|
interface TestPoolRow {
|
||||||
@@ -28,6 +32,30 @@ interface PoolWhere {
|
|||||||
|
|
||||||
const acceptedAt = new Date('0200-05-01T00:00:00.000Z');
|
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[] =>
|
const buildRows = (): TestPoolRow[] =>
|
||||||
Array.from({ length: 29 }, (_, index) => {
|
Array.from({ length: 29 }, (_, index) => {
|
||||||
const uniqueName = `P${String(index + 1).padStart(2, '0')}`;
|
const uniqueName = `P${String(index + 1).padStart(2, '0')}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user