From 15f6acb4a23235ee2f80821b9cf2931dae13e6d3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:06:07 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EA=B0=9C=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=9E=A5=EC=88=98=EB=B3=84=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=EA=B0=81=EC=9D=84=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그 발생 시각과 트랜잭션 flush 시각을 분리하고, 정규 및 즉시 장수 행동 로그에는 실행 전 장수 턴 시각을 기록한다. 기존 로그 행은 변경하지 않는다. --- app/game-engine/src/turn/inMemoryWorld.ts | 13 +++++++++++-- .../src/turn/reservedTurnHandler.ts | 15 +++++++++------ ...sScoreResetPersistence.integration.test.ts | 18 +++++++++++++++++- .../test/generalTurnLifecycle.test.ts | 19 +++++++++++++++++++ packages/logic/src/logging/entries.ts | 5 +++-- packages/logic/src/logging/types.ts | 2 ++ packages/logic/test/loggingEntries.test.ts | 18 ++++++++++++++++++ 7 files changed, 79 insertions(+), 11 deletions(-) diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 75a443f3..1fa3aeba 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -814,7 +814,11 @@ export class InMemoryTurnWorld { }; } - pushLog(entry: LogEntryDraft): void { + pushLog(entry: LogEntryDraft, occurredAt?: Date): void { + if (occurredAt && !entry.occurredAt) { + this.logs.push({ ...entry, occurredAt: new Date(occurredAt.getTime()) }); + return; + } this.logs.push(entry); } @@ -1382,7 +1386,12 @@ export class InMemoryTurnWorld { this.dirtyNationIds.add(result.nation.id); } if (result.logs && result.logs.length > 0) { - this.logs.push(...result.logs); + // Ref command logs use the executing general's pre-advance turntime. + // Preserve that per-entry occurrence time instead of replacing every + // log in the transaction with the shared completion cursor at flush. + for (const log of result.logs) { + this.pushLog(log, currentGeneral.turnTime); + } } if (result.messages && result.messages.length > 0) { this.messages.push(...result.messages); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index aca2f06b..96486190 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2245,10 +2245,13 @@ 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, - }); + options.world.pushLog( + { + ...createActionLog(failureText), + generalId: general.id, + }, + general.turnTime + ); } return { ok: false, reason: failureText }; } @@ -2325,7 +2328,7 @@ export const createImmediateGeneralActionExecutor = async (options: { if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) { for (const log of resolution.logs) { - options.world.pushLog(log); + options.world.pushLog(log, general.turnTime); } return { ok: false, reason: '가까운 아국 도시가 없습니다.' }; } @@ -2409,7 +2412,7 @@ export const createImmediateGeneralActionExecutor = async (options: { options.world.removeTroop(troopId); } for (const log of [...resolution.logs, ...progressionLogs]) { - options.world.pushLog(log); + options.world.pushLog(log, general.turnTime); } options.world.updateGeneral(input.generalId, nextGeneral); return { ok: true }; diff --git a/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts index 43eed0c0..73abf04c 100644 --- a/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts +++ b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -15,6 +16,7 @@ integration('general access score reset persistence', () => { let closeDb: (() => Promise) | undefined; const cleanup = async () => { + await db.logEntry.deleteMany({ where: { generalId } }); await db.generalAccessLog.deleteMany({ where: { generalId } }); await db.general.deleteMany({ where: { id: generalId } }); await db.worldState.deleteMany({ where: { scenarioCode } }); @@ -33,8 +35,9 @@ integration('general access score reset persistence', () => { await closeDb?.(); }); - it('commits the own-turn reset marker in the same world flush', async () => { + it('commits the own-turn reset marker and per-entry log occurrence time in the same world flush', async () => { const turnTime = new Date('2026-08-15T00:10:00.000Z'); + const occurredAt = new Date('2026-08-15T00:07:43.000Z'); await db.general.create({ data: { id: generalId, @@ -95,6 +98,13 @@ integration('general access score reset persistence', () => { { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } ); world.markGeneralAccessScoreReset(generalId); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + text: '●1월:아무것도 실행하지 않았습니다.', + generalId, + occurredAt, + }); const hooks = await createDatabaseTurnHooks(databaseUrl!, world); try { @@ -113,6 +123,12 @@ integration('general access score reset persistence', () => { refreshScoreTotal: 999, }); expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]); + expect( + await db.logEntry.findFirstOrThrow({ + where: { generalId, category: LogCategory.ACTION }, + select: { createdAt: true }, + }) + ).toEqual({ createdAt: occurredAt }); } finally { await hooks.close(); } diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 15b4389c..05f6b330 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -159,6 +159,25 @@ const makeState = (meta: Record = {}): TurnWorldState => ({ }); describe('legacy general turn lifecycle', () => { + it('timestamps action logs with the executing general turn instead of the shared flush cursor', async () => { + const flushCursor = new Date('0200-01-01T00:35:00.000Z'); + const generalTurnTime = new Date('0200-01-01T00:37:43.000Z'); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ turnTime: generalTurnTime })]), + state: { ...makeState(), lastTurnTime: flushCursor }, + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: '휴식', args: {} }; + + await harness.runOneTick(); + + const actionLog = harness.world + .peekDirtyState() + .logs.find((log) => log.text.includes('아무것도 실행하지 않았습니다.')); + expect(actionLog?.occurredAt).toEqual(generalTurnTime); + }); + it('emits legacy plain logs when command gains cross experience and dedication levels', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ diff --git a/packages/logic/src/logging/entries.ts b/packages/logic/src/logging/entries.ts index 5747794b..4598da04 100644 --- a/packages/logic/src/logging/entries.ts +++ b/packages/logic/src/logging/entries.ts @@ -47,8 +47,9 @@ export const finalizeLogEntry = (entry: LogEntryDraft, context: LogContext): Log if (entry.meta !== undefined) { record.meta = entry.meta; } - if (context.at !== undefined) { - record.createdAt = context.at; + const createdAt = entry.occurredAt ?? context.at; + if (createdAt !== undefined) { + record.createdAt = createdAt; } return record; diff --git a/packages/logic/src/logging/types.ts b/packages/logic/src/logging/types.ts index 1d7a2cc3..fbd3afa1 100644 --- a/packages/logic/src/logging/types.ts +++ b/packages/logic/src/logging/types.ts @@ -31,6 +31,8 @@ export interface LogEntryDraft { /** 월 경계 전 action처럼 flush 시점과 다른 달에 귀속되는 로그의 명시적 날짜. */ year?: number; month?: number; + /** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */ + occurredAt?: Date; } export interface LogEntryRecord { diff --git a/packages/logic/test/loggingEntries.test.ts b/packages/logic/test/loggingEntries.test.ts index f7061410..7dd3e543 100644 --- a/packages/logic/test/loggingEntries.test.ts +++ b/packages/logic/test/loggingEntries.test.ts @@ -22,4 +22,22 @@ describe('finalizeLogEntry', () => { text: '●193년 12월:이전 달 사건', }); }); + + it('keeps an explicit occurrence time instead of replacing it with the flush time', () => { + const occurredAt = new Date('0200-01-01T00:37:43.000Z'); + const flushAt = new Date('0200-01-01T00:40:00.000Z'); + + expect( + finalizeLogEntry( + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + text: '상업 투자를 실행했습니다.', + generalId: 1, + occurredAt, + }, + { year: 200, month: 1, at: flushAt } + )?.createdAt + ).toEqual(occurredAt); + }); }); From c65f4f16b8b7eaa006bd8bcc24272eb89f82e590 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:06:47 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B2=A9=EB=B3=B4=20=EB=AA=85=EB=A0=B9=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref 군사 명령 순서에 첩보를 등록하고 도시 인자 예약과 월초 만료 수명을 API, 엔진, 실제 Chromium 테스트로 고정한다. --- app/game-api/test/commandTable.test.ts | 42 +++++++++++++- .../test/nationTurnCompatibility.test.ts | 27 +++++++++ .../e2e/commandArguments.spec.ts | 58 +++++++++++++++++++ resources/turn-commands/default.json | 1 + 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 15ce9ce8..3958b1da 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -135,7 +135,16 @@ describe('buildTurnCommandTable', () => { 'che_정착장려', 'che_주민선정', ], - 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], + 군사: [ + 'che_징병', + 'che_모병', + 'che_훈련', + 'che_사기진작', + 'che_출병', + 'che_집합', + 'che_소집해제', + 'che_첩보', + ], 인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], 계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], @@ -221,6 +230,37 @@ describe('buildTurnCommandTable', () => { }); }); + it('exposes the user-only spy command with a city target when the actor can pay the Ref cost', async () => { + const general = { ...buildGeneral(), gold: 300, rice: 300 } as GeneralRow; + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general, + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + const spy = table.general + .find(({ category }) => category === '군사') + ?.values.find(({ key }) => key === 'che_첩보'); + + expect(spy).toMatchObject({ + name: '첩보', + reqArg: true, + possible: true, + status: 'available', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }); + }); + it('uses min-condition constraints for availability', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index 9cdb1163..e085f38e 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -224,4 +224,31 @@ describe('레거시 사령부 턴 실행 호환성', () => { }, ]); }); + + it('첩보 도시는 실행 월부터 세 달 보이고 각 월 시작에 감소한 뒤 만료된다', async () => { + const nation = { + id: 1, + meta: { + rate: 20, + spy: { 2: 3 }, + }, + }; + const handler = createNationTurnMonthlyHandler({ + getWorld: () => + ({ + listNations: () => [nation], + updateNation: (_id: number, patch: { meta?: typeof nation.meta }) => { + if (patch.meta) nation.meta = patch.meta; + }, + }) as never, + }); + + expect(nation.meta.spy).toEqual({ 2: 3 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 2 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 1 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({}); + }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 22248b40..c1d8afa2 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -416,6 +416,22 @@ const commandTable = { }, ], }, + { + key: 'che_첩보', + name: '첩보', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, ], }, ], @@ -921,6 +937,48 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); }); +test('shows and reserves the Ref spy command for a user on desktop and mobile', async ({ page }) => { + const requests = await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + const editor = page.locator('[data-command-scope="general"]'); + await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + + let picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + const spy = picker.getByRole('button', { name: '첩보', exact: true }); + await expect(spy).toBeVisible(); + await spy.hover(); + await spy.focus(); + await expect(spy).toBeFocused(); + await spy.click(); + const form = picker.getByTestId('command-argument-form'); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '선택한 도시에 첩보를 실행합니다.' + ); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '인접 도시에서는 더 많은 정보를 얻습니다.' + ); + await form.locator('select').selectOption('2'); + await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') }); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행'); + expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}'); + + await page.setViewportSize({ width: 500, height: 900 }); + await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + await expect(picker.getByRole('button', { name: '첩보', exact: true })).toBeVisible(); + const geometry = await picker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + horizontalOverflow: element.scrollWidth - element.clientWidth, + })); + expect(geometry.width).toBeLessThanOrEqual(500); + expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0); + await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); +}); + test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page, }) => { diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index 29a6a649..6553d1f8 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -33,6 +33,7 @@ "che_징병", "che_모병", "che_소집해제", + "che_첩보", "che_군량매매", "che_물자조달", "che_증여", From b33b2f454bd368c0c44ad5033254e622209a1d36 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:16:43 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=9C=A0=EC=82=B0=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EA=B2=BD=EB=A7=A4=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A2=85=EB=A5=98=EB=B3=84=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref의 명마, 무기, 서적, 도구 순서와 종류 내부 선언 순서를 보존한다. 선택 목록은 종류별 optgroup으로 구분하고 API·Chromium 회귀를 추가한다. --- app/game-api/src/router/inherit/index.ts | 9 ++++-- app/game-api/test/inheritRouter.test.ts | 26 +++++++++++++++ app/game-frontend/src/views/InheritView.vue | 26 +++++++++++++-- .../inheritance-management.spec.ts | 32 ++++++++++++++++++- 4 files changed, 87 insertions(+), 6 deletions(-) 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/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-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/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 2546e890..7b50b62a 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -81,17 +81,33 @@ const statusFixture = { resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 }, availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }], availableUnique: [ + { + key: 'che_명마_07_백마', + name: '백마(+7)', + rawName: '백마', + info: '기동력을 올려주는 유니크 명마입니다.', + slot: 'horse', + }, { key: 'che_무기_12_칠성검', name: '칠성검(+12)', rawName: '칠성검', info: '무력을 올려주는 유니크 무기입니다.', + slot: 'weapon', }, { key: 'che_서적_07_논어', name: '논어(+7)', rawName: '논어', info: '지력을 올려주는 유니크 서적입니다.', + slot: 'book', + }, + { + key: 'che_보물_도기', + name: '도기', + rawName: '도기', + info: '전투를 돕는 유니크 도구입니다.', + slot: 'item', }, ], availableTargetGenerals: [{ id: 8, name: '조조' }], @@ -197,7 +213,21 @@ test.describe('inheritance management legacy parity', () => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(gameUrl); await expect(page.locator('#container')).toBeVisible(); - await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검'); + await expect(page.locator('#specific-unique')).toHaveValue('che_명마_07_백마'); + await expect(page.locator('#specific-unique optgroup')).toHaveCount(4); + expect( + await page.locator('#specific-unique optgroup').evaluateAll((groups) => + groups.map((group) => ({ + label: group.getAttribute('label'), + values: [...group.querySelectorAll('option')].map((option) => option.value), + })) + ) + ).toEqual([ + { label: '명마', values: ['che_명마_07_백마'] }, + { label: '무기', values: ['che_무기_12_칠성검'] }, + { label: '서적', values: ['che_서적_07_논어'] }, + { label: '도구', values: ['che_보물_도기'] }, + ]); const desktop = await page.evaluate(() => { const rect = (selector: string) => { From daa96b79aa516ae07c6a4d0ccd59138e38330070 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:08:04 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=A0=95=EC=8B=9D=20=EA=B8=B0=EC=88=98=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/context.ts | 1 + app/game-api/src/router/lobby/index.ts | 2 + app/game-api/test/lobbyRouter.test.ts | 4 ++ .../src/scenario/scenarioSeeder.ts | 17 +++++- app/game-engine/test/scenarioSeeder.test.ts | 52 ++++++++++++++++ app/game-frontend/e2e/mainNavigation.spec.ts | 60 ++++++++++++++++++- app/game-frontend/src/views/MainView.vue | 23 ++++++- app/gateway-api/test/releaseManifest.test.ts | 2 +- .../migration.sql | 18 ++++++ release-manifest.json | 2 +- 10 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 packages/infra/prisma/migrations/20260820002000_persist_official_game_index/migration.sql 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/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/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/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-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 81a3271b..2d6be228 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; @@ -529,6 +531,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, @@ -1112,7 +1116,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분'); @@ -1264,6 +1270,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) => { @@ -2239,7 +2295,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/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index ef828879..94f08bb8 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 }}