fix: 빙의 전용 화면과 가오픈 갱신을 보정한다

This commit is contained in:
2026-09-01 02:38:02 +00:00
parent dd84e6d6b3
commit 9c82eb0cf5
16 changed files with 243 additions and 73 deletions
+4 -4
View File
@@ -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);
+3 -1
View File
@@ -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 ?? '',
+1
View File
@@ -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' } });
+9 -9
View File
@@ -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,
}); });
+21 -5
View File
@@ -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'))
+10 -2
View File
@@ -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: '생성장수',
+1
View File
@@ -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: {
+87 -1
View File
@@ -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);
+30 -4
View File
@@ -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] ||
+4 -2
View File
@@ -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 {
+5 -4
View File
@@ -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', () => {