diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 29f39cbb..b07c1550 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { LogCategory, LogScope } from '@sammo-ts/logic'; +import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic'; import { asRecord, type RankDataType } from '@sammo-ts/common'; import type { GameApiContext } from '../../context.js'; @@ -517,6 +517,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { injury: general.injury, experience: general.experience, dedication: general.dedication, + bill: getBillByLevel(dedicationLevel), age: general.age, retirementYear, turnTime: general.turnTime.toISOString(), diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts index 0601df8a..502adbca 100644 --- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { asRecord, type RankDataType } from '@sammo-ts/common'; -import { LogCategory } from '@sammo-ts/logic'; +import { getBillByLevel, LogCategory } from '@sammo-ts/logic'; import { accessAuthedProcedure } from '../../../trpc.js'; import { @@ -201,6 +201,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { }, experience: general.experience, dedication: general.dedication, + bill: getBillByLevel(dedicationLevel), injury: general.injury, gold: general.gold, rice: general.rice, diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index 54370c7a..810eb1d1 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -79,7 +79,7 @@ export const troopRouter = router({ troopLeaderIds.length === 0 ? [] : await ctx.db.generalTurn.findMany({ - where: { generalId: { in: troopLeaderIds } }, + where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } }, select: { generalId: true, turnIdx: true, actionCode: true }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }); @@ -94,7 +94,8 @@ export const troopRouter = router({ : 30; for (const turn of turns) { const list = reservedByLeader.get(turn.generalId) ?? []; - list.push(turn.actionCode); + // Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다. + list.push(turn.actionCode === 'che_집합' ? '집합' : '-'); reservedByLeader.set(turn.generalId, list); } diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 4ed4e01f..9cd5752f 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -297,6 +297,7 @@ describe('in-game my information ownership', () => { statUpgradeLimit: 20, dex: [350, 1_375, 3_500, 7_125, 12_650], }, + bill: 1_000, }, city: { population: 322_886, @@ -433,6 +434,7 @@ describe('in-game my information ownership', () => { stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 }, }, progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' }, + bill: 800, itemNames: { horse: '노새(+3)' }, itemInfo: { horse: '통솔 +3' }, }, @@ -855,6 +857,7 @@ describe('battle-center general and user permissions', () => { statUpgradeLimit: 20, dex: [0, 0, 0, 0, 0], }, + bill: 600, serviceYears: 3, battleStats: { kills: 5, diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts index 704c1398..9102d215 100644 --- a/app/game-api/test/troopRouter.test.ts +++ b/app/game-api/test/troopRouter.test.ts @@ -77,10 +77,12 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; requestId?: string; transaction?: ReturnType; + turns?: Array<{ generalId: number; turnIdx: number; actionCode: string }>; result: Awaited>; }) => { const me = options.me ?? buildGeneral(); const requestCommand = vi.fn(async () => options.result); + const generalTurnFindMany = vi.fn(async () => options.turns ?? []); const db = { ...(options.transaction ? { $transaction: options.transaction } : {}), general: { @@ -110,7 +112,7 @@ const buildContext = (options: { }, city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) }, worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) }, - generalTurn: { findMany: vi.fn(async () => []) }, + generalTurn: { findMany: generalTurnFindMany }, }; const accessTokenStore = new RedisAccessTokenStore( { @@ -134,7 +136,7 @@ const buildContext = (options: { flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; - return { context, requestCommand }; + return { context, requestCommand, generalTurnFindMany }; }; describe('troop router permissions and mutations', () => { @@ -175,6 +177,30 @@ describe('troop router permissions and mutations', () => { }); }); + it('returns only the first five Ref-redacted troop command labels without exposing action codes', async () => { + const fixture = buildContext({ + me: buildGeneral({ troopId: 1 }), + turns: [ + { generalId: 1, turnIdx: 0, actionCode: 'che_집합' }, + { generalId: 1, turnIdx: 1, actionCode: 'che_이동' }, + { generalId: 1, turnIdx: 2, actionCode: 'che_징병' }, + { generalId: 1, turnIdx: 3, actionCode: '휴식' }, + { generalId: 1, turnIdx: 4, actionCode: 'che_화계' }, + ], + result: null, + }); + + const result = await appRouter.createCaller(fixture.context).troop.getList(); + + expect(result.troops[0]?.reservedCommands).toEqual(['집합', '-', '-', '-', '-']); + expect(JSON.stringify(result.troops)).not.toContain('che_'); + expect(fixture.generalTurnFindMany).toHaveBeenCalledWith({ + where: { generalId: { in: [1] }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }); + }); + it('creates a troop only for the general owned by the authenticated user', async () => { const { context, requestCommand } = buildContext({ result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' }, diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index bcd7b16b..06edbb51 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -629,6 +629,10 @@ test('nation directory reuses only the public general-directory row on hover and await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담'); await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); + await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toHaveCSS( + 'text-decoration-line', + 'none' + ); await expect(preview).not.toContainText('user-'); await expect(preview).not.toContainText('secret'); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index cb5265b8..5e0c0e8c 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -112,6 +112,7 @@ const myGeneral = (state: FixtureState) => ({ injury: 0, experience: 100, dedication: 200, + bill: 800, age: 30, turnTime: '2026-01-01 00:10:00', recentWar: '2026-01-01 00:00:00', @@ -296,6 +297,7 @@ const battleCenter = (state: FixtureState) => ({ stats: { leadership: 70, strength: 60, intelligence: 50 }, experience: 100, dedication: 200, + bill: 800, injury: 0, gold: 1_000, rice: 2_000, @@ -334,6 +336,7 @@ const battleCenter = (state: FixtureState) => ({ stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0, + bill: 400, injury: 0, gold: 500, rice: 500, @@ -1245,6 +1248,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide await expect(page.locator('.general-table')).toContainText('병종보병'); await expect(page.locator('.general-table')).toContainText('삭턴6 턴'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); + await expect(page.locator('.battle-general-extra')).toContainText('봉급800'); await expect(page.locator('.battle-general-extra')).toContainText('전투8회'); await expect(page.locator('.battle-general-extra')).toContainText('계략12'); await expect(page.locator('.battle-general-extra')).toContainText('사관4년'); @@ -1255,7 +1259,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00'); await expect(page.locator('.legacy-general-details')).toHaveCount(0); expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([ - ['명성', '계급', ''], + ['명성', '계급', '봉급'], ['전투', '계략', '사관'], ['승률', '승리', '패배'], ['살상률', '사살', '피살'], @@ -1429,6 +1433,7 @@ test('내 정보 항목과 국가 성향은 HTML 리치 툴팁을 마우스와 const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => { const trigger = page.locator(`[data-rich-tooltip="${testId}"]`); await expect(trigger).toHaveAttribute('tabindex', '0'); + await expect(trigger).toHaveCSS('text-decoration-line', 'none'); await trigger.hover(); await expect(visibleTooltip).toHaveCount(1); await expect(visibleTooltip).toHaveAttribute('role', 'tooltip'); @@ -2039,6 +2044,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.battle-general-name')).toContainText('간의대부'); await expect(page.locator('.battle-general-name')).toContainText('건강'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); + await expect(page.locator('.battle-general-extra')).toContainText('봉급800'); await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).not.toContainText('che_'); await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', ''); @@ -2049,7 +2055,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%'); await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%'); expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([ - ['명성', '계급', ''], + ['명성', '계급', '봉급'], ['전투', '계략', '사관'], ['승률', '승리', '패배'], ['살상률', '사살', '피살'], diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index d5aa1667..08e0eea5 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -2189,6 +2189,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01); const strategicCell = nationCard.locator('.strategic'); const strategicTooltip = strategicCell.getByRole('tooltip'); + await expect(strategicCell).toHaveCSS('text-decoration-line', 'none'); await expect(strategicTooltip).toBeHidden(); await strategicCell.hover(); await expect(strategicTooltip).toBeVisible(); diff --git a/app/game-frontend/e2e/npcPossession.spec.ts b/app/game-frontend/e2e/npcPossession.spec.ts index 8a035639..975e93a7 100644 --- a/app/game-frontend/e2e/npcPossession.spec.ts +++ b/app/game-frontend/e2e/npcPossession.spec.ts @@ -266,6 +266,7 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses }); const tooltip = page.locator('.npc-tooltip').first(); const tooltipPopup = tooltip.getByRole('tooltip'); + await expect(tooltip).toHaveCSS('text-decoration-line', 'none'); await expect(tooltipPopup).toBeHidden(); await tooltip.hover(); await expect(tooltipPopup).toBeVisible(); diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts index 67fc7e71..583f52b4 100644 --- a/app/game-frontend/e2e/troop.spec.ts +++ b/app/game-frontend/e2e/troop.spec.ts @@ -80,7 +80,7 @@ const baseTroops = (): TroopFixture[] => [ name: '백마대', nationId: 1, turnTime: '2026-07-25T08:20:30.000Z', - reservedCommands: ['che_집합', 'che_이동'], + reservedCommands: ['집합', '-'], leader: { id: 1, name: '공손찬', @@ -96,7 +96,7 @@ const baseTroops = (): TroopFixture[] => [ name: '청룡대', nationId: 1, turnTime: '2026-07-25T08:30:30.000Z', - reservedCommands: ['che_징병'], + reservedCommands: ['-'], leader: { id: 2, name: '관우', @@ -224,6 +224,27 @@ const installApiFixture = async (page: Page, state: FixtureState) => { }); }; +test('shows only the Ref-safe first-five troop command labels on desktop and mobile', async ({ page }) => { + await installApiFixture(page, { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }); + + for (const viewport of [ + { width: 1000, height: 800 }, + { width: 500, height: 800 }, + ]) { + await page.setViewportSize(viewport); + await gotoTroop(page); + + const firstTroopCommands = page.locator('.troopReservedCommand').first(); + await expect(firstTroopCommands).toContainText('1: 집합'); + await expect(firstTroopCommands).toContainText('2: -'); + await expect(page.locator('#troopList')).not.toContainText('che_'); + } +}); + test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => { await installApiFixture(page, { me: { id: 1, troopId: 1 }, @@ -233,6 +254,9 @@ test('renders the legacy desktop grid with matching computed geometry and states await page.setViewportSize({ width: 1000, height: 800 }); await gotoTroop(page); await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); + await expect(page.locator('.troopReservedCommand').first()).toContainText('1: 집합'); + await expect(page.locator('.troopReservedCommand').first()).toContainText('2: -'); + await expect(page.locator('#troopList')).not.toContainText('che_'); const geometry = await page .locator('.troopItem') @@ -319,6 +343,9 @@ test('matches the legacy 500px responsive placement', async ({ page }) => { await page.setViewportSize({ width: 500, height: 800 }); await gotoTroop(page); await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); + await expect(page.locator('.troopReservedCommand').first()).toContainText('1: 집합'); + await expect(page.locator('.troopReservedCommand').first()).toContainText('2: -'); + await expect(page.locator('#troopList')).not.toContainText('che_'); const geometry = await page .locator('.troopItem') diff --git a/app/game-frontend/src/components/directory/DirectoryTooltip.vue b/app/game-frontend/src/components/directory/DirectoryTooltip.vue index 9106f8e3..9110bbc4 100644 --- a/app/game-frontend/src/components/directory/DirectoryTooltip.vue +++ b/app/game-frontend/src/components/directory/DirectoryTooltip.vue @@ -29,8 +29,6 @@ defineProps<{ } .directory-tooltip--enabled { cursor: help; - text-decoration: underline dotted rgb(150 210 255 / 85%); - text-underline-offset: 2px; } .directory-tooltip--enabled:focus-visible { border-radius: 2px; diff --git a/app/game-frontend/src/components/main/GeneralBattleSummary.vue b/app/game-frontend/src/components/main/GeneralBattleSummary.vue index 7b2ec503..b7b97216 100644 --- a/app/game-frontend/src/components/main/GeneralBattleSummary.vue +++ b/app/game-frontend/src/components/main/GeneralBattleSummary.vue @@ -7,6 +7,7 @@ export type GeneralBattleSummaryData = { available?: boolean; experience?: number | null; dedicationText?: string | null; + bill?: number | null; warnum?: number | null; wins?: number | null; losses?: number | null; @@ -62,8 +63,13 @@ const killRate = computed(() => {