From 9c82eb0cf5198f7d812b78a4156333af998dfd61 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 1 Sep 2026 02:38:02 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B9=99=EC=9D=98=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EA=B0=80=EC=98=A4=ED=94=88=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EC=9D=84=20=EB=B3=B4=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/general/index.ts | 8 +- app/game-api/src/router/join/index.ts | 4 +- app/game-api/src/router/lobby/index.ts | 1 + .../test/createGeneral.integration.test.ts | 1 + app/game-api/test/gameClock.test.ts | 18 ++-- app/game-api/test/lobbyRouter.test.ts | 26 +++++- .../test/mainFrontStatusRouter.test.ts | 6 +- .../test/npcPossession.integration.test.ts | 93 +++++++++++-------- app/game-frontend/e2e/inGameMenus.spec.ts | 12 ++- app/game-frontend/e2e/joinLayout.spec.ts | 1 + app/game-frontend/e2e/npcPossession.spec.ts | 88 +++++++++++++++++- app/game-frontend/src/views/JoinView.vue | 34 ++++++- .../e2e/lobby-game-auth.spec.ts | 5 + app/gateway-frontend/src/views/LobbyView.vue | 4 + packages/common/src/time/GameClock.ts | 6 +- packages/common/test/gameClock.test.ts | 9 +- 16 files changed, 243 insertions(+), 73 deletions(-) diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index baf15c48..98046be3 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -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); diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 2f218f48..bb53ae20 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -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 ?? '', diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 93052114..40723f20 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -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, diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 1056ee91..56090bda 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -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' } }); diff --git a/app/game-api/test/gameClock.test.ts b/app/game-api/test/gameClock.test.ts index 9d746e11..8258ece2 100644 --- a/app/game-api/test/gameClock.test.ts +++ b/app/game-api/test/gameClock.test.ts @@ -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, }); diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index f1932c0c..e6f69462 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -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(); diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts index 795556da..e6ebbcba 100644 --- a/app/game-api/test/mainFrontStatusRouter.test.ts +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -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({ diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index dc5cf994..9eeaf8c3 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -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(); - 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')) diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 93b1549e..d17182d8 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -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: '생성장수', diff --git a/app/game-frontend/e2e/joinLayout.spec.ts b/app/game-frontend/e2e/joinLayout.spec.ts index fc142b4a..d4031b96 100644 --- a/app/game-frontend/e2e/joinLayout.spec.ts +++ b/app/game-frontend/e2e/joinLayout.spec.ts @@ -41,6 +41,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise => return response({ rules: { stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 }, + allowDirectCreation: true, allowCustomName: true, }, user: { diff --git a/app/game-frontend/e2e/npcPossession.spec.ts b/app/game-frontend/e2e/npcPossession.spec.ts index 975e93a7..6cfeb354 100644 --- a/app/game-frontend/e2e/npcPossession.spec.ts +++ b/app/game-frontend/e2e/npcPossession.spec.ts @@ -141,9 +141,16 @@ const installFixture = async (page: Page, state: FixtureState): Promise => 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 => }); }; +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('.npc-card-holder')!; + const holderRect = holder.getBoundingClientRect(); + const cards = [...holder.querySelectorAll('.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); diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 0ecc4442..fc72022b 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -53,6 +53,8 @@ const submitting = ref(false); const joinConfig = ref(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(() => {
-

장수 생성/빙의

+

{{ possessionOnly ? '장수 빙의' : '장수 생성/빙의' }}

로그인 완료, 아직 장수가 없는 상태입니다.

내 지난 플레이 전투 시뮬레이터