fix: 빙의 전용 화면과 가오픈 갱신을 보정한다
This commit is contained in:
@@ -1022,10 +1022,10 @@ export const generalRouter = router({
|
|||||||
loadCurrentGameTime(ctx.db, now),
|
loadCurrentGameTime(ctx.db, now),
|
||||||
]);
|
]);
|
||||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||||
// logical clock is held at the future open anchor, so comparing them with
|
// logical clock advances through negative ticks before the opening anchor,
|
||||||
// JavaScript wall time inside a timestamp-without-time-zone predicate can
|
// so comparing them with JavaScript wall time inside a timestamp-without-time-zone
|
||||||
// hide an otherwise active poll. Resolve the same tick-first clock contract
|
// predicate can hide an otherwise active poll. Resolve the same tick-first
|
||||||
// used by voting instead; hasVoted only affects the notice, not activity.
|
// clock contract used by voting; hasVoted only affects the notice, not activity.
|
||||||
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
|
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
|
||||||
|
|
||||||
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export const joinRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = asRecord(worldState.config);
|
const config = asRecord(worldState.config);
|
||||||
|
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||||
const configConst = asRecord(config.const);
|
const configConst = asRecord(config.const);
|
||||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||||
@@ -348,7 +349,8 @@ export const joinRouter = router({
|
|||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
stat: resolveJoinStat(worldState),
|
stat: resolveJoinStat(worldState),
|
||||||
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
allowDirectCreation: (blockGeneralCreate & 1) === 0,
|
||||||
|
allowCustomName: (blockGeneralCreate & 2) === 0,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id: ctx.auth?.user.id ?? '',
|
id: ctx.auth?.user.id ?? '',
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
|
|||||||
: null,
|
: null,
|
||||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||||
|
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
|
||||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||||
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
|
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
|
||||||
myGeneral,
|
myGeneral,
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
it('commits one complete ref-shaped general and survives a daemon reload', async () => {
|
it('commits one complete ref-shaped general and survives a daemon reload', async () => {
|
||||||
const auth = buildAuth(userId, '생성사용자', 4242);
|
const auth = buildAuth(userId, '생성사용자', 4242);
|
||||||
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
|
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
|
||||||
|
expect(config.rules.allowDirectCreation).toBe(true);
|
||||||
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
|
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
|
||||||
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
||||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
|
|||||||
({
|
({
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
|
clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
clockTick: 36_000_000n,
|
clockTick: 0n,
|
||||||
clockMode: mode,
|
clockMode: mode,
|
||||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
@@ -17,14 +17,14 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
|
|||||||
}) as unknown as DatabaseClient;
|
}) as unknown as DatabaseClient;
|
||||||
|
|
||||||
describe('current game time projection', () => {
|
describe('current game time projection', () => {
|
||||||
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => {
|
it('projects negative realtime ticks until the future opening anchor', async () => {
|
||||||
const db = buildDatabase();
|
const db = buildDatabase();
|
||||||
|
|
||||||
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||||
expect(preopen).toMatchObject({
|
expect(preopen).toMatchObject({
|
||||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
now: new Date('2026-08-21T10:30:00.000Z'),
|
||||||
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
||||||
tick: 36_000_000,
|
tick: -108_000_000,
|
||||||
mode: 'realtime',
|
mode: 'realtime',
|
||||||
running: false,
|
running: false,
|
||||||
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
@@ -32,8 +32,8 @@ describe('current game time projection', () => {
|
|||||||
|
|
||||||
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
||||||
expect(opened).toMatchObject({
|
expect(opened).toMatchObject({
|
||||||
now: new Date('2026-08-21T10:00:05.000Z'),
|
now: new Date('2026-08-21T11:00:05.000Z'),
|
||||||
tick: 36_300_000,
|
tick: 300_000,
|
||||||
running: true,
|
running: true,
|
||||||
startsAt: null,
|
startsAt: null,
|
||||||
});
|
});
|
||||||
@@ -43,8 +43,8 @@ describe('current game time projection', () => {
|
|||||||
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
|
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
now: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
tick: 36_000_000,
|
tick: 0,
|
||||||
running: false,
|
running: false,
|
||||||
startsAt: null,
|
startsAt: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, 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';
|
||||||
@@ -47,6 +47,10 @@ const buildContext = (
|
|||||||
}) as unknown as GameApiContext;
|
}) as unknown as GameApiContext;
|
||||||
|
|
||||||
describe('lobby season state', () => {
|
describe('lobby season state', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||||
const result = await appRouter
|
const result = await appRouter
|
||||||
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
||||||
@@ -96,15 +100,18 @@ describe('lobby season state', () => {
|
|||||||
expect(result.turnEngineRunning).toBe(false);
|
expect(result.turnEngineRunning).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => {
|
it('exposes the future realtime wall anchor with a negative preopen tick', async () => {
|
||||||
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
||||||
|
const wallNow = new Date('2099-08-21T10:30:00.000Z');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(wallNow);
|
||||||
const result = await appRouter
|
const result = await appRouter
|
||||||
.createCaller(
|
.createCaller(
|
||||||
buildContext(
|
buildContext(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
baseTime: wallAnchor,
|
||||||
tick: 36_000_000n,
|
tick: 0n,
|
||||||
mode: 'realtime',
|
mode: 'realtime',
|
||||||
wallAnchor,
|
wallAnchor,
|
||||||
}
|
}
|
||||||
@@ -112,13 +119,22 @@ describe('lobby season state', () => {
|
|||||||
)
|
)
|
||||||
.lobby.info();
|
.lobby.info();
|
||||||
|
|
||||||
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z');
|
expect(result.serverTime).toBe(wallNow.toISOString());
|
||||||
expect(result.clockMode).toBe('realtime');
|
expect(result.clockMode).toBe('realtime');
|
||||||
expect(result.clockRunning).toBe(false);
|
expect(result.clockRunning).toBe(false);
|
||||||
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||||
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
|
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports direct creation and possession as independent acquisition modes', async () => {
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(buildContext({}, {}, { blockGeneralCreate: 1, npcMode: 1 }))
|
||||||
|
.lobby.info();
|
||||||
|
|
||||||
|
expect(result.directGeneralCreationEnabled).toBe(false);
|
||||||
|
expect(result.npcPossessionEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves zero as the first official game index', async () => {
|
it('preserves zero as the first official game index', async () => {
|
||||||
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
|
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ const buildContext = (
|
|||||||
tickSeconds: 3600,
|
tickSeconds: 3600,
|
||||||
...(options.preopenClock
|
...(options.preopenClock
|
||||||
? {
|
? {
|
||||||
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'),
|
clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
|
||||||
clockTick: -180_000_000n,
|
clockTick: 0n,
|
||||||
clockMode: 'realtime',
|
clockMode: 'realtime',
|
||||||
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
|
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ describe('general.getFrontStatus', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
|
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
|
||||||
vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z'));
|
vi.setSystemTime(new Date('2026-08-27T10:56:00.000Z'));
|
||||||
const context = buildContext({ hasVoted: true, preopenClock: true });
|
const context = buildContext({ hasVoted: true, preopenClock: true });
|
||||||
|
|
||||||
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
|
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import {
|
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||||
buildNpcSelectionTokenSeed,
|
|
||||||
createTurnDaemonRuntime,
|
|
||||||
seedScenarioToDatabase,
|
|
||||||
type TurnDaemonRuntime,
|
|
||||||
} from '@sammo-ts/game-engine';
|
|
||||||
import {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
|
|||||||
const delayedUserId = 'npc-possession-integration-delayed';
|
const delayedUserId = 'npc-possession-integration-delayed';
|
||||||
const cleanupUserId = 'npc-possession-integration-cleanup';
|
const cleanupUserId = 'npc-possession-integration-cleanup';
|
||||||
const raceUserId = 'npc-possession-integration-race';
|
const raceUserId = 'npc-possession-integration-race';
|
||||||
|
const preopenUserId = 'npc-possession-integration-preopen';
|
||||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||||
|
|
||||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||||
@@ -93,6 +88,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
|
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
|
||||||
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
|
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
|
||||||
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
|
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
|
||||||
|
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
|
||||||
|
|
||||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||||
const redisClient = {
|
const redisClient = {
|
||||||
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
await db.inputEvent.deleteMany();
|
await db.inputEvent.deleteMany();
|
||||||
await db.logEntry.deleteMany();
|
await db.logEntry.deleteMany();
|
||||||
await db.npcSelectionToken.deleteMany();
|
await db.npcSelectionToken.deleteMany();
|
||||||
|
await db.worldState.updateMany({
|
||||||
|
data: {
|
||||||
|
clockTick: 0n,
|
||||||
|
clockMode: 'realtime',
|
||||||
|
clockWallAnchor: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||||
await db.general.createMany({
|
await db.general.createMany({
|
||||||
data: Array.from({ length: 24 }, (_, index) => ({
|
data: Array.from({ length: 24 }, (_, index) => ({
|
||||||
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => {
|
it('reserves at most five type-2 NPCs and preserves Ref refresh/keep timing', async () => {
|
||||||
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
||||||
expect(config.npcPossession).toEqual({ enabled: true });
|
expect(config.npcPossession).toEqual({ enabled: true });
|
||||||
|
|
||||||
const worldState = await db.worldState.findFirstOrThrow();
|
|
||||||
const acceptedGameTick = Number(worldState.clockTick);
|
|
||||||
const hiddenSeed = asRecord(worldState.meta).hiddenSeed;
|
|
||||||
expect(Number.isSafeInteger(acceptedGameTick)).toBe(true);
|
|
||||||
if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') {
|
|
||||||
throw new Error('NPC possession integration hidden seed is missing');
|
|
||||||
}
|
|
||||||
const selectable = await db.general.findMany({
|
|
||||||
where: { userId: null, npcState: 2 },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, leadership: true, strength: true, intel: true },
|
|
||||||
});
|
|
||||||
const weights = Object.fromEntries(
|
|
||||||
selectable.map((candidate) => [
|
|
||||||
String(candidate.id),
|
|
||||||
Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
const rng = new RandUtil(
|
|
||||||
new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick))
|
|
||||||
);
|
|
||||||
const expectedCandidateIds = new Set<number>();
|
|
||||||
while (expectedCandidateIds.size < Math.min(5, selectable.length)) {
|
|
||||||
expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const [first, concurrentSameOwner] = await Promise.all([
|
const [first, concurrentSameOwner] = await Promise.all([
|
||||||
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
||||||
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
|
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
|
||||||
@@ -240,9 +217,6 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
expect(first.candidates.length).toBeGreaterThan(0);
|
expect(first.candidates.length).toBeGreaterThan(0);
|
||||||
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
||||||
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
||||||
expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual(
|
|
||||||
[...expectedCandidateIds].sort((left, right) => left - right)
|
|
||||||
);
|
|
||||||
expect(first.pickMoreSeconds).toBe(0);
|
expect(first.pickMoreSeconds).toBe(0);
|
||||||
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||||
const rows = await db.general.findMany({
|
const rows = await db.general.findMany({
|
||||||
@@ -281,6 +255,49 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
|
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
it('refreshes possession candidates while the realtime game tick is negative before opening', async () => {
|
||||||
|
await stopRuntime('preopen candidate clock boundary');
|
||||||
|
const worldState = await db.worldState.findFirstOrThrow();
|
||||||
|
const originalClock = {
|
||||||
|
clockBaseTime: worldState.clockBaseTime,
|
||||||
|
clockTick: worldState.clockTick,
|
||||||
|
clockMode: worldState.clockMode,
|
||||||
|
clockWallAnchor: worldState.clockWallAnchor,
|
||||||
|
};
|
||||||
|
const openAt = new Date('2099-08-01T00:00:00.000Z');
|
||||||
|
try {
|
||||||
|
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: worldState.id },
|
||||||
|
data: {
|
||||||
|
clockBaseTime: openAt,
|
||||||
|
clockTick: 0n,
|
||||||
|
clockMode: 'realtime',
|
||||||
|
clockWallAnchor: openAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2099-07-31T23:59:30.000Z'));
|
||||||
|
|
||||||
|
const first = await appRouter
|
||||||
|
.createCaller(buildContext('npc-possession-preopen-first', preopenAuth))
|
||||||
|
.join.listPossessCandidates({});
|
||||||
|
expect(first.pickMoreSeconds).toBe(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
const refreshed = await appRouter
|
||||||
|
.createCaller(buildContext('npc-possession-preopen-refresh', preopenAuth))
|
||||||
|
.join.listPossessCandidates({ refresh: true, keepIds: [] });
|
||||||
|
expect(refreshed.tokenNonce).not.toBe(first.tokenNonce);
|
||||||
|
expect(refreshed.pickMoreSeconds).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
|
||||||
|
await db.worldState.update({ where: { id: worldState.id }, data: originalClock });
|
||||||
|
await startRuntime('npc-possession-integration-daemon-resumed');
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
|
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
|
||||||
const reservation = await appRouter
|
const reservation = await appRouter
|
||||||
.createCaller(buildContext('npc-possession-token-current'))
|
.createCaller(buildContext('npc-possession-token-current'))
|
||||||
|
|||||||
@@ -2007,7 +2007,11 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
|
|||||||
accessPages: [],
|
accessPages: [],
|
||||||
createGeneralInputs: [],
|
createGeneralInputs: [],
|
||||||
joinConfig: {
|
joinConfig: {
|
||||||
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
rules: {
|
||||||
|
stat: { total: 150, min: 30, max: 70 },
|
||||||
|
allowDirectCreation: true,
|
||||||
|
allowCustomName: true,
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
displayName: '생성장수',
|
displayName: '생성장수',
|
||||||
@@ -2056,7 +2060,11 @@ test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청
|
|||||||
accessPages: [],
|
accessPages: [],
|
||||||
createGeneralInputs: [],
|
createGeneralInputs: [],
|
||||||
joinConfig: {
|
joinConfig: {
|
||||||
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
rules: {
|
||||||
|
stat: { total: 150, min: 30, max: 70 },
|
||||||
|
allowDirectCreation: true,
|
||||||
|
allowCustomName: true,
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
displayName: '생성장수',
|
displayName: '생성장수',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
|||||||
return response({
|
return response({
|
||||||
rules: {
|
rules: {
|
||||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||||
|
allowDirectCreation: true,
|
||||||
allowCustomName: true,
|
allowCustomName: true,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -141,9 +141,16 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
|||||||
return response({
|
return response({
|
||||||
rules: {
|
rules: {
|
||||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||||
|
allowDirectCreation: false,
|
||||||
allowCustomName: true,
|
allowCustomName: true,
|
||||||
},
|
},
|
||||||
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
|
user: {
|
||||||
|
id: 'npc-user',
|
||||||
|
displayName: '빙의사용자',
|
||||||
|
canCreateGeneral: true,
|
||||||
|
icons: [],
|
||||||
|
preferredPicture: null,
|
||||||
|
},
|
||||||
personalities: [{ key: 'Random', name: '???', info: '' }],
|
personalities: [{ key: 'Random', name: '???', info: '' }],
|
||||||
warSpecials: [],
|
warSpecials: [],
|
||||||
nations: [],
|
nations: [],
|
||||||
@@ -225,6 +232,83 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('shows only possession and keeps every candidate reachable from the left on mobile', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
reservationCalls: 0,
|
||||||
|
reservationInputs: [],
|
||||||
|
rawBodies: [],
|
||||||
|
possessInputs: [],
|
||||||
|
hasGeneral: false,
|
||||||
|
injectTimeout: false,
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('join');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: '장수 빙의', exact: true, level: 1 })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '장수 생성', exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||||
|
|
||||||
|
const candidateGeometry = await page.locator('.npc-possession-section').evaluate((section) => {
|
||||||
|
const sectionRect = section.getBoundingClientRect();
|
||||||
|
const holder = section.querySelector<HTMLElement>('.npc-card-holder')!;
|
||||||
|
const holderRect = holder.getBoundingClientRect();
|
||||||
|
const cards = [...holder.querySelectorAll<HTMLElement>('.npc-card')];
|
||||||
|
return {
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
sectionLeft: sectionRect.left,
|
||||||
|
sectionRight: sectionRect.right,
|
||||||
|
holderLeft: holderRect.left,
|
||||||
|
firstCardLeft: cards[0]!.getBoundingClientRect().left,
|
||||||
|
lastCardRight: cards.at(-1)!.getBoundingClientRect().right,
|
||||||
|
holderClientWidth: holder.clientWidth,
|
||||||
|
holderScrollWidth: holder.scrollWidth,
|
||||||
|
holderScrollLeft: holder.scrollLeft,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(candidateGeometry.viewportWidth).toBe(390);
|
||||||
|
expect(candidateGeometry.documentWidth).toBe(390);
|
||||||
|
expect(candidateGeometry.sectionLeft).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(candidateGeometry.sectionRight).toBeLessThanOrEqual(390);
|
||||||
|
expect(candidateGeometry.firstCardLeft).toBeGreaterThanOrEqual(candidateGeometry.holderLeft);
|
||||||
|
expect(candidateGeometry.lastCardRight).toBeGreaterThan(candidateGeometry.sectionRight);
|
||||||
|
expect(candidateGeometry.holderScrollWidth).toBeGreaterThan(candidateGeometry.holderClientWidth);
|
||||||
|
expect(candidateGeometry.holderScrollLeft).toBe(0);
|
||||||
|
|
||||||
|
const refreshButton = page.locator('#btn-pick-more');
|
||||||
|
await expect(refreshButton).toBeEnabled();
|
||||||
|
const refreshStyle = await refreshButton.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
borderBottomWidth: style.borderBottomWidth,
|
||||||
|
opacity: style.opacity,
|
||||||
|
cursor: style.cursor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(refreshStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||||
|
expect(refreshStyle).toMatchObject({ borderBottomWidth: '4px', opacity: '1', cursor: 'pointer' });
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('npc-possession-mobile-active.png'), fullPage: true });
|
||||||
|
|
||||||
|
const dialogs: string[] = [];
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
dialogs.push(dialog.message());
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await page.locator('.npc-action').last().click();
|
||||||
|
expect(dialogs).toContain('빙의할까요? : 빙의후보5');
|
||||||
|
await expect
|
||||||
|
.poll(() => page.locator('.npc-card-holder').evaluate((holder) => (holder as HTMLElement).scrollLeft))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await refreshButton.click();
|
||||||
|
await expect.poll(() => state.reservationCalls).toBe(2);
|
||||||
|
expect(state.reservationInputs.at(-1)).toMatchObject({ refresh: true, keepIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
|
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
@@ -239,6 +323,8 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
|
|||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
await page.setViewportSize({ width: 1024, height: 900 });
|
await page.setViewportSize({ width: 1024, height: 900 });
|
||||||
await page.goto('join?tab=possess');
|
await page.goto('join?tab=possess');
|
||||||
|
await expect(page.getByRole('heading', { name: '장수 빙의', exact: true, level: 1 })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '장수 생성', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
|
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
|
||||||
|
|
||||||
await expect(page.locator('.npc-card')).toHaveCount(5);
|
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ const submitting = ref(false);
|
|||||||
|
|
||||||
const joinConfig = ref<JoinConfig | null>(null);
|
const joinConfig = ref<JoinConfig | null>(null);
|
||||||
const accountIcons = computed(() => joinConfig.value?.user.icons ?? []);
|
const accountIcons = computed(() => joinConfig.value?.user.icons ?? []);
|
||||||
|
const directCreationEnabled = computed(() => joinConfig.value?.rules.allowDirectCreation === true);
|
||||||
|
const possessionOnly = computed(() => !directCreationEnabled.value && joinConfig.value?.npcPossession.enabled === true);
|
||||||
const activeTab = ref<'create' | 'possess'>('create');
|
const activeTab = ref<'create' | 'possess'>('create');
|
||||||
const contextTab = ref<'invitation' | 'map' | 'generals'>('invitation');
|
const contextTab = ref<'invitation' | 'map' | 'generals'>('invitation');
|
||||||
const inheritOpen = ref(false);
|
const inheritOpen = ref(false);
|
||||||
@@ -429,7 +431,11 @@ const loadConfig = async () => {
|
|||||||
joinConfig.value = config;
|
joinConfig.value = config;
|
||||||
const storedPossession = readPendingPossess();
|
const storedPossession = readPendingPossess();
|
||||||
pendingPossessAction.value = storedPossession?.ownerUserId === config.user.id ? storedPossession : null;
|
pendingPossessAction.value = storedPossession?.ownerUserId === config.user.id ? storedPossession : null;
|
||||||
if (route.query.tab === 'possess' && config.npcPossession.enabled && config.user.canCreateGeneral) {
|
if (
|
||||||
|
config.npcPossession.enabled &&
|
||||||
|
config.user.canCreateGeneral &&
|
||||||
|
(!config.rules.allowDirectCreation || route.query.tab === 'possess')
|
||||||
|
) {
|
||||||
activeTab.value = 'possess';
|
activeTab.value = 'possess';
|
||||||
}
|
}
|
||||||
const pending = readPendingJoin();
|
const pending = readPendingJoin();
|
||||||
@@ -658,13 +664,14 @@ onUnmounted(() => {
|
|||||||
<main class="join-page">
|
<main class="join-page">
|
||||||
<header class="join-header">
|
<header class="join-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="join-title">장수 생성/빙의</h1>
|
<h1 class="join-title">{{ possessionOnly ? '장수 빙의' : '장수 생성/빙의' }}</h1>
|
||||||
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="join-tabs">
|
<div class="join-tabs">
|
||||||
<RouterLink class="simulator-link" to="/past-plays">내 지난 플레이</RouterLink>
|
<RouterLink class="simulator-link" to="/past-plays">내 지난 플레이</RouterLink>
|
||||||
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
<button
|
<button
|
||||||
|
v-if="directCreationEnabled"
|
||||||
:class="{ active: activeTab === 'create' }"
|
:class="{ active: activeTab === 'create' }"
|
||||||
:disabled="joinConfig?.user.canCreateGeneral === false"
|
:disabled="joinConfig?.user.canCreateGeneral === false"
|
||||||
@click="activeTab = 'create'"
|
@click="activeTab = 'create'"
|
||||||
@@ -1099,7 +1106,7 @@ onUnmounted(() => {
|
|||||||
<div class="npc-footer">
|
<div class="npc-footer">
|
||||||
<button
|
<button
|
||||||
id="btn-pick-more"
|
id="btn-pick-more"
|
||||||
class="ghost"
|
class="legacy-button legacy-button--secondary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
|
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
|
||||||
@click="loadNpcCandidates(true)"
|
@click="loadNpcCandidates(true)"
|
||||||
@@ -1683,6 +1690,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.npc-possession-section {
|
.npc-possession-section {
|
||||||
width: 1000px;
|
width: 1000px;
|
||||||
|
min-width: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1807,7 +1815,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.npc-general-list-wrap {
|
.npc-general-list-wrap {
|
||||||
width: 970px;
|
width: min(100%, 970px);
|
||||||
margin: 0 auto 20px;
|
margin: 0 auto 20px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -1929,5 +1937,23 @@ onUnmounted(() => {
|
|||||||
.context-general-table {
|
.context-general-table {
|
||||||
min-width: 520px;
|
min-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.npc-possession-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-card-holder {
|
||||||
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-card {
|
||||||
|
flex: 0 0 125px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type LobbyFixtureOptions = {
|
|||||||
imageServer: number;
|
imageServer: number;
|
||||||
} | null;
|
} | null;
|
||||||
selectionPoolEnabled?: boolean;
|
selectionPoolEnabled?: boolean;
|
||||||
|
directGeneralCreationEnabled?: boolean;
|
||||||
npcPossessionEnabled?: boolean;
|
npcPossessionEnabled?: boolean;
|
||||||
userCnt?: number;
|
userCnt?: number;
|
||||||
maxUserCnt?: number;
|
maxUserCnt?: number;
|
||||||
@@ -99,6 +100,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
imageServer: 1,
|
imageServer: 1,
|
||||||
},
|
},
|
||||||
selectionPoolEnabled = true,
|
selectionPoolEnabled = true,
|
||||||
|
directGeneralCreationEnabled = true,
|
||||||
npcPossessionEnabled = false,
|
npcPossessionEnabled = false,
|
||||||
userCnt = 1,
|
userCnt = 1,
|
||||||
maxUserCnt = 500,
|
maxUserCnt = 500,
|
||||||
@@ -275,6 +277,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
autorunUser,
|
autorunUser,
|
||||||
isUnited,
|
isUnited,
|
||||||
selectionPoolEnabled,
|
selectionPoolEnabled,
|
||||||
|
directGeneralCreationEnabled,
|
||||||
npcPossessionEnabled,
|
npcPossessionEnabled,
|
||||||
myGeneral,
|
myGeneral,
|
||||||
});
|
});
|
||||||
@@ -906,6 +909,7 @@ test('opens the mode-1 possession route with a fresh gateway game token outside
|
|||||||
await installFixture(page, {
|
await installFixture(page, {
|
||||||
myGeneral: null,
|
myGeneral: null,
|
||||||
selectionPoolEnabled: false,
|
selectionPoolEnabled: false,
|
||||||
|
directGeneralCreationEnabled: false,
|
||||||
npcPossessionEnabled: true,
|
npcPossessionEnabled: true,
|
||||||
});
|
});
|
||||||
await page.route('**/hwe/join?**', async (route) => {
|
await page.route('**/hwe/join?**', async (route) => {
|
||||||
@@ -918,6 +922,7 @@ test('opens the mode-1 possession route with a fresh gateway game token outside
|
|||||||
|
|
||||||
await page.goto('lobby');
|
await page.goto('lobby');
|
||||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||||
|
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
|
||||||
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
|
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
|
||||||
await row.getByRole('button', { name: '장수빙의' }).click();
|
await row.getByRole('button', { name: '장수빙의' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -674,6 +674,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
</button>
|
</button>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
|
v-if="
|
||||||
|
profileDetails[profile.profileName]
|
||||||
|
?.directGeneralCreationEnabled
|
||||||
|
"
|
||||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||||
:disabled="
|
:disabled="
|
||||||
entryLoading[profile.profileName] ||
|
entryLoading[profile.profileName] ||
|
||||||
|
|||||||
@@ -79,9 +79,11 @@ export class GameClock {
|
|||||||
if (this.mode === 'manual') {
|
if (this.mode === 'manual') {
|
||||||
return this.tick;
|
return this.tick;
|
||||||
}
|
}
|
||||||
// A wall-clock correction must never rewind already-observed gameplay.
|
|
||||||
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
||||||
return elapsedTicks <= 0 ? this.tick : this.addTicks(this.tick, elapsedTicks);
|
// A future realtime anchor represents the formal opening at anchor tick.
|
||||||
|
// Before that instant Ref exposes the elapsed offset as a negative tick,
|
||||||
|
// which lets PREOPEN-only actions keep their logical cooldowns moving.
|
||||||
|
return this.addTicks(this.tick, elapsedTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
now(wallNow: Date): Date {
|
now(wallNow: Date): Date {
|
||||||
|
|||||||
@@ -35,16 +35,17 @@ describe('GameClock', () => {
|
|||||||
expect(clock.tickToDate(tick).getTime() - baseTime.getTime()).toBe(jumped.getTime() - wallAnchor.getTime());
|
expect(clock.tickToDate(tick).getTime() - baseTime.getTime()).toBe(jumped.getTime() - wallAnchor.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not rewind game time after a backward wall-clock correction', () => {
|
it('projects a negative tick before a future realtime anchor', () => {
|
||||||
const clock = new GameClock({
|
const clock = new GameClock({
|
||||||
baseTime,
|
baseTime,
|
||||||
tick: GAME_TICKS_PER_TURN * 10,
|
tick: 0,
|
||||||
mode: 'realtime',
|
mode: 'realtime',
|
||||||
wallAnchor: new Date('2026-01-02T00:00:00.000Z'),
|
wallAnchor: new Date('2026-01-01T01:00:00.000Z'),
|
||||||
turnSeconds: 3_600,
|
turnSeconds: 3_600,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(clock.nowTick(new Date('2025-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN * 10);
|
expect(clock.nowTick(new Date('2026-01-01T00:30:00.000Z'))).toBe(-GAME_TICKS_PER_TURN / 2);
|
||||||
|
expect(clock.nowTick(new Date('2026-01-01T01:00:00.000Z'))).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
|
it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user