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),
]);
// 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);
+3 -1
View File
@@ -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 ?? '',
+1
View File
@@ -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' } });
+9 -9
View File
@@ -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,
});
+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 { 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'))
+10 -2
View File
@@ -2007,7 +2007,11 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
accessPages: [],
createGeneralInputs: [],
joinConfig: {
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
rules: {
stat: { total: 150, min: 30, max: 70 },
allowDirectCreation: true,
allowCustomName: true,
},
user: {
id: 'user-1',
displayName: '생성장수',
@@ -2056,7 +2060,11 @@ test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청
accessPages: [],
createGeneralInputs: [],
joinConfig: {
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
rules: {
stat: { total: 150, min: 30, max: 70 },
allowDirectCreation: true,
allowCustomName: true,
},
user: {
id: 'user-1',
displayName: '생성장수',
+1
View File
@@ -41,6 +41,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
return response({
rules: {
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
allowDirectCreation: true,
allowCustomName: true,
},
user: {
+87 -1
View File
@@ -141,9 +141,16 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
return response({
rules: {
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
allowDirectCreation: false,
allowCustomName: true,
},
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
user: {
id: 'npc-user',
displayName: '빙의사용자',
canCreateGeneral: true,
icons: [],
preferredPicture: null,
},
personalities: [{ key: 'Random', name: '???', info: '' }],
warSpecials: [],
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 ({
page,
}, testInfo) => {
@@ -239,6 +323,8 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
await installFixture(page, state);
await page.setViewportSize({ width: 1024, height: 900 });
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.locator('.npc-card')).toHaveCount(5);
+30 -4
View File
@@ -53,6 +53,8 @@ const submitting = ref(false);
const joinConfig = ref<JoinConfig | null>(null);
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 contextTab = ref<'invitation' | 'map' | 'generals'>('invitation');
const inheritOpen = ref(false);
@@ -429,7 +431,11 @@ const loadConfig = async () => {
joinConfig.value = config;
const storedPossession = readPendingPossess();
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';
}
const pending = readPendingJoin();
@@ -658,13 +664,14 @@ onUnmounted(() => {
<main class="join-page">
<header class="join-header">
<div>
<h1 class="join-title">장수 생성/빙의</h1>
<h1 class="join-title">{{ possessionOnly ? '장수 빙의' : '장수 생성/빙의' }}</h1>
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/past-plays"> 지난 플레이</RouterLink>
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button
v-if="directCreationEnabled"
:class="{ active: activeTab === 'create' }"
:disabled="joinConfig?.user.canCreateGeneral === false"
@click="activeTab = 'create'"
@@ -1099,7 +1106,7 @@ onUnmounted(() => {
<div class="npc-footer">
<button
id="btn-pick-more"
class="ghost"
class="legacy-button legacy-button--secondary"
type="button"
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
@click="loadNpcCandidates(true)"
@@ -1683,6 +1690,7 @@ onUnmounted(() => {
.npc-possession-section {
width: 1000px;
min-width: 0;
align-self: center;
}
@@ -1807,7 +1815,7 @@ onUnmounted(() => {
}
.npc-general-list-wrap {
width: 970px;
width: min(100%, 970px);
margin: 0 auto 20px;
overflow-x: auto;
}
@@ -1929,5 +1937,23 @@ onUnmounted(() => {
.context-general-table {
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>
@@ -44,6 +44,7 @@ type LobbyFixtureOptions = {
imageServer: number;
} | null;
selectionPoolEnabled?: boolean;
directGeneralCreationEnabled?: boolean;
npcPossessionEnabled?: boolean;
userCnt?: number;
maxUserCnt?: number;
@@ -99,6 +100,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
imageServer: 1,
},
selectionPoolEnabled = true,
directGeneralCreationEnabled = true,
npcPossessionEnabled = false,
userCnt = 1,
maxUserCnt = 500,
@@ -275,6 +277,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
autorunUser,
isUnited,
selectionPoolEnabled,
directGeneralCreationEnabled,
npcPossessionEnabled,
myGeneral,
});
@@ -906,6 +909,7 @@ test('opens the mode-1 possession route with a fresh gateway game token outside
await installFixture(page, {
myGeneral: null,
selectionPoolEnabled: false,
directGeneralCreationEnabled: false,
npcPossessionEnabled: true,
});
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');
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 row.getByRole('button', { name: '장수빙의' }).click();
@@ -674,6 +674,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</button>
<template v-else>
<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"
:disabled="
entryLoading[profile.profileName] ||
+4 -2
View File
@@ -79,9 +79,11 @@ export class GameClock {
if (this.mode === 'manual') {
return this.tick;
}
// A wall-clock correction must never rewind already-observed gameplay.
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 {
+5 -4
View File
@@ -35,16 +35,17 @@ describe('GameClock', () => {
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({
baseTime,
tick: GAME_TICKS_PER_TURN * 10,
tick: 0,
mode: 'realtime',
wallAnchor: new Date('2026-01-02T00:00:00.000Z'),
wallAnchor: new Date('2026-01-01T01:00:00.000Z'),
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', () => {