diff --git a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts index e3460b43..69436d11 100644 --- a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts @@ -64,16 +64,16 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx }) const turns = generalIds.length ? await ctx.db.generalTurn.findMany({ where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, - select: { generalId: true, turnIdx: true, actionCode: true }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }) : []; const cityNames = new Map(cities.map((city) => [city.id, city.name])); const troopNames = new Map(troops.map((troop) => [troop.troopLeaderId, troop.name])); - const turnMap = new Map(); + const turnMap = new Map>(); for (const turn of turns) { const list = turnMap.get(turn.generalId) ?? []; - list[turn.turnIdx] = turn.actionCode; + list[turn.turnIdx] = { action: turn.actionCode, args: turn.arg }; turnMap.set(turn.generalId, list); } const generals = generalRows.map((general) => { diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts index 088733e1..e244985e 100644 --- a/app/game-api/test/nationGeneralSecretRouter.test.ts +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -84,7 +84,16 @@ const fixture = (generals: GeneralRow[], userId = 'u1') => { city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) }, troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) }, worldState: { findFirst: vi.fn(async () => null) }, - generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) }, + generalTurn: { + findMany: vi.fn(async () => [ + { + generalId: 1, + turnIdx: 0, + actionCode: 'che_징병', + arg: { crewType: 1, amount: 300 }, + }, + ]), + }, generalAccessLog: { findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))), }, @@ -134,6 +143,14 @@ describe('nation general and secret office permissions', () => { expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]); expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 }); + expect(result.generals[0]?.reservedCommands).toEqual([ + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + ]); + expect(db.generalTurn.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, + }) + ); expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } }); expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } })); }); diff --git a/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts index ed3dfc5f..7cdb7855 100644 --- a/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts +++ b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts @@ -76,6 +76,37 @@ const cities = [ }, ] as const; +const commandTable = { + general: [ + { + category: '내정', + values: [ + { + key: 'che_농지개간', + name: '농지 개간', + reqArg: false, + status: 'available', + possible: true, + inputFields: [], + }, + { key: 'che_훈련', name: '훈련', reqArg: false, status: 'available', possible: true, inputFields: [] }, + ], + }, + ], + nation: [], + inputOptions: { + cities: cities.map((city) => ({ value: city.id, label: city.name })), + nations: [{ value: 1, label: '위' }], + generals: [], + crewTypes: [{ value: 1, label: '보병' }], + armTypes: [], + nationTypes: [], + colors: [], + items: {}, + recruitment: null, + }, +}; + const overviewFixture = (state: FixtureState) => ({ me: { id: state.role === 'head' ? 20 : 21, officerLevel: state.role === 'head' ? 5 : 1 }, nation: { @@ -161,7 +192,10 @@ const secretGeneral = (id: number, name: string, cityId: number, overrides: Reco atmos: 90, killTurn: 7, turnTime: '2026-01-01T01:02:00.000Z', - reservedCommands: ['농지 개간', '훈련'], + reservedCommands: [ + { action: 'che_농지개간', args: {} }, + { action: 'che_훈련', args: {} }, + ], ...overrides, }); @@ -292,6 +326,7 @@ const install = async (page: Page, state: FixtureState): Promise => { : response(secretFixture()); } if (operation === 'nation.getPersonnelInfo') return response(personnelFixture(state)); + if (operation === 'turns.getCommandTable') return response(commandTable); if (operation === 'nation.appoint') { const input = requestInput(route, index); state.appointmentInputs.push({ diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 8259b737..47387c3f 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -4,6 +4,35 @@ import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const response = (data: unknown) => ({ result: { data } }); const operations = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const commandTable = { + general: [ + { + category: '전체', + values: [ + { key: '휴식', name: '휴식', reqArg: false, status: 'available', possible: true, inputFields: [] }, + { key: 'che_이동', name: '이동', reqArg: true, status: 'available', possible: true, inputFields: [] }, + { key: 'che_징병', name: '징병', reqArg: true, status: 'available', possible: true, inputFields: [] }, + { key: 'che_증여', name: '증여', reqArg: true, status: 'available', possible: true, inputFields: [] }, + { key: 'che_화계', name: '화계', reqArg: true, status: 'available', possible: true, inputFields: [] }, + ], + }, + ], + nation: [], + inputOptions: { + cities: [{ value: 1, label: '업 (위)' }], + nations: [{ value: 1, label: '위' }], + generals: [ + { value: 1, label: '테스트장수 (위 · 업)' }, + { value: 2, label: '다른장수 (위 · 업)' }, + ], + crewTypes: [{ value: 1, label: '보병' }], + armTypes: [], + nationTypes: [], + colors: [], + items: {}, + recruitment: null, + }, +}; const general = { id: 1, name: '테스트장수', @@ -112,11 +141,18 @@ const install = async (page: Page, secretAllowed = true) => { atmos: 90, killTurn: 7, turnTime: '2026-01-01T01:02:00.000Z', - reservedCommands: ['징병', '훈련'], + reservedCommands: [ + { action: 'che_이동', args: { destCityId: 1 } }, + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + { action: 'che_증여', args: { destGeneralId: 2, isGold: false, amount: 200 } }, + { action: 'che_화계', args: { destCityId: 1 } }, + { action: '휴식', args: {} }, + ], }, ], }); } + if (operation === 'turns.getCommandTable') return response(commandTable); return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); @@ -311,7 +347,7 @@ test('nation generals filter buttons open Ref operator menus and apply compound await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); }); -test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { +test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }, testInfo) => { await install(page); await page.setViewportSize({ width: 500, height: 900 }); for (const path of ['nation/generals', 'nation/secret']) { @@ -319,18 +355,60 @@ test('both pages preserve the legacy 1000px overflow contract at 500px', async ( await expect( page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list') ).toBeVisible(); - expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000); + const fixedWidthElement = path.endsWith('secret') + ? page.locator('.secret-page .title').first() + : page.locator('.general-page'); + expect(await fixedWidthElement.evaluate((el) => el.getBoundingClientRect().width)).toBe(1000); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + if (path.endsWith('secret')) { + await page.screenshot({ path: testInfo.outputPath('secret-command-brief-mobile-500.png'), fullPage: true }); + } } }); -test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => { +test('secret office renders five Ref-style command briefs and the forbidden error flow', async ({ page }, testInfo) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('nation/secret'); await expect(page.locator('.summary')).toContainText('전체 금'); - await expect(page.locator('#secret-general-list')).toContainText('1 : 징병'); - expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000); + const commandRows = page.locator('#secret-general-list .turns div'); + await expect(commandRows).toHaveCount(5); + await expect(commandRows).toHaveText([ + '1 : 【업】으로 이동', + '2 : 【보병】 300명 징병', + '3 : 【다른장수】에게 쌀 200을 증여', + '4 : 【업】에 화계실행', + '5 : 휴식', + ]); + await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여'); + const geometry = await page.locator('#secret-general-list .turns').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + height: rect.height, + fontSize: style.fontSize, + textAlign: style.textAlign, + horizontalOverflow: element.scrollWidth - element.clientWidth, + }; + }); + expect(geometry.width).toBeGreaterThanOrEqual(190); + expect(geometry.width).toBeLessThanOrEqual(230); + expect(geometry.height).toBeGreaterThanOrEqual(60); + expect(geometry).toMatchObject({ fontSize: '11px', textAlign: 'left', horizontalOverflow: 0 }); + const titleBox = await page.locator('.secret-page .title').first().boundingBox(); + const listBox = await page.locator('#secret-general-list').boundingBox(); + expect(titleBox?.width).toBe(1000); + expect(listBox?.width).toBe(974); + await testInfo.attach('secret-command-brief-geometry', { + body: JSON.stringify( + { viewport: { width: 1200, height: 900 }, titleBox, listBox, commandCell: geometry }, + null, + 2 + ), + contentType: 'application/json', + }); + await page.screenshot({ path: testInfo.outputPath('secret-command-brief-desktop-1200.png'), fullPage: true }); await page.unroute(gameTrpcRoute); await install(page, false); diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index 5556d93c..2bf3987c 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -2,6 +2,8 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { computed, onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; +import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief'; +import type { CommandTable } from '../components/command/types'; import { useGameFeedback } from '../composables/useGameFeedback'; import { getNpcColor } from '../utils/npcColor'; import { legacyNationTextColor } from '../utils/legacyNationColor'; @@ -13,10 +15,12 @@ type SecretResult = Awaited>; type City = Result['cities'][number]; type SecretGeneral = SecretResult['generals'][number]; +type ReservedCommand = SecretGeneral['reservedCommands'][number]; type OfficerLevel = 2 | 3 | 4; type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; const data = ref(null); const secretData = ref(null); +const commandTable = ref(null); const personnelData = ref(null); const error = ref(''); const integrationError = ref(''); @@ -148,6 +152,8 @@ const commandNeedsAttention = (city: City, command: string): boolean => { if (normalized.includes('성벽보수')) return city.wall - city.wallMax > -700; return false; }; +const commandBrief = (command: ReservedCommand): string => + formatReservedCommandBrief('general', command.action, command.args, commandTable.value); const loadSecretIntegration = async (): Promise => { if (secretLoading.value) { @@ -161,7 +167,10 @@ const loadSecretIntegration = async (): Promise => { secretLoading.value = true; integrationError.value = ''; try { - secretData.value = await trpc.nation.getSecretGeneralList.query(); + const secret = await trpc.nation.getSecretGeneralList.query(); + const table = await trpc.turns.getCommandTable.query({ generalId: secret.viewer.generalId }); + secretData.value = secret; + commandTable.value = table; } catch (cause) { integrationError.value = cause instanceof Error ? cause.message : '암행부 연동에 실패했습니다.'; showErrorToast(integrationError.value); @@ -496,13 +505,17 @@ onMounted(async () => {
{{ commandIndex + 1 }} : {{ command }}{{ commandBrief(command) }}
diff --git a/app/game-frontend/src/views/NationSecretView.vue b/app/game-frontend/src/views/NationSecretView.vue index 44ec6ccb..72f8ac2b 100644 --- a/app/game-frontend/src/views/NationSecretView.vue +++ b/app/game-frontend/src/views/NationSecretView.vue @@ -1,10 +1,14 @@ @@ -142,8 +151,12 @@ onMounted(load);