fix: 빙의 전용 화면과 가오픈 갱신을 보정한다
This commit is contained in:
@@ -1022,10 +1022,10 @@ export const generalRouter = router({
|
||||
loadCurrentGameTime(ctx.db, now),
|
||||
]);
|
||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||
// logical clock is held at the future open anchor, so comparing them with
|
||||
// JavaScript wall time inside a timestamp-without-time-zone predicate can
|
||||
// hide an otherwise active poll. Resolve the same tick-first clock contract
|
||||
// used by voting instead; hasVoted only affects the notice, not activity.
|
||||
// logical clock advances through negative ticks before the opening anchor,
|
||||
// so comparing them with JavaScript wall time inside a timestamp-without-time-zone
|
||||
// predicate can hide an otherwise active poll. Resolve the same tick-first
|
||||
// clock contract used by voting; hasVoted only affects the notice, not activity.
|
||||
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
|
||||
|
||||
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||
|
||||
@@ -302,6 +302,7 @@ export const joinRouter = router({
|
||||
}
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||
const configConst = asRecord(config.const);
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||
@@ -348,7 +349,8 @@ export const joinRouter = router({
|
||||
return {
|
||||
rules: {
|
||||
stat: resolveJoinStat(worldState),
|
||||
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
||||
allowDirectCreation: (blockGeneralCreate & 1) === 0,
|
||||
allowCustomName: (blockGeneralCreate & 2) === 0,
|
||||
},
|
||||
user: {
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
|
||||
: null,
|
||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
|
||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
|
||||
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 () => {
|
||||
const auth = buildAuth(userId, '생성사용자', 4242);
|
||||
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.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
|
||||
@@ -7,8 +7,8 @@ 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,
|
||||
clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: mode,
|
||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
@@ -17,14 +17,14 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
|
||||
}) as unknown as DatabaseClient;
|
||||
|
||||
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 preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||
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'),
|
||||
tick: 36_000_000,
|
||||
tick: -108_000_000,
|
||||
mode: 'realtime',
|
||||
running: false,
|
||||
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'));
|
||||
expect(opened).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:05.000Z'),
|
||||
tick: 36_300_000,
|
||||
now: new Date('2026-08-21T11:00:05.000Z'),
|
||||
tick: 300_000,
|
||||
running: true,
|
||||
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'));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
now: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tick: 0,
|
||||
running: false,
|
||||
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 { appRouter } from '../src/router.js';
|
||||
@@ -47,6 +47,10 @@ const buildContext = (
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
describe('lobby season state', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
||||
@@ -96,15 +100,18 @@ describe('lobby season state', () => {
|
||||
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 wallNow = new Date('2099-08-21T10:30:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(wallNow);
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
||||
tick: 36_000_000n,
|
||||
baseTime: wallAnchor,
|
||||
tick: 0n,
|
||||
mode: 'realtime',
|
||||
wallAnchor,
|
||||
}
|
||||
@@ -112,13 +119,22 @@ describe('lobby season state', () => {
|
||||
)
|
||||
.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.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||
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 () => {
|
||||
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ const buildContext = (
|
||||
tickSeconds: 3600,
|
||||
...(options.preopenClock
|
||||
? {
|
||||
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'),
|
||||
clockTick: -180_000_000n,
|
||||
clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
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 () => {
|
||||
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 });
|
||||
|
||||
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 {
|
||||
buildNpcSelectionTokenSeed,
|
||||
createTurnDaemonRuntime,
|
||||
seedScenarioToDatabase,
|
||||
type TurnDaemonRuntime,
|
||||
} from '@sammo-ts/game-engine';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
createGamePostgresConnector,
|
||||
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
|
||||
const delayedUserId = 'npc-possession-integration-delayed';
|
||||
const cleanupUserId = 'npc-possession-integration-cleanup';
|
||||
const raceUserId = 'npc-possession-integration-race';
|
||||
const preopenUserId = 'npc-possession-integration-preopen';
|
||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||
|
||||
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 cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
|
||||
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
|
||||
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
|
||||
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.logEntry.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' } });
|
||||
await db.general.createMany({
|
||||
data: Array.from({ length: 24 }, (_, index) => ({
|
||||
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await closeDb?.();
|
||||
}, 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();
|
||||
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([
|
||||
appRouter.createCaller(buildContext('npc-possession-token-a')).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).toBeLessThanOrEqual(5);
|
||||
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.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||
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);
|
||||
}, 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 () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-current'))
|
||||
|
||||
Reference in New Issue
Block a user