diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 141a54c1..0cd9df52 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -44,6 +44,7 @@ export type WorldStateConfig = z.infer; export const zWorldStateMeta = z.object({ serverId: z.string().optional(), + gameIdx: z.number().int().positive().optional(), starttime: z.string().optional(), opentime: z.string().optional(), preopenAt: z.string().optional(), diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index edbc8bbc..955ceb2e 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -12,6 +12,7 @@ import { isWarTraitKey, } from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic'; +import type { ItemSlot } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { @@ -39,6 +40,8 @@ const BUFF_KEYS: InheritBuffType[] = [ 'warMagicTrialProbOppose', ]; +const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; + const BUFF_LABELS: Record = { warAvoidRatio: '회피 확률 증가', warCriticalRatio: '필살 확률 증가', @@ -79,7 +82,8 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { const loader = new ItemLoader(); const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader); const enabledKeys: Array[0]> = []; - for (const entries of Object.values(allItems)) { + for (const slot of UNIQUE_ITEM_SLOT_ORDER) { + const entries = allItems[slot] ?? {}; for (const [key, amount] of Object.entries(asRecord(entries))) { if (asNumber(amount, 0) !== 0 && isItemKey(key)) { enabledKeys.push(key); @@ -94,10 +98,11 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { name: item.name, rawName: item.rawName, info: item.info ?? '', + slot: item.slot, }; }) ); - return items.sort((left, right) => left.name.localeCompare(right.name, 'ko')); + return items; }; const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise } } }) => { diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 2ba7ab5e..0b611078 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -53,6 +53,8 @@ export const lobbyRouter = router({ return { serverId: worldState.meta.serverId?.trim() || ctx.profile?.name || 'game', + profile: ctx.profile.id, + gameIdx: worldState.meta.gameIdx ?? 1, year: worldState.currentYear, month: worldState.currentMonth, userCnt, diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index ae39c870..72d28db3 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -218,6 +218,32 @@ describe('inherit router actor and permission boundaries', () => { } ); + it('orders unique auction candidates by Ref slot order and preserves order within each slot', async () => { + const fixture = buildContext({ + configConst: { + allItems: { + item: { che_보물_도기: 1 }, + book: { che_서적_07_논어: 1 }, + weapon: { che_무기_12_칠성검: 1 }, + horse: { + che_명마_07_백마: 1, + che_명마_07_기주마: 1, + }, + }, + }, + }); + + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.availableUnique.map(({ key, slot }) => ({ key, slot }))).toEqual([ + { key: 'che_명마_07_백마', slot: 'horse' }, + { key: 'che_명마_07_기주마', slot: 'horse' }, + { key: 'che_무기_12_칠성검', slot: 'weapon' }, + { key: 'che_서적_07_논어', slot: 'book' }, + { key: 'che_보물_도기', slot: 'item' }, + ]); + }); + it('loads the first inheritance-log page without an out-of-range integer cursor', async () => { const createdAt = new Date('2026-07-26T00:00:00Z'); const fixture = buildContext({ diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index eb411eb6..144570c8 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -15,6 +15,7 @@ const buildContext = ( ): GameApiContext => ({ auth: null, + profile: { id: 'che', scenario: 'default', name: 'che:default' }, db: { worldState: { findFirst: vi.fn(async () => ({ @@ -75,6 +76,7 @@ describe('lobby season state', () => { buildContext( { serverId: 'che_260819_season', + gameIdx: 101, preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' }, @@ -103,6 +105,8 @@ describe('lobby season state', () => { expect(result).toMatchObject({ serverId: 'che_260819_season', + profile: 'che', + gameIdx: 101, preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)', diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index a1d1c96f..3a8b17b5 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -323,9 +323,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom options: install.autorunUser.options, }; } - const archivedWorldMeta = { ...worldMeta }; - delete archivedWorldMeta.hiddenSeed; - await connector.connect(); try { const result: ScenarioSeedResult = { seed, warnings, applied: true }; @@ -383,6 +380,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom await prisma.worldState.deleteMany(); } + const serverId = typeof worldMeta.serverId === 'string' ? worldMeta.serverId : undefined; + const completedGameCount = await prisma.gameHistory.count({ + where: { + status: 'COMPLETED', + ...(serverId ? { serverId: { not: serverId } } : {}), + }, + }); + // Ref fixes server_cnt once during ResetHelper initialization. Keep the + // frequently rendered game index in the same persisted read model and + // exclude abandoned or unfinished rows from the official sequence. + worldMeta.gameIdx = completedGameCount + 1; + const archivedWorldMeta = { ...worldMeta }; + delete archivedWorldMeta.hiddenSeed; + await prisma.worldState.create({ data: { scenarioCode: String(options.scenarioId), diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 96486190..8043a2a6 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -156,15 +156,15 @@ export const applyLegacyGeneralProgression = ( meta.explevel = expLevel; if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) { const josaRo = JosaUtil.pick(String(expLevel), '로'); - logs.push({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - text: + logs.push( + createGeneralActionLog( + general.id, expLevel > previousExpLevel ? `Lv ${expLevel}${josaRo} 레벨업!` : `Lv ${expLevel}${josaRo} 레벨다운!`, - }); + { format: LogFormat.PLAIN } + ) + ); } } if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) { @@ -176,15 +176,15 @@ export const applyLegacyGeneralProgression = ( const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US'); const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); const josaRoBill = JosaUtil.pick(billText, '로'); - logs.push({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - text: + logs.push( + createGeneralActionLog( + general.id, dedicationLevel > previousDedicationLevel ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, - }); + { format: LogFormat.PLAIN } + ) + ); } } @@ -715,12 +715,27 @@ const buildConstraintContext = ( mode: 'full', }); -const createActionLog = (message: string, meta?: Record): LogEntryDraft => ({ +/** + * Ref ActionLogger is constructed with a general ID, so every personal action + * log carries its owner before it reaches persistence. Keep that ownership + * explicit here: finalizeLogEntry intentionally rejects ownerless GENERAL logs. + */ +interface GeneralActionLogOptions { + format?: LogFormat; + meta?: Record; +} + +const createGeneralActionLog = ( + generalId: number, + message: string, + options: GeneralActionLogOptions = {} +): LogEntryDraft => ({ scope: LogScope.GENERAL, category: LogCategory.ACTION, - format: LogFormat.MONTH, + generalId, + format: options.format ?? LogFormat.MONTH, text: message, - meta, + ...(options.meta ? { meta: options.meta } : {}), }); const resolveDefinition = ( @@ -936,7 +951,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = failureText; - logs.push(createActionLog(failureText)); + logs.push(createGeneralActionLog(currentGeneral.id, failureText)); } const actionConstraintEnv = { @@ -972,7 +987,7 @@ export const createReservedTurnHandler = async (options: { const failureText = failedDefinition.formatConstraintFailure?.(reason, constraintCtx, failedActionArgs, view) ?? `${reason} ${failedDefinition.name} 실패.`; - logs.push(createActionLog(failureText, meta)); + logs.push(createGeneralActionLog(currentGeneral.id, failureText, meta ? { meta } : {})); } if (!usedFallback && (kind === 'general' || currentNation)) { const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth); @@ -987,7 +1002,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = `${remainTurn}턴 더 기다려야 합니다`; - logs.push(createActionLog(blockedReason)); + logs.push(createGeneralActionLog(currentGeneral.id, blockedReason)); } } @@ -1068,7 +1083,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = '예약된 명령을 실행하지 못했습니다.'; - logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); + logs.push(createGeneralActionLog(currentGeneral.id, '예약된 명령을 실행하지 못했습니다.')); actionRng = sharedActionRng ?? buildRng(actionKey); baseContext = { general: currentGeneral, @@ -1151,7 +1166,7 @@ export const createReservedTurnHandler = async (options: { const progressText = executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? `${definition.name} 수행중... (${nextTerm}/${termMax})`; - logs.push(createActionLog(progressText)); + logs.push(createGeneralActionLog(currentGeneral.id, progressText)); return { actionKey, usedFallback, completed: false, blockedReason }; } } @@ -1576,7 +1591,7 @@ export const createReservedTurnHandler = async (options: { }, rng: preprocessRng, log: { - push: (message) => logs.push(createActionLog(message)), + push: (message) => logs.push(createGeneralActionLog(currentGeneral.id, message)), }, }); preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); @@ -1602,7 +1617,12 @@ export const createReservedTurnHandler = async (options: { } currentGeneral.crew = 0; currentGeneral.rice = 0; - logs.push(createActionLog('군량이 모자라 병사들이 소집해제되었습니다!')); + logs.push( + createGeneralActionLog( + currentGeneral.id, + '군량이 모자라 병사들이 소집해제되었습니다!' + ) + ); preTurnContext.skill.activate('pre.소집해제'); } preTurnContext.skill.activate('pre.병력군량소모'); @@ -1625,7 +1645,8 @@ export const createReservedTurnHandler = async (options: { if (isBlocked) { currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1); logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, blockCode === 2 ? '현재 멀티, 또는 비매너로 인한블럭 대상자입니다.' : '현재 악성유저로 분류되어 블럭 대상자입니다.' @@ -1981,7 +2002,8 @@ export const createReservedTurnHandler = async (options: { ? currentGeneral.meta.owner_name : currentGeneral.userId; logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, `${ownerName ?? '사용자'}이 ${currentGeneral.name}의 육체에서 유체이탈합니다!` ) ); @@ -2060,7 +2082,8 @@ export const createReservedTurnHandler = async (options: { chiefGeneralId: successor.id, }; logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, `${successor.name}이 ${currentNation.name}의 유지를 이어 받았습니다` ) ); @@ -2093,7 +2116,12 @@ export const createReservedTurnHandler = async (options: { if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) { currentGeneral = resetRetiredGeneral(currentGeneral); lifecycleOutcome = 'retired'; - logs.push(createActionLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.')); + logs.push( + createGeneralActionLog( + currentGeneral.id, + '나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.' + ) + ); } currentGeneral = { @@ -2245,13 +2273,7 @@ export const createImmediateGeneralActionExecutor = async (options: { definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ?? `${reason} ${definition.name} 실패.`; if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') { - options.world.pushLog( - { - ...createActionLog(failureText), - generalId: general.id, - }, - general.turnTime - ); + options.world.pushLog(createGeneralActionLog(general.id, failureText), general.turnTime); } return { ok: false, reason: failureText }; } diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index fff9e833..b20be1fc 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { TurnSchedule } from '@sammo-ts/logic'; +import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; @@ -41,8 +41,9 @@ const mockDate = new Date('0189-01-01T00:00:00Z'); // We need a mock Prisma client that satisfies the shape required by InMemoryReservedTurnStore // It expects { generalTurn: { findMany, deleteMany, createMany }, nationTurn: { ... } } -const createMockPrisma = (initialGeneralRows: any[] = []) => { +const createMockPrisma = (initialGeneralRows: any[] = [], initialNationRows: any[] = []) => { let generalRows = [...initialGeneralRows]; + let nationRows = [...initialNationRows]; return { generalTurn: { findMany: vi.fn(async ({ where } = {}) => { @@ -67,9 +68,28 @@ const createMockPrisma = (initialGeneralRows: any[] = []) => { }), }, nationTurn: { - findMany: vi.fn(async () => []), - deleteMany: vi.fn(async () => ({ count: 0 })), - createMany: vi.fn(async () => ({ count: 0 })), + findMany: vi.fn(async ({ where } = {}) => { + if (where?.nationId && where?.officerLevel) { + return nationRows + .filter((row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel) + .sort((left, right) => left.turnIdx - right.turnIdx); + } + return nationRows; + }), + deleteMany: vi.fn(async ({ where } = {}) => { + if (where?.nationId && where?.officerLevel) { + nationRows = nationRows.filter( + (row) => row.nationId !== where.nationId || row.officerLevel !== where.officerLevel + ); + } + return { count: 0 }; + }), + createMany: vi.fn(async ({ data }) => { + if (Array.isArray(data)) { + nationRows.push(...data); + } + return { count: data.length }; + }), }, }; }; @@ -423,8 +443,17 @@ describe('Reserved Turn Execution Integration', () => { }; const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }]; + const invalidNationRows = [ + { + nationId: 1, + officerLevel: 5, + turnIdx: 0, + actionCode: 'che_천도', + arg: { destCityId: 'bad' }, + }, + ]; - const mockPrisma = createMockPrisma(invalidRows); + const mockPrisma = createMockPrisma(invalidRows, invalidNationRows); const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { maxGeneralTurns: 10, maxNationTurns: 10, @@ -456,7 +485,26 @@ describe('Reserved Turn Execution Integration', () => { const dirty = world.consumeDirtyState(); expect(world.getGeneralById(1)!.cityId).toBe(1); - expect(dirty.logs.some((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toBe(true); + expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 천도 실패.'))).toMatchObject({ + scope: 'GENERAL', + category: 'ACTION', + generalId: 1, + }); + expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toMatchObject({ + scope: 'GENERAL', + category: 'ACTION', + generalId: 1, + }); + const personalActionLogs = dirty.logs.filter( + (log) => log.scope === 'GENERAL' && log.category === 'ACTION' + ); + expect(personalActionLogs.length).toBeGreaterThan(0); + expect(personalActionLogs.every((log) => log.generalId === 1)).toBe(true); + expect( + personalActionLogs.map((log) => + finalizeLogEntry(log, { year: invalidState.currentYear, month: invalidState.currentMonth }) + ) + ).not.toContain(null); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); }); @@ -620,6 +668,7 @@ describe('Reserved Turn Execution Integration', () => { const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.')); expect(denyLog?.text).toContain('이동 실패.'); expect(denyLog?.meta?.constraintName).toBe('notSameDestCity'); + expect(denyLog?.generalId).toBe(1); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); }); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index d516c825..c6d587c5 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -128,6 +128,58 @@ describeDb('scenario database seed', () => { } }); + test('persists the next official game index without counting cancelled or unfinished games', async () => { + const marker = `scenario-seeder-game-index-${Date.now()}`; + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const completedBefore = await connector.prisma.gameHistory.count({ where: { status: 'COMPLETED' } }); + await connector.prisma.gameHistory.createMany({ + data: [ + { + serverId: `${marker}-completed`, + date: new Date('2026-08-01T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '정상 종료 fixture', + status: 'COMPLETED', + }, + { + serverId: `${marker}-abandoned`, + date: new Date('2026-08-02T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '취소 fixture', + status: 'ABANDONED', + }, + { + serverId: `${marker}-open`, + date: new Date('2026-08-03T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '미완료 fixture', + status: 'OPEN', + }, + ], + }); + + await seedScenarioToDatabase({ + scenarioId: 1010, + databaseUrl, + installOptions: { serverId: marker }, + }); + + const worldState = await connector.prisma.worldState.findFirstOrThrow(); + expect(worldState.meta).toMatchObject({ gameIdx: completedBefore + 2 }); + await expect( + connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: marker } }) + ).resolves.toMatchObject({ status: 'OPEN' }); + } finally { + await connector.prisma.gameHistory.deleteMany({ where: { serverId: { startsWith: marker } } }); + await connector.disconnect(); + } + }); + test('writes scenario data into tables', async () => { const { seed } = await seedScenarioToDatabase({ scenarioId, diff --git a/app/game-engine/test/turnFailureLogPersistence.integration.test.ts b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts new file mode 100644 index 00000000..6f3bada5 --- /dev/null +++ b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts @@ -0,0 +1,137 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient, type InputJsonValue } from '@sammo-ts/infra'; +import { LogCategory, LogFormat, LogScope, type TurnSchedule } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 2_146_200_820; +const generalId = 2_146_200_821; +const turnTime = new Date('0190-01-01T00:00:00.000Z'); +const turnRunResult = { + lastTurnTime: turnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, +} as const; + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 10 }], +}; + +const state: TurnWorldState = { + id: worldId, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: turnTime, + meta: {}, +}; + +const snapshot: TurnWorldSnapshot = { + generals: [], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'turn-failure-log-persistence', + name: '턴 실패 로그 영속화', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'che', unitSet: 'che' }, + }, + scenarioMeta: { + title: '턴 실패 로그 영속화', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, +}; + +integration('turn failure personal-record persistence', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + let databaseHooks: DatabaseTurnHooks | undefined; + + const cleanup = async () => { + await db.logEntry.deleteMany({ where: { generalId } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await databaseHooks?.close(); + await cleanup(); + await disconnect?.(); + }); + + it('stores personal and nation-turn failure reasons under the acting general', async () => { + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'turn-failure-log-persistence', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + config: snapshot.scenarioConfig as unknown as InputJsonValue, + meta: {}, + }, + }); + + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + format: LogFormat.MONTH, + text: '대상 도시가 아국이 아닙니다. 발령 실패.', + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + format: LogFormat.MONTH, + text: '같은 도시입니다. 이동 실패.', + }); + + databaseHooks = await createDatabaseTurnHooks(databaseUrl!, world); + await databaseHooks.hooks.flushChanges?.(turnRunResult); + + const records = await db.logEntry.findMany({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + }, + orderBy: { id: 'asc' }, + select: { generalId: true, text: true }, + }); + + expect(records).toEqual([ + { generalId, text: '●1월:대상 도시가 아국이 아닙니다. 발령 실패.' }, + { generalId, text: '●1월:같은 도시입니다. 이동 실패.' }, + ]); + }); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index c06c6da0..442a0535 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -898,6 +898,62 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상· await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); }); +test('개인턴·수뇌턴 실패 사유를 메인 개인 기록에 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + settingMutations: [], + accessPages: [], + recentRecords: { + global: [], + general: [ + { + id: 19002, + text: '●1월:대상 도시가 아국이 아닙니다. 여포 발령 실패.', + createdAt: '2026-01-01T03:55:00.000Z', + }, + { + id: 19001, + text: '●1월:같은 도시입니다. 으로 이동 실패.', + createdAt: '2026-01-01T03:54:00.000Z', + }, + ], + history: [], + }, + }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto(''); + + const inspectFailureLogs = async (selector: string) => { + const lines = page.locator(selector); + await expect(lines).toHaveCount(2); + await expect(lines.nth(0)).toContainText('대상 도시가 아국이 아닙니다. 여포 발령 실패. 12:55'); + await expect(lines.nth(1)).toContainText('같은 도시입니다. 업으로 이동 실패. 12:54'); + return lines.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + text: element.textContent?.trim(), + width: rect.width, + height: rect.height, + lineHeight: style.lineHeight, + }; + }) + ); + }; + + const desktop = await inspectFailureLogs('.record-zone [data-record-bucket="general"] .record-line'); + expect(desktop.every((line) => line.width > 0 && line.height === 21 && line.lineHeight === '21px')).toBe(true); + await persistParityArtifact(page, 'core-main-turn-failure-personal-records-desktop', desktop); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobile = await inspectFailureLogs('.record-zone-mobile [data-record-bucket="general"] .record-line'); + expect(mobile.every((line) => line.width > 0 && line.height === 21 && line.lineHeight === '21px')).toBe(true); + await persistParityArtifact(page, 'core-main-turn-failure-personal-records-mobile', mobile); +}); + test('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => { const seedText = '(전투시드: 0123456789abcdef)'; const logText = diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 14ad75c9..df63fc59 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -52,6 +52,8 @@ type NavigationFixture = { currentYear?: number; currentMonth?: number; serverId?: string; + profile?: string; + gameIdx?: number; scenarioTitle?: string; nationColor?: string; lastExecuted?: string | null; @@ -538,6 +540,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => { return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, serverId: state.serverId ?? 'che_fixture_season', + profile: state.profile ?? 'che', + gameIdx: state.gameIdx ?? 101, year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, @@ -1121,7 +1125,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); - await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( + 1 + ); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); @@ -1273,6 +1279,56 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + profile: 'hwe', + gameIdx: 7, + scenarioTitle: '메인 화면 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true }); + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + if (page.url() === 'about:blank') await waitForMain(page); + + const title = page.getByRole('heading', { name: '메인 화면 검증 시나리오 훼섭 7기', exact: true }); + await expect(title).toBeVisible(); + const geometry = await title.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const mainRect = element.closest('.main-page')?.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: rect.right, + mainLeft: mainRect?.left, + mainRight: mainRect?.right, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(geometry.mainLeft ?? 0); + expect(geometry.right).toBeLessThanOrEqual(geometry.mainRight ?? viewport.width); + expect(geometry.documentOverflow).toBeLessThanOrEqual(0); + expect(geometry.fontSize).toBe('25.6px'); + expect(geometry.lineHeight).toBe('38.4px'); + expect(geometry.fontFamily).toContain('Pretendard'); + await persistArtifact(page, `official-game-index-${viewport.width}`); + } +}); + test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({ page, }, testInfo) => { @@ -2248,7 +2304,7 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); - await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.getByRole('heading', { name: '모바일 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 0ed26756..9b0c8865 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -285,9 +285,7 @@ const screenshot = async (page: Page, name: string) => { await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); }; -test('personnel keeps the legacy frame while presenting modern appointment cards and interaction states', async ({ - page, -}) => { +test('personnel keeps the desktop frame while exposing row-level appointment controls', async ({ page }) => { await installFixture(page, { role: 'leader', rate: 20 }); await page.setViewportSize({ width: 1000, height: 900 }); await gotoOffice(page, 'nation/personnel'); @@ -313,8 +311,9 @@ test('personnel keeps the legacy frame while presenting modern appointment cards heading: box('.heading-table'), status: box('.chief-status'), icon: box('.general-icon'), - appointmentCard: box('.appointment-card'), - selectionTrigger: box('.selection-trigger'), + chiefEntry: box('.chief-entry-cell'), + changeButton: box('.personnel-change-button'), + cityOfficer: box('.city-officer-cell'), documentWidth: document.documentElement.scrollWidth, }; }); @@ -322,23 +321,27 @@ test('personnel keeps the legacy frame while presenting modern appointment cards expect(computed.heading.width).toBe(1000); expect(computed.heading.height).toBeCloseTo(56, 0); expect(computed.status.width).toBe(1000); - expect(computed.icon.width).toBeCloseTo(64.7, 0); + expect(computed.icon.width).toBe(64); expect(computed.icon.height).toBeCloseTo(64, 0); - expect(computed.appointmentCard.width).toBeGreaterThan(450); - expect(computed.appointmentCard.height).toBeGreaterThan(140); - expect(computed.selectionTrigger.height).toBeGreaterThanOrEqual(70); + expect(computed.chiefEntry.width).toBeGreaterThan(499); + expect(computed.chiefEntry.width).toBeLessThan(501); + expect(computed.chiefEntry.height).toBeGreaterThanOrEqual(76); + expect(computed.changeButton.height).toBeGreaterThanOrEqual(34); + expect(computed.cityOfficer.width).toBeCloseTo(280, 0); expect(computed.container.fontFamily).toContain('Pretendard'); expect(computed.container.fontSize).toBe('14px'); expect(computed.container.lineHeight).toBe('18.2px'); expect(computed.status.backgroundImage).toContain('back_walnut.jpg'); expect(computed.documentWidth).toBe(1000); - const appointButton = page.getByRole('button', { name: '주부 임명', exact: true }); - expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe('rgb(55, 104, 70)'); - await appointButton.hover(); - expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); - await appointButton.focus(); - expect(await appointButton.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); + const changeButton = page.getByRole('button', { name: '주부 변경하기', exact: true }); + expect(await changeButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe('rgb(49, 91, 61)'); + await changeButton.hover(); + expect(await changeButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); + await changeButton.focus(); + expect(await changeButton.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); + await expect(page.getByRole('button', { name: '허창 태수 변경하기', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '허창 군사 변경하기', exact: true })).toHaveCount(0); await screenshot(page, 'core-personnel-desktop-leader.png'); }); @@ -348,7 +351,7 @@ test('personnel selects an informed general and reports the JosaUtil-composed re await page.setViewportSize({ width: 1000, height: 900 }); await gotoOffice(page, 'nation/personnel'); - await page.getByRole('button', { name: '주부 장수 선택', exact: true }).click(); + await page.getByRole('button', { name: '주부 변경하기', exact: true }).click(); const picker = page.getByTestId('personnel-selection-dialog'); await expect(picker).toBeVisible(); await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible(); @@ -363,14 +366,11 @@ test('personnel selects an informed general and reports the JosaUtil-composed re await candidate.focus(); expect(await candidate.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); await screenshot(page, 'core-personnel-desktop-general-picker.png'); - await candidate.click(); - - await expect(page.getByRole('button', { name: '주부 장수 선택', exact: true })).toContainText('장료'); page.once('dialog', async (dialog) => { expect(dialog.message()).toBe('장료를 주부직에 임명하시겠습니까?'); await dialog.accept(); }); - await page.getByRole('button', { name: '주부 임명', exact: true }).click(); + await candidate.click(); await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); expect(state.appointedGeneralId).toBe(6); @@ -380,21 +380,41 @@ test('personnel selects an informed general and reports the JosaUtil-composed re await screenshot(page, 'core-personnel-appointment-toast.png'); }); -test('personnel preserves the legacy fixed 1000px document on a 500px viewport', async ({ page }) => { - await installFixture(page, { role: 'head', rate: 20 }); +test('personnel reflows row-level appointments at 500px and 390px without gradients or overflow', async ({ page }) => { + const state: FixtureState = { role: 'head', rate: 20 }; + await installFixture(page, state); await page.setViewportSize({ width: 500, height: 900 }); await gotoOffice(page, 'nation/personnel'); await expect(page.getByText('작위검증국')).toBeVisible(); expect( await page.locator('#personnel-container').evaluate((element) => element.getBoundingClientRect().width) - ).toBe(1000); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000); + ).toBe(500); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(500); + const rowGeometry = await page.locator('#personnel-container').evaluate((container) => { + const rect = (selector: string) => container.querySelector(selector)!.getBoundingClientRect(); + return { + chiefWidth: rect('.chief-entry-cell').width, + cityWidth: rect('.city-identity').width, + officerWidth: rect('.city-officer-cell').width, + gradientCount: [...container.querySelectorAll('*')].filter((element) => + getComputedStyle(element).backgroundImage.includes('gradient') + ).length, + }; + }); + expect(rowGeometry.chiefWidth).toBeGreaterThan(249); + expect(rowGeometry.chiefWidth).toBeLessThan(251); + expect(rowGeometry.cityWidth).toBeGreaterThan(79); + expect(rowGeometry.cityWidth).toBeLessThan(81); + expect(rowGeometry.officerWidth).toBeGreaterThan(139); + expect(rowGeometry.officerWidth).toBeLessThan(141); + expect(rowGeometry.gradientCount).toBe(0); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); - await page.getByRole('button', { name: '태수 도시 선택', exact: true }).click(); + await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click(); const picker = page.getByTestId('personnel-selection-dialog'); await expect(picker).toBeVisible(); + await expect(picker.getByRole('heading', { name: '허창 태수 변경' })).toBeVisible(); expect(await picker.evaluate((element) => getComputedStyle(element).transitionDuration)).toContain('0.15s'); await expect(picker).toHaveCSS('transform', 'none'); const pickerGeometry = await picker.evaluate((element) => { @@ -414,26 +434,71 @@ test('personnel preserves the legacy fixed 1000px document on a 500px viewport', expect(pickerGeometry.bottom).toBe(900); expect(pickerGeometry.width).toBeGreaterThan(480); expect(pickerGeometry.borderTopLeftRadius).toBe('16px'); - await expect(picker.getByRole('button', { name: /낙양/ })).toContainText('중원 · 중도시'); - await expect(picker.getByRole('button', { name: /허창/ })).toContainText('현재 태수하후돈'); - await picker.getByRole('button', { name: /낙양/ }).focus(); expect( - await picker.getByRole('button', { name: /낙양/ }).evaluate((button) => getComputedStyle(button).outlineStyle) + await picker.evaluate( + (element) => + [...element.querySelectorAll('*')].filter((child) => + getComputedStyle(child).backgroundImage.includes('gradient') + ).length + ) + ).toBe(0); + await expect(picker.getByRole('button', { name: /장료/ })).toContainText('허창 · 일반 장수'); + await expect(picker.getByRole('button', { name: /하후돈/ })).toContainText('현재 임명 중'); + await picker.getByRole('button', { name: /장료/ }).focus(); + expect( + await picker.getByRole('button', { name: /장료/ }).evaluate((button) => getComputedStyle(button).outlineStyle) ).not.toBe('none'); await screenshot(page, 'core-personnel-mobile-city-picker.png'); - await picker.getByRole('button', { name: /낙양/ }).click(); - await expect(page.getByRole('button', { name: '태수 도시 선택', exact: true })).toContainText('낙양'); - await screenshot(page, 'core-personnel-mobile-head.png'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('장료를 허창 태수직에 임명하시겠습니까?'); + await dialog.accept(); + }); + await picker.getByRole('button', { name: /장료/ }).click(); + await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); + expect(state.appointedGeneralId).toBe(6); + expect(state.appointedCityId).toBe(1); + expect(state.appointedOfficerLevel).toBe(4); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(250); + expect( + await page.locator('#personnel-container').evaluate((element) => element.getBoundingClientRect().width) + ).toBe(390); + const overflowContributors = await page.evaluate(() => + [...document.querySelectorAll('body *')] + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + element: `${element.tagName.toLowerCase()}#${element.id}.${element.className}`, + parent: `${element.parentElement?.tagName.toLowerCase() ?? ''}#${element.parentElement?.id ?? ''}.${element.parentElement?.className ?? ''}`, + left: rect.left, + right: rect.right, + width: rect.width, + }; + }) + .filter((entry) => entry.right > window.innerWidth + 0.5 || entry.left < -0.5) + .slice(0, 12) + ); + expect(overflowContributors).toEqual([]); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); + const narrowChiefWidth = await page + .locator('.chief-entry-cell') + .first() + .evaluate((element) => element.getBoundingClientRect().width); + expect(narrowChiefWidth).toBeGreaterThan(194); + expect(narrowChiefWidth).toBeLessThan(196); + await screenshot(page, 'core-personnel-mobile-rows.png'); }); test('personnel hides every mutation control for an ordinary member and exposes load errors', async ({ page }) => { await installFixture(page, { role: 'member', rate: 20 }); await gotoOffice(page, 'nation/personnel'); - await expect(page.getByText('도 시 관 직 임 명')).toHaveCount(0); + await expect(page.getByRole('button', { name: /변경하기/ })).toHaveCount(0); await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0); await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toHaveCount(0); await expect(page.getByRole('button', { name: '추방', exact: true })).toHaveCount(0); - await expect(page.getByText(/곽가\(10년\).*허창/)).toBeVisible(); + const auditorCell = page.locator('.city-officer-cell').filter({ hasText: '곽가' }); + await expect(auditorCell).toContainText('10년 · 허창'); const failed = await page.context().newPage(); await installFixture(failed, { role: 'member', rate: 20, failPersonnelLoad: true }); @@ -556,9 +621,7 @@ test('finance editor preserves Ref formatting controls and uploads images throug const editor = page.getByRole('textbox', { name: '국가 방침' }); const editorFrame = page.locator('#notice-form .legacy-html-editor'); await expect(editor).toBeVisible(); - expect(await editorFrame.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe( - 'rgba(0, 0, 0, 0)' - ); + expect(await editorFrame.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); expect(await editor.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); await editor.fill('서식 검증'); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index d33af421..4e0d1317 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -40,10 +40,11 @@ body { min-width: 500px; } -/* These redesigned identity/tournament screens own a true handheld layout. */ +/* These redesigned screens own a true handheld layout. */ #app:has(.responsive-settings-page), #app:has(#tournament-container), -#app:has(#tournament-betting-container) { +#app:has(#tournament-betting-container), +#app:has(#personnel-container) { min-width: 320px; } diff --git a/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue index 173ba8aa..ee9381cf 100644 --- a/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue +++ b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue @@ -245,7 +245,7 @@ onBeforeUnmount(() => { align-items: flex-start; justify-content: space-between; padding: 20px 22px 16px; - background: linear-gradient(135deg, rgb(69 57 34 / 72%), rgb(25 28 22 / 96%)); + background: #29281f; border-bottom: 1px solid #53482f; } .personnel-picker-eyebrow { @@ -370,7 +370,7 @@ onBeforeUnmount(() => { display: grid; place-items: center; color: #d8be79; - background: radial-gradient(circle at 50% 30%, #353626, #11130f 72%); + background: #25271f; font: 700 24px/1 var(--sammo-font-sans); } .personnel-picker-card-body, diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 4aeb761d..4167d6ac 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -6,6 +6,7 @@ import { trpc } from '../utils/trpc'; type InheritStatus = Awaited>; type InheritLog = Awaited>[number]; type JoinConfig = Awaited>; +type UniqueItemSlot = InheritStatus['availableUnique'][number]['slot']; type BuffKey = | 'warAvoidRatio' @@ -67,6 +68,14 @@ const pointOrder = [ 'betting', ] as const; +const uniqueItemSlotOrder: readonly UniqueItemSlot[] = ['horse', 'weapon', 'book', 'item']; +const uniqueItemSlotLabels: Record = { + horse: '명마', + weapon: '무기', + book: '서적', + item: '도구', +}; + const pointHelp: Record = { previous: '이전에 물려받은 포인트입니다.', lived_month: '살아남은 기간입니다. (1개월 단위)', @@ -196,6 +205,15 @@ const specialNameMap = computed(() => { const selectedSpecialWarInfo = computed( () => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? '' ); +const availableUniqueGroups = computed(() => + uniqueItemSlotOrder + .map((slot) => ({ + slot, + label: uniqueItemSlotLabels[slot], + items: status.value?.availableUnique.filter((item) => item.slot === slot) ?? [], + })) + .filter((group) => group.items.length > 0) +); const buffCost = (key: BuffKey, target: number): number => { const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0]; @@ -518,9 +536,11 @@ onMounted(() => {
diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 047a2995..93d68c40 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -95,6 +95,27 @@ const nationAccess = computed(() => ({ })); const nationColor = computed(() => nation.value?.color ?? '#000000'); const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); +const profileLabels: Record = { + che: '체', + kwe: '퀘', + pwe: '풰', + twe: '퉤', + nya: '냐', + pya: '퍄', + hwe: '훼', +}; +const gameProfileLabel = computed(() => { + const profile = lobbyInfo.value?.profile?.trim(); + return profile ? (profileLabels[profile] ?? profile) : ''; +}); +const gameTitle = computed(() => { + const scenarioTitle = lobbyInfo.value?.scenarioTitle || '전장 현황'; + const profileLabel = gameProfileLabel.value; + const gameIdx = lobbyInfo.value?.gameIdx; + return profileLabel && typeof gameIdx === 'number' && Number.isInteger(gameIdx) && gameIdx > 0 + ? `${scenarioTitle} ${profileLabel}섭 ${gameIdx}기` + : scenarioTitle; +}); const recordTimeSuffixPattern = /\d{2}:\d{2}(?:<\/>)?\s*$/u; const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => { if (!appendTime || recordTimeSuffixPattern.test(entry.text)) return formatLog(entry.text); @@ -199,7 +220,7 @@ watch(

- {{ lobbyInfo?.scenarioTitle || '전장 현황' }} + {{ gameTitle }}