fix: 가오픈 동안 서버 게임 시계를 정지한다
정식 오픈 시각을 게임 시계 anchor로 전달하고, API가 실제 진행 상태와 시작 경계를 반환하게 한다. 프론트는 추가 조회 없이 경계에서 자동으로 시계를 시작한다.
This commit is contained in:
@@ -68,7 +68,10 @@ export const lobbyRouter = router({
|
|||||||
preopenAt: worldState.meta.preopenAt ?? '',
|
preopenAt: worldState.meta.preopenAt ?? '',
|
||||||
turntime: worldState.meta.turntime ?? '',
|
turntime: worldState.meta.turntime ?? '',
|
||||||
serverTime: gameTime.now.toISOString(),
|
serverTime: gameTime.now.toISOString(),
|
||||||
|
serverWallTime: gameTime.wallNow.toISOString(),
|
||||||
clockMode: gameTime.mode ?? 'realtime',
|
clockMode: gameTime.mode ?? 'realtime',
|
||||||
|
clockRunning: gameTime.running,
|
||||||
|
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
||||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||||
npcMode: worldState.config.npcMode ?? 0,
|
npcMode: worldState.config.npcMode ?? 0,
|
||||||
defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165),
|
defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165),
|
||||||
|
|||||||
@@ -4,14 +4,25 @@ import type { DatabaseClient } from '../context.js';
|
|||||||
|
|
||||||
export interface CurrentGameTime {
|
export interface CurrentGameTime {
|
||||||
now: Date;
|
now: Date;
|
||||||
|
wallNow: Date;
|
||||||
tick: number | null;
|
tick: number | null;
|
||||||
mode: GameClockMode | null;
|
mode: GameClockMode | null;
|
||||||
|
running: boolean;
|
||||||
|
startsAt: Date | null;
|
||||||
dateToTick(date: Date): number | null;
|
dateToTick(date: Date): number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date()): Promise<CurrentGameTime> => {
|
export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date()): Promise<CurrentGameTime> => {
|
||||||
if (!db.worldState) {
|
if (!db.worldState) {
|
||||||
return { now: wallNow, tick: null, mode: null, dateToTick: () => null };
|
return {
|
||||||
|
now: wallNow,
|
||||||
|
wallNow,
|
||||||
|
tick: null,
|
||||||
|
mode: null,
|
||||||
|
running: true,
|
||||||
|
startsAt: null,
|
||||||
|
dateToTick: () => null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const state = await db.worldState.findFirst({
|
const state = await db.worldState.findFirst({
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
@@ -24,7 +35,15 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
|
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
|
||||||
return { now: wallNow, tick: null, mode: null, dateToTick: () => null };
|
return {
|
||||||
|
now: wallNow,
|
||||||
|
wallNow,
|
||||||
|
tick: null,
|
||||||
|
mode: null,
|
||||||
|
running: true,
|
||||||
|
startsAt: null,
|
||||||
|
dateToTick: () => null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
|
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
|
||||||
const storedTick = Number(state.clockTick);
|
const storedTick = Number(state.clockTick);
|
||||||
@@ -39,10 +58,14 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
turnSeconds: state.tickSeconds,
|
turnSeconds: state.tickSeconds,
|
||||||
});
|
});
|
||||||
const tick = clock.nowTick(wallNow);
|
const tick = clock.nowTick(wallNow);
|
||||||
|
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
|
||||||
return {
|
return {
|
||||||
now: clock.tickToDate(tick),
|
now: clock.tickToDate(tick),
|
||||||
|
wallNow,
|
||||||
tick,
|
tick,
|
||||||
mode,
|
mode,
|
||||||
|
running,
|
||||||
|
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null,
|
||||||
dateToTick: (date) => clock.dateToTick(date),
|
dateToTick: (date) => clock.dateToTick(date),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,8 +57,11 @@ describe('auction worker clock-shift race', () => {
|
|||||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||||
const time = {
|
const time = {
|
||||||
now,
|
now,
|
||||||
|
wallNow: now,
|
||||||
tick: 36_000_000,
|
tick: 36_000_000,
|
||||||
mode: 'manual' as const,
|
mode: 'manual' as const,
|
||||||
|
running: false,
|
||||||
|
startsAt: null,
|
||||||
dateToTick: () => 72_000_000,
|
dateToTick: () => 72_000_000,
|
||||||
};
|
};
|
||||||
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { DatabaseClient } from '../src/context.js';
|
||||||
|
import { loadCurrentGameTime } from '../src/services/gameClock.js';
|
||||||
|
|
||||||
|
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient =>
|
||||||
|
({
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
|
||||||
|
clockTick: 36_000_000n,
|
||||||
|
clockMode: mode,
|
||||||
|
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
|
tickSeconds: 600,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}) as unknown as DatabaseClient;
|
||||||
|
|
||||||
|
describe('current game time projection', () => {
|
||||||
|
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => {
|
||||||
|
const db = buildDatabase();
|
||||||
|
|
||||||
|
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||||
|
expect(preopen).toMatchObject({
|
||||||
|
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||||
|
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
||||||
|
tick: 36_000_000,
|
||||||
|
mode: 'realtime',
|
||||||
|
running: false,
|
||||||
|
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
||||||
|
expect(opened).toMatchObject({
|
||||||
|
now: new Date('2026-08-21T10:00:05.000Z'),
|
||||||
|
tick: 36_300_000,
|
||||||
|
running: true,
|
||||||
|
startsAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a manual clock stopped without scheduling an automatic start', async () => {
|
||||||
|
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||||
|
tick: 36_000_000,
|
||||||
|
running: false,
|
||||||
|
startsAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,6 +68,32 @@ describe('lobby season state', () => {
|
|||||||
|
|
||||||
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||||
expect(result.clockMode).toBe('manual');
|
expect(result.clockMode).toBe('manual');
|
||||||
|
expect(result.clockRunning).toBe(false);
|
||||||
|
expect(result.clockStartsAt).toBeNull();
|
||||||
|
expect(new Date(result.serverWallTime).getTime()).not.toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => {
|
||||||
|
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(
|
||||||
|
buildContext(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
||||||
|
tick: 36_000_000n,
|
||||||
|
mode: 'realtime',
|
||||||
|
wallAnchor,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.lobby.info();
|
||||||
|
|
||||||
|
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z');
|
||||||
|
expect(result.clockMode).toBe('realtime');
|
||||||
|
expect(result.clockRunning).toBe(false);
|
||||||
|
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||||
|
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves zero as the first official game index', async () => {
|
it('preserves zero as the first official game index', async () => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface ScenarioInstallOptions {
|
|||||||
joinMode?: 'full' | 'onlyRandom';
|
joinMode?: 'full' | 'onlyRandom';
|
||||||
autorunUser?: ScenarioAutorunOptions | null;
|
autorunUser?: ScenarioAutorunOptions | null;
|
||||||
preopenAt?: Date | null;
|
preopenAt?: Date | null;
|
||||||
|
openAt?: Date | null;
|
||||||
season?: number;
|
season?: number;
|
||||||
firstGameIdx?: number;
|
firstGameIdx?: number;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -229,11 +230,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
const sync = install?.sync ?? false;
|
const sync = install?.sync ?? false;
|
||||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||||
const gameClockMode = options.gameClockMode ?? 'realtime';
|
const gameClockMode = options.gameClockMode ?? 'realtime';
|
||||||
|
// A realtime season prepared before its formal opening must not consume
|
||||||
|
// wall time while users are only allowed to edit reserved commands.
|
||||||
|
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
|
||||||
const initialClock = new GameClock({
|
const initialClock = new GameClock({
|
||||||
baseTime: startState.startTime,
|
baseTime: startState.startTime,
|
||||||
tick: 0,
|
tick: 0,
|
||||||
mode: gameClockMode,
|
mode: gameClockMode,
|
||||||
wallAnchor: now,
|
wallAnchor: initialClockWallAnchor,
|
||||||
turnSeconds: tickSeconds,
|
turnSeconds: tickSeconds,
|
||||||
});
|
});
|
||||||
const initialClockTick = initialClock.dateToTick(now);
|
const initialClockTick = initialClock.dateToTick(now);
|
||||||
@@ -410,7 +414,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
clockBaseTime: initialClock.baseTime,
|
clockBaseTime: initialClock.baseTime,
|
||||||
clockTick: BigInt(initialClockTick),
|
clockTick: BigInt(initialClockTick),
|
||||||
clockMode: gameClockMode,
|
clockMode: gameClockMode,
|
||||||
clockWallAnchor: now,
|
clockWallAnchor: initialClock.wallAnchor,
|
||||||
lastTurnTick: BigInt(initialClockTick),
|
lastTurnTick: BigInt(initialClockTick),
|
||||||
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
||||||
meta: asJson(worldMeta),
|
meta: asJson(worldMeta),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type ScenarioSeederPrismaClient = {
|
|||||||
tickSeconds: number;
|
tickSeconds: number;
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
currentMonth: number;
|
currentMonth: number;
|
||||||
|
clockWallAnchor: Date | null;
|
||||||
} | null>;
|
} | null>;
|
||||||
};
|
};
|
||||||
gameHistory: {
|
gameHistory: {
|
||||||
@@ -364,6 +365,8 @@ describeDb('scenario database seed', () => {
|
|||||||
develop: true,
|
develop: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
preopenAt: new Date('2030-01-01T01:00:00Z'),
|
||||||
|
openAt: new Date('2030-01-01T02:00:00Z'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,6 +388,7 @@ describeDb('scenario database seed', () => {
|
|||||||
}
|
}
|
||||||
expect(worldState.tickSeconds).toBe(3600);
|
expect(worldState.tickSeconds).toBe(3600);
|
||||||
expect(worldState.currentMonth).toBe(1);
|
expect(worldState.currentMonth).toBe(1);
|
||||||
|
expect(worldState.clockWallAnchor).toEqual(new Date('2030-01-01T02:00:00.000Z'));
|
||||||
|
|
||||||
const config = (worldState.config ?? {}) as Record<string, unknown>;
|
const config = (worldState.config ?? {}) as Record<string, unknown>;
|
||||||
expect(config.extendedGeneral).toBe(false);
|
expect(config.extendedGeneral).toBe(false);
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ type NavigationFixture = {
|
|||||||
generalName?: string;
|
generalName?: string;
|
||||||
generalTurnTime?: string;
|
generalTurnTime?: string;
|
||||||
serverTime?: string;
|
serverTime?: string;
|
||||||
|
serverWallTime?: string;
|
||||||
clockMode?: 'realtime' | 'manual';
|
clockMode?: 'realtime' | 'manual';
|
||||||
|
clockRunning?: boolean;
|
||||||
|
clockStartsAt?: string | null;
|
||||||
cityDefence?: number;
|
cityDefence?: number;
|
||||||
cityState?: number;
|
cityState?: number;
|
||||||
nationRate?: number;
|
nationRate?: number;
|
||||||
@@ -555,7 +558,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
month: state.currentMonth ?? 1,
|
month: state.currentMonth ?? 1,
|
||||||
turnTerm: 10,
|
turnTerm: 10,
|
||||||
serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
|
serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
|
||||||
|
serverWallTime: state.serverWallTime ?? '2026-08-13T00:00:00.000Z',
|
||||||
clockMode: state.clockMode ?? 'realtime',
|
clockMode: state.clockMode ?? 'realtime',
|
||||||
|
clockRunning: state.clockRunning ?? true,
|
||||||
|
clockStartsAt: state.clockStartsAt ?? null,
|
||||||
scenarioTitle: state.scenarioTitle ?? '',
|
scenarioTitle: state.scenarioTitle ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1711,12 +1717,48 @@ test('main general card uses local turn time and command clock tracks corrected
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.clockMode = 'manual';
|
state.clockMode = 'manual';
|
||||||
|
state.clockRunning = false;
|
||||||
state.serverTime = '2026-08-13T00:08:30.000Z';
|
state.serverTime = '2026-08-13T00:08:30.000Z';
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
await expect(frozenClock).toHaveText('09:08:30');
|
await expect(frozenClock).toHaveText('09:08:30');
|
||||||
await page.clock.runFor(2_000);
|
await page.clock.runFor(2_000);
|
||||||
await expect(frozenClock).toHaveText('09:08:30');
|
await expect(frozenClock).toHaveText('09:08:30');
|
||||||
|
|
||||||
|
state.clockMode = 'realtime';
|
||||||
|
state.clockRunning = false;
|
||||||
|
state.serverTime = '2026-08-13T00:10:00.000Z';
|
||||||
|
state.serverWallTime = '2026-08-21T10:00:00.000Z';
|
||||||
|
state.clockStartsAt = '2026-08-21T10:00:02.000Z';
|
||||||
|
await page.reload();
|
||||||
|
const preopenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
|
await expect(preopenClock).toHaveText('09:10:00');
|
||||||
|
const operationsBeforePreopenBoundary = state.operations.length;
|
||||||
|
await page.clock.runFor(1_500);
|
||||||
|
await expect(preopenClock).toHaveText('09:10:00');
|
||||||
|
if (artifactRoot) {
|
||||||
|
const preopenGeometry = await preopenClock.evaluate((element) => ({
|
||||||
|
rect: element.getBoundingClientRect().toJSON(),
|
||||||
|
overflow: element.scrollWidth - element.clientWidth,
|
||||||
|
fontSize: getComputedStyle(element).fontSize,
|
||||||
|
lineHeight: getComputedStyle(element).lineHeight,
|
||||||
|
value: element.textContent,
|
||||||
|
}));
|
||||||
|
expect(preopenGeometry.overflow).toBeLessThanOrEqual(0);
|
||||||
|
await Promise.all([
|
||||||
|
page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'main-preopen-clock-frozen-mobile-500.png'),
|
||||||
|
fullPage: true,
|
||||||
|
}),
|
||||||
|
writeFile(
|
||||||
|
resolve(artifactRoot, 'main-preopen-clock-frozen-mobile-500.json'),
|
||||||
|
`${JSON.stringify(preopenGeometry, null, 2)}\n`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await page.clock.runFor(1_500);
|
||||||
|
await expect(preopenClock).toHaveText('09:10:01');
|
||||||
|
expect(state.operations).toHaveLength(operationsBeforePreopenBoundary);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('message targets keep reply behavior and use nation-color contrast in labels and select options', async ({
|
test('message targets keep reply behavior and use nation-color contrast in labels and select options', async ({
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ const props = defineProps<{
|
|||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
turnTermMinutes?: number;
|
turnTermMinutes?: number;
|
||||||
serverTime?: string;
|
serverTime?: string;
|
||||||
|
serverWallTime?: string;
|
||||||
clockMode?: 'realtime' | 'manual';
|
clockMode?: 'realtime' | 'manual';
|
||||||
|
clockRunning?: boolean;
|
||||||
|
clockStartsAt?: string | null;
|
||||||
autorunLimit?: number | null;
|
autorunLimit?: number | null;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
mapData?: CommandMapData | null;
|
mapData?: CommandMapData | null;
|
||||||
@@ -86,8 +89,10 @@ const autonomousUntil = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentServerTime = ref('--:--:--');
|
const currentServerTime = ref('--:--:--');
|
||||||
|
const MAX_SERVER_CLOCK_TIMER_DELAY_MS = 60_000;
|
||||||
let sampledServerTimeMs: number | null = null;
|
let sampledServerTimeMs: number | null = null;
|
||||||
let sampledClientTimeMs = 0;
|
let sampledClientTimeMs = 0;
|
||||||
|
let sampledStartDelayMs: number | null = 0;
|
||||||
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
|
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const updateServerClock = () => {
|
const updateServerClock = () => {
|
||||||
@@ -97,21 +102,42 @@ const updateServerClock = () => {
|
|||||||
currentServerTime.value = '--:--:--';
|
currentServerTime.value = '--:--:--';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectedTime = new Date(
|
const clientElapsedMs = Math.max(0, Date.now() - sampledClientTimeMs);
|
||||||
props.clockMode === 'manual' ? sampledServerTimeMs : sampledServerTimeMs + Date.now() - sampledClientTimeMs
|
const elapsedGameMs =
|
||||||
);
|
props.clockMode === 'manual' || sampledStartDelayMs === null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, clientElapsedMs - sampledStartDelayMs);
|
||||||
|
const projectedTime = new Date(sampledServerTimeMs + elapsedGameMs);
|
||||||
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
|
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
|
||||||
if (props.clockMode !== 'manual') {
|
if (props.clockMode !== 'manual' && sampledStartDelayMs !== null) {
|
||||||
serverClockTimer = setTimeout(updateServerClock, 1_000 - projectedTime.getMilliseconds());
|
const untilStartMs = sampledStartDelayMs - clientElapsedMs;
|
||||||
|
serverClockTimer = setTimeout(
|
||||||
|
updateServerClock,
|
||||||
|
untilStartMs > 0
|
||||||
|
? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS)
|
||||||
|
: 1_000 - projectedTime.getMilliseconds()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.serverTime, props.clockMode] as const,
|
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const,
|
||||||
([serverTime]) => {
|
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => {
|
||||||
const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN;
|
const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN;
|
||||||
sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null;
|
sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null;
|
||||||
sampledClientTimeMs = Date.now();
|
sampledClientTimeMs = Date.now();
|
||||||
|
if (clockMode === 'manual') {
|
||||||
|
sampledStartDelayMs = null;
|
||||||
|
} else if (clockRunning !== false) {
|
||||||
|
sampledStartDelayMs = 0;
|
||||||
|
} else {
|
||||||
|
const wallTimeMs = serverWallTime ? new Date(serverWallTime).getTime() : Number.NaN;
|
||||||
|
const startsAtMs = clockStartsAt ? new Date(clockStartsAt).getTime() : Number.NaN;
|
||||||
|
sampledStartDelayMs =
|
||||||
|
Number.isFinite(wallTimeMs) && Number.isFinite(startsAtMs)
|
||||||
|
? Math.max(0, startsAtMs - wallTimeMs)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
updateServerClock();
|
updateServerClock();
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
@@ -267,7 +267,10 @@ watch(
|
|||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
:server-time="lobbyInfo?.serverTime"
|
:server-time="lobbyInfo?.serverTime"
|
||||||
|
:server-wall-time="lobbyInfo?.serverWallTime"
|
||||||
:clock-mode="lobbyInfo?.clockMode"
|
:clock-mode="lobbyInfo?.clockMode"
|
||||||
|
:clock-running="lobbyInfo?.clockRunning"
|
||||||
|
:clock-starts-at="lobbyInfo?.clockStartsAt"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
:map-data="worldMap"
|
:map-data="worldMap"
|
||||||
:map-layout="mapLayout"
|
:map-layout="mapLayout"
|
||||||
@@ -437,7 +440,10 @@ watch(
|
|||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
:server-time="lobbyInfo?.serverTime"
|
:server-time="lobbyInfo?.serverTime"
|
||||||
|
:server-wall-time="lobbyInfo?.serverWallTime"
|
||||||
:clock-mode="lobbyInfo?.clockMode"
|
:clock-mode="lobbyInfo?.clockMode"
|
||||||
|
:clock-running="lobbyInfo?.clockRunning"
|
||||||
|
:clock-starts-at="lobbyInfo?.clockStartsAt"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
:map-data="worldMap"
|
:map-data="worldMap"
|
||||||
:map-layout="mapLayout"
|
:map-layout="mapLayout"
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ const parseInstallOptions = (
|
|||||||
joinMode: joinMode === 'full' || joinMode === 'onlyRandom' ? joinMode : undefined,
|
joinMode: joinMode === 'full' || joinMode === 'onlyRandom' ? joinMode : undefined,
|
||||||
autorunUser: autorunUser ?? null,
|
autorunUser: autorunUser ?? null,
|
||||||
preopenAt: preopenAt ?? null,
|
preopenAt: preopenAt ?? null,
|
||||||
|
openAt: openAt ?? null,
|
||||||
installOperationId: action.installOperationId,
|
installOperationId: action.installOperationId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2173,6 +2174,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
? {
|
? {
|
||||||
...options.installOptions,
|
...options.installOptions,
|
||||||
preopenAt: options.installOptions.preopenAt?.toISOString() ?? null,
|
preopenAt: options.installOptions.preopenAt?.toISOString() ?? null,
|
||||||
|
openAt: options.installOptions.openAt?.toISOString() ?? null,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
adminUser: options.adminUser,
|
adminUser: options.adminUser,
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ interface ProfileSeedRequest {
|
|||||||
scenarioId: number;
|
scenarioId: number;
|
||||||
tickSeconds?: number;
|
tickSeconds?: number;
|
||||||
now: string;
|
now: string;
|
||||||
installOptions?: Omit<ScenarioInstallOptions, 'preopenAt'> & { preopenAt?: string | null };
|
installOptions?: Omit<ScenarioInstallOptions, 'preopenAt' | 'openAt'> & {
|
||||||
|
preopenAt?: string | null;
|
||||||
|
openAt?: string | null;
|
||||||
|
};
|
||||||
adminUser?: AdminSeedUser | null;
|
adminUser?: AdminSeedUser | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +52,11 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P
|
|||||||
if (preopenAt && Number.isNaN(preopenAt.getTime())) {
|
if (preopenAt && Number.isNaN(preopenAt.getTime())) {
|
||||||
throw new Error('Profile seed preopenAt must be an ISO date-time.');
|
throw new Error('Profile seed preopenAt must be an ISO date-time.');
|
||||||
}
|
}
|
||||||
|
const rawOpenAt = request.installOptions?.openAt;
|
||||||
|
const openAt = typeof rawOpenAt === 'string' ? new Date(rawOpenAt) : null;
|
||||||
|
if (openAt && Number.isNaN(openAt.getTime())) {
|
||||||
|
throw new Error('Profile seed openAt must be an ISO date-time.');
|
||||||
|
}
|
||||||
const resourceRoot = path.join(process.cwd(), 'resources');
|
const resourceRoot = path.join(process.cwd(), 'resources');
|
||||||
|
|
||||||
await seedProfileDatabase({
|
await seedProfileDatabase({
|
||||||
@@ -61,6 +69,7 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P
|
|||||||
? {
|
? {
|
||||||
...request.installOptions,
|
...request.installOptions,
|
||||||
preopenAt,
|
preopenAt,
|
||||||
|
openAt,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
|
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
|
|||||||
firstGameIdx: 0,
|
firstGameIdx: 0,
|
||||||
installOperationId: 'selected-cli-operation',
|
installOperationId: 'selected-cli-operation',
|
||||||
installCommitSha: 'selected-cli-commit',
|
installCommitSha: 'selected-cli-commit',
|
||||||
|
preopenAt: '2036-03-03T01:00:00.000Z',
|
||||||
|
openAt: '2036-03-03T02:00:00.000Z',
|
||||||
},
|
},
|
||||||
adminUser: {
|
adminUser: {
|
||||||
id: 'selected-cli-admin',
|
id: 'selected-cli-admin',
|
||||||
@@ -69,6 +71,7 @@ describeDatabase('selected workspace profile seed CLI', () => {
|
|||||||
const world = await connector.prisma.worldState.findFirstOrThrow();
|
const world = await connector.prisma.worldState.findFirstOrThrow();
|
||||||
expect(world).toMatchObject({
|
expect(world).toMatchObject({
|
||||||
scenarioCode: '1010',
|
scenarioCode: '1010',
|
||||||
|
clockWallAnchor: new Date('2036-03-03T02:00:00.000Z'),
|
||||||
meta: {
|
meta: {
|
||||||
firstGameIdx: 0,
|
firstGameIdx: 0,
|
||||||
gameIdx: completedGameCount,
|
gameIdx: completedGameCount,
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ describe('parseProfileSeedRequest', () => {
|
|||||||
installOperationId: 'operation-id',
|
installOperationId: 'operation-id',
|
||||||
installCommitSha: 'abcdef',
|
installCommitSha: 'abcdef',
|
||||||
preopenAt: null,
|
preopenAt: null,
|
||||||
|
openAt: '2030-01-01T02:00:00.000Z',
|
||||||
},
|
},
|
||||||
adminUser: { id: 'admin', username: 'admin' },
|
adminUser: { id: 'admin', username: 'admin' },
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
scenarioId: 1010,
|
scenarioId: 1010,
|
||||||
tickSeconds: 60,
|
tickSeconds: 60,
|
||||||
installOptions: { installOperationId: 'operation-id', installCommitSha: 'abcdef' },
|
installOptions: {
|
||||||
|
installOperationId: 'operation-id',
|
||||||
|
installCommitSha: 'abcdef',
|
||||||
|
openAt: '2030-01-01T02:00:00.000Z',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user