fix(backend): 생성 장수 첫 턴을 접수 시각에 맞춤
daemon 완료 시각이 뒤처져도 신규 장수 턴을 접수된 논리 게임 시각부터 한 턴 안에 배정한다. 로비 응답에는 보정 시계가 사용할 서버 게임 시각과 clock mode를 추가한다.
This commit is contained in:
@@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common';
|
|||||||
|
|
||||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||||
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||||
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { procedure, router } from '../../trpc.js';
|
import { procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
export const lobbyRouter = router({
|
export const lobbyRouter = router({
|
||||||
@@ -26,6 +27,7 @@ export const lobbyRouter = router({
|
|||||||
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
||||||
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
||||||
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
||||||
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
|
|
||||||
let myGeneral = null;
|
let myGeneral = null;
|
||||||
if (ctx.auth?.user.id) {
|
if (ctx.auth?.user.id) {
|
||||||
@@ -54,6 +56,8 @@ export const lobbyRouter = router({
|
|||||||
starttime: worldState.meta.starttime ?? '',
|
starttime: worldState.meta.starttime ?? '',
|
||||||
opentime: worldState.meta.opentime ?? '',
|
opentime: worldState.meta.opentime ?? '',
|
||||||
turntime: worldState.meta.turntime ?? '',
|
turntime: worldState.meta.turntime ?? '',
|
||||||
|
serverTime: gameTime.now.toISOString(),
|
||||||
|
clockMode: gameTime.mode ?? 'realtime',
|
||||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
const buildContext = (
|
||||||
|
meta: Record<string, unknown>,
|
||||||
|
clock: {
|
||||||
|
baseTime?: Date;
|
||||||
|
tick?: bigint;
|
||||||
|
mode?: string;
|
||||||
|
wallAnchor?: Date;
|
||||||
|
} = {}
|
||||||
|
): GameApiContext =>
|
||||||
({
|
({
|
||||||
auth: null,
|
auth: null,
|
||||||
db: {
|
db: {
|
||||||
@@ -16,6 +24,10 @@ const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
|||||||
tickSeconds: 3_600,
|
tickSeconds: 3_600,
|
||||||
config: {},
|
config: {},
|
||||||
meta,
|
meta,
|
||||||
|
clockBaseTime: clock.baseTime ?? null,
|
||||||
|
clockTick: clock.tick ?? null,
|
||||||
|
clockMode: clock.mode ?? 'realtime',
|
||||||
|
clockWallAnchor: clock.wallAnchor ?? null,
|
||||||
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -36,4 +48,23 @@ describe('lobby season state', () => {
|
|||||||
|
|
||||||
expect(result.isUnited).toBe(isunited);
|
expect(result.isUnited).toBe(isunited);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the projected server game time and whether the clock is running', async () => {
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(
|
||||||
|
buildContext(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
baseTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||||
|
tick: 72_000_000n,
|
||||||
|
mode: 'manual',
|
||||||
|
wallAnchor: new Date('2026-08-15T17:00:00.000Z'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.lobby.info();
|
||||||
|
|
||||||
|
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||||
|
expect(result.clockMode).toBe('manual');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -330,8 +330,8 @@ export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
|
|||||||
return new Date(baseTime + alignedSeconds * 1000);
|
return new Date(baseTime + alignedSeconds * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveTurnTime = (
|
export const resolveJoinTurnTime = (
|
||||||
rng: RandUtil,
|
rng: Pick<RandUtil, 'nextRangeInt'>,
|
||||||
worldState: WorldStateRow,
|
worldState: WorldStateRow,
|
||||||
acceptedAt: Date,
|
acceptedAt: Date,
|
||||||
runtimeTurnTime: Date,
|
runtimeTurnTime: Date,
|
||||||
@@ -348,7 +348,12 @@ const resolveTurnTime = (
|
|||||||
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
||||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
} else {
|
} else {
|
||||||
turnTimeBase = base;
|
// Ref normally uses game_env.turntime as a near-current cursor. Core's
|
||||||
|
// durable daemon can legitimately be catching up from an older cursor,
|
||||||
|
// so scheduling from runtimeTurnTime may put a newly created general
|
||||||
|
// hours behind the game clock. The accepted game time is the equivalent
|
||||||
|
// current-time boundary for a new general.
|
||||||
|
turnTimeBase = acceptedAt;
|
||||||
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
||||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
}
|
}
|
||||||
@@ -662,7 +667,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const experience = await resolveCatchupExperience(db, relativeYear);
|
const experience = await resolveCatchupExperience(db, relativeYear);
|
||||||
const turnTime = resolveTurnTime(
|
const turnTime = resolveJoinTurnTime(
|
||||||
rng,
|
rng,
|
||||||
worldState,
|
worldState,
|
||||||
acceptedAt,
|
acceptedAt,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildJoinCreateGeneralSeed,
|
buildJoinCreateGeneralSeed,
|
||||||
cutJoinTurnTime,
|
cutJoinTurnTime,
|
||||||
JOIN_WELCOME_MESSAGE,
|
JOIN_WELCOME_MESSAGE,
|
||||||
|
resolveJoinTurnTime,
|
||||||
} from '../src/turn/joinCreateGeneralService.js';
|
} from '../src/turn/joinCreateGeneralService.js';
|
||||||
|
|
||||||
describe('generic join legacy time contracts', () => {
|
describe('generic join legacy time contracts', () => {
|
||||||
@@ -19,6 +20,35 @@ describe('generic join legacy time contracts', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('schedules a new general within one turn of the accepted game time even when the daemon cursor is stale', () => {
|
||||||
|
const calls: Array<[number, number]> = [];
|
||||||
|
const values = [59, 250_000];
|
||||||
|
const rng = {
|
||||||
|
nextRangeInt(min: number, max: number) {
|
||||||
|
calls.push([min, max]);
|
||||||
|
return values.shift() ?? min;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const acceptedAt = new Date('2026-08-15T17:57:05.837Z');
|
||||||
|
const staleRuntimeTurnTime = new Date('2026-08-15T07:10:00.000Z');
|
||||||
|
|
||||||
|
const turnTime = resolveJoinTurnTime(
|
||||||
|
rng,
|
||||||
|
{ tickSeconds: 120 } as Parameters<typeof resolveJoinTurnTime>[1],
|
||||||
|
acceptedAt,
|
||||||
|
staleRuntimeTurnTime,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(turnTime.toISOString()).toBe('2026-08-15T17:58:05.087Z');
|
||||||
|
expect(turnTime.getTime()).toBeGreaterThan(acceptedAt.getTime());
|
||||||
|
expect(turnTime.getTime()).toBeLessThanOrEqual(acceptedAt.getTime() + 120_000);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
[0, 119],
|
||||||
|
[0, 999_999],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user