From f95699aeda211f9badb6a69cb25786fa9295c0a7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 23:01:59 +0000 Subject: [PATCH] =?UTF-8?q?fix(game-ui):=20=EB=B6=80=EB=8C=80=ED=8E=B8?= =?UTF-8?q?=EC=84=B1=20=EC=9E=A5=EC=88=98=20=ED=8C=A8=EB=84=90=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부대원 hover가 내 정보와 감찰부의 GeneralInformationPanel을 재사용하도록 연결한다.\n\n부대 조회 read model은 rank_data와 장수 상태를 공통 panel source로 투영하고 권한 0에서는 상세 자료를 노출하지 않는다. --- app/game-api/src/router/troop/index.ts | 295 ++++++++++++++++++++-- app/game-api/test/troopRouter.test.ts | 91 ++++++- app/game-frontend/e2e/troop.spec.ts | 206 +++++++++++++-- app/game-frontend/src/views/TroopView.vue | 20 +- 4 files changed, 560 insertions(+), 52 deletions(-) diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index 810eb1d1..e13ed06b 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -1,12 +1,43 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common'; -import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic'; +import { asRecord, type RankDataType, type TurnDaemonCommandResult } from '@sammo-ts/common'; +import { + getBillByLevel, + isValidTroopNameWidth, + normalizeTroopName, + resolveTroopSecretPermission, +} from '@sammo-ts/logic'; import { accessAuthedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; +import { + loadCrewTypeDisplayNames, + loadItemDisplayNames, + resolveDedicationLevelName, + resolveOfficerLevelName, + sanitizeInternalDisplayCode, +} from '../../services/gameDisplayNames.js'; +import { + resolveGeneralTypeCall, + resolveLeadershipBonus, + resolveRefreshScoreText, + resolveRemainingMinutes, +} from '../../services/generalBasicCardProjection.js'; +import { loadTraitNames } from '../nation/shared.js'; import { getMyGeneral } from '../shared/general.js'; +const TROOP_PANEL_RECORD_TYPES = [ + 'firenum', + 'warnum', + 'killnum', + 'deathnum', + 'killcrew', + 'deathcrew', +] as const satisfies readonly RankDataType[]; + +const readNumber = (value: unknown, fallback = 0): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + const troopNameSchema = z .string() .refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); @@ -42,7 +73,7 @@ export const troopRouter = router({ const [nation, troops, generals, cities, worldState] = await Promise.all([ ctx.db.nation.findUnique({ where: { id: me.nationId }, - select: { id: true, name: true, meta: true }, + select: { id: true, name: true, color: true, level: true, meta: true }, }), ctx.db.troop.findMany({ where: { nationId: me.nationId }, @@ -55,49 +86,140 @@ export const troopRouter = router({ name: true, cityId: true, troopId: true, + npcState: true, picture: true, imageServer: true, turnTime: true, + recentWarTime: true, leadership: true, strength: true, intel: true, + officerLevel: true, + gold: true, + rice: true, + crew: true, + train: true, + atmos: true, + injury: true, experience: true, + dedication: true, + age: true, + crewTypeId: true, + weaponCode: true, + bookCode: true, + horseCode: true, + itemCode: true, + personalCode: true, + specialCode: true, + special2Code: true, meta: true, }, }), ctx.db.city.findMany({ select: { id: true, name: true }, }), - ctx.db.worldState.findFirst({ select: { config: true } }), + ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }), ]); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); } + const permission = resolveTroopSecretPermission(me, nation.meta, false); const troopLeaderIds = troops.map((troop) => troop.troopLeaderId); - const turns = + const generalIds = generals.map((general) => general.id); + const [turns, rankRows, accessRows] = await Promise.all([ troopLeaderIds.length === 0 ? [] - : await ctx.db.generalTurn.findMany({ + : ctx.db.generalTurn.findMany({ where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } }, select: { generalId: true, turnIdx: true, actionCode: true }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], - }); + }), + permission < 1 || generalIds.length === 0 + ? [] + : ctx.db.rankData.findMany({ + where: { + generalId: { in: generalIds }, + type: { in: [...TROOP_PANEL_RECORD_TYPES] }, + }, + select: { generalId: true, type: true, value: true }, + }), + permission < 1 || generalIds.length === 0 + ? [] + : ctx.db.generalAccessLog.findMany({ + where: { generalId: { in: generalIds } }, + select: { generalId: true, refreshScore: true, refreshScoreTotal: true }, + }), + ]); const cityNames = new Map(cities.map((city) => [city.id, city.name])); const generalMap = new Map(generals.map((general) => [general.id, general])); const reservedByLeader = new Map(); + const firstActionByLeader = new Map(); + const rankValueMap = new Map>(); + const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row])); const worldConfig = asRecord(worldState?.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); + const scenarioStat = asRecord(worldConfig.stat); + const chiefStatMin = readNumber(scenarioStat.chiefMin, 70); + const statGradeLevel = readNumber(constValues.statGradeLevel, 5); + const retirementYear = readNumber(constValues.retirementYear, 70); + const maxDedicationLevel = Math.max(0, Math.trunc(readNumber(constValues.maxDedLevel, 30))); const statUpgradeLimit = typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit) ? constValues.upgradeLimit : 30; for (const turn of turns) { + if (!firstActionByLeader.has(turn.generalId)) { + firstActionByLeader.set(turn.generalId, turn.actionCode); + } const list = reservedByLeader.get(turn.generalId) ?? []; // Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다. list.push(turn.actionCode === 'che_집합' ? '집합' : '-'); reservedByLeader.set(turn.generalId, list); } + for (const row of rankRows) { + const values = rankValueMap.get(row.generalId) ?? new Map(); + values.set(row.type as (typeof TROOP_PANEL_RECORD_TYPES)[number], row.value); + rankValueMap.set(row.generalId, values); + } + + const [personalityNames, domesticNames, warNames, crewTypeNames, itemNames] = + permission < 1 + ? [new Map(), new Map(), new Map(), new Map(), new Map()] + : await Promise.all([ + loadTraitNames( + generals.map((general) => general.personalCode), + 'personality' + ), + loadTraitNames( + generals.map((general) => general.specialCode), + 'domestic' + ), + loadTraitNames( + generals.map((general) => general.special2Code), + 'war' + ), + loadCrewTypeDisplayNames(worldState, ctx.profile.id), + loadItemDisplayNames( + generals.flatMap((general) => [ + general.weaponCode, + general.bookCode, + general.horseCode, + general.itemCode, + ]) + ), + ]); + const traitName = (code: string, names: Map): string => + names.get(code)?.name ?? sanitizeInternalDisplayCode(code); + const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code); + const worldMeta = asRecord(worldState?.meta); + const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; + const lastExecuted = + rawLastExecuted instanceof Date + ? rawLastExecuted + : typeof rawLastExecuted === 'string' + ? new Date(rawLastExecuted) + : null; const mappedTroops = troops .map((troop) => { @@ -123,19 +245,52 @@ export const troopRouter = router({ .map((general) => { const meta = asRecord(general.meta); const metaNumber = (key: string): number => { - const value = meta[key]; - return typeof value === 'number' && Number.isFinite(value) ? value : 0; + return readNumber(meta[key]); }; + const stats = { + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + }; + const storedDedicationLevel = metaNumber('dedlevel'); + const dedicationLevel = + storedDedicationLevel > 0 + ? storedDedicationLevel + : Math.max( + 0, + Math.min(Math.ceil(Math.sqrt(general.dedication) / 10), maxDedicationLevel) + ); + const rankValue = ( + type: (typeof TROOP_PANEL_RECORD_TYPES)[number], + fallbackKeys: string[] = [] + ): number => { + const stored = rankValueMap.get(general.id)?.get(type); + if (stored !== undefined) return stored; + for (const key of fallbackKeys) { + const fallback = meta[key]; + if (typeof fallback === 'number' && Number.isFinite(fallback)) return fallback; + } + return 0; + }; + const officerCityId = readNumber( + meta.officerCity ?? meta.officer_city ?? meta.officerCityId + ); + const access = accessByGeneral.get(general.id); + const refreshScore = access?.refreshScore ?? 0; + const refreshScoreTotal = access?.refreshScoreTotal ?? 0; + const firstAction = firstActionByLeader.get(troop.troopLeaderId); + const troopStatus: 'inactive' | 'present' | 'away' = + firstAction !== undefined && firstAction !== 'che_집합' + ? 'inactive' + : leader?.cityId === general.cityId + ? 'present' + : 'away'; return { id: general.id, name: general.name, cityId: general.cityId, cityName: cityNames.get(general.cityId) ?? '알 수 없음', - stats: { - leadership: general.leadership, - strength: general.strength, - intelligence: general.intel, - }, + stats, experience: general.experience, progression: { experienceLevel: metaNumber('explevel'), @@ -147,6 +302,114 @@ export const troopRouter = router({ statUpgradeLimit, dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), }, + panel: + permission < 1 + ? null + : { + general: { + id: general.id, + name: general.name, + picture: general.picture, + imageServer: general.imageServer, + npcState: general.npcState, + officerLevel: general.officerLevel, + officerLevelText: resolveOfficerLevelName( + general.officerLevel, + nation.level + ), + officerCityName: + general.officerLevel >= 2 && general.officerLevel <= 4 + ? (cityNames.get(officerCityId) ?? null) + : null, + generalType: resolveGeneralTypeCall( + stats, + chiefStatMin, + statGradeLevel + ), + leadershipBonus: resolveLeadershipBonus( + general.officerLevel, + nation.level + ), + stats, + gold: general.gold, + rice: general.rice, + crew: general.crew, + train: general.train, + atmos: general.atmos, + injury: general.injury, + experience: general.experience, + dedication: general.dedication, + age: general.age, + retirementYear, + turnTime: general.turnTime.toISOString(), + defenceTrain: readNumber(meta.defence_train, 80), + killTurn: readNumber(meta.killturn ?? meta.killTurn), + remainingMinutes: resolveRemainingMinutes( + general.turnTime, + lastExecuted, + worldState?.tickSeconds ?? 0 + ), + troopId: general.troopId, + troop: { + name: troop.name, + status: troopStatus, + leaderCityName: + leader && leader.cityId !== general.cityId + ? (cityNames.get(leader.cityId) ?? null) + : null, + }, + refreshScore: { + current: refreshScore, + total: refreshScoreTotal, + text: resolveRefreshScoreText(refreshScoreTotal), + }, + crewTypeId: general.crewTypeId, + crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', + traits: { + personal: traitName(general.personalCode, personalityNames), + specialDomestic: traitName(general.specialCode, domesticNames), + specialWar: traitName(general.special2Code, warNames), + }, + progression: { + experienceLevel: metaNumber('explevel'), + dedicationLevel, + dedicationText: resolveDedicationLevelName( + dedicationLevel, + maxDedicationLevel + ), + statExperience: { + leadership: metaNumber('leadership_exp'), + strength: metaNumber('strength_exp'), + intelligence: metaNumber('intel_exp'), + }, + statUpgradeLimit, + dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), + }, + itemNames: { + horse: itemName(general.horseCode), + weapon: itemName(general.weaponCode), + book: itemName(general.bookCode), + item: itemName(general.itemCode), + }, + }, + summary: { + available: true, + experience: general.experience, + dedicationText: resolveDedicationLevelName( + dedicationLevel, + maxDedicationLevel + ), + bill: getBillByLevel(dedicationLevel), + warnum: rankValue('warnum', ['rank_warnum', 'warnum']), + wins: rankValue('killnum', ['rank_killnum', 'killnum']), + losses: rankValue('deathnum', ['rank_deathnum', 'deathnum']), + strategies: rankValue('firenum', ['rank_firenum', 'firenum']), + serviceYears: metaNumber('belong'), + killCrew: rankValue('killcrew', ['rank_killcrew', 'killcrew']), + deathCrew: rankValue('deathcrew', ['rank_deathcrew', 'deathcrew']), + recentWar: general.recentWarTime?.toISOString() ?? null, + }, + }, }; }), }; @@ -157,9 +420,9 @@ export const troopRouter = router({ }); return { - nation: { id: nation.id, name: nation.name }, + nation: { id: nation.id, name: nation.name, color: nation.color }, me: { id: me.id, troopId: me.troopId }, - permission: resolveTroopSecretPermission(me, nation.meta, false), + permission, troops: mappedTroops, }; }), diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts index 9102d215..c2c40987 100644 --- a/app/game-api/test/troopRouter.test.ts +++ b/app/game-api/test/troopRouter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import type { RankDataType } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; @@ -78,11 +79,15 @@ const buildContext = (options: { requestId?: string; transaction?: ReturnType; turns?: Array<{ generalId: number; turnIdx: number; actionCode: string }>; + rankRows?: Array<{ generalId: number; type: RankDataType; value: number }>; + accessRows?: Array<{ generalId: number; refreshScore: number; refreshScoreTotal: number }>; result: Awaited>; }) => { const me = options.me ?? buildGeneral(); const requestCommand = vi.fn(async () => options.result); const generalTurnFindMany = vi.fn(async () => options.turns ?? []); + const rankDataFindMany = vi.fn(async () => options.rankRows ?? []); + const generalAccessLogFindMany = vi.fn(async () => options.accessRows ?? []); const db = { ...(options.transaction ? { $transaction: options.transaction } : {}), general: { @@ -99,7 +104,9 @@ const buildContext = (options: { }, nation: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => - where.id === me.nationId ? { id: me.nationId, name: '테스트국', meta: options.nationMeta ?? {} } : null + where.id === me.nationId + ? { id: me.nationId, name: '테스트국', color: '#123456', level: 4, meta: options.nationMeta ?? {} } + : null ), }, troop: { @@ -111,8 +118,19 @@ const buildContext = (options: { ), }, city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) }, - worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) }, + worldState: { + findFirst: vi.fn(async () => ({ + tickSeconds: 300, + config: { + stat: { chiefMin: 70 }, + const: { upgradeLimit: 20, statGradeLevel: 5, retirementYear: 70, maxDedLevel: 30 }, + }, + meta: { lastTurnTime: '2026-01-01T00:00:00.000Z' }, + })), + }, generalTurn: { findMany: generalTurnFindMany }, + rankData: { findMany: rankDataFindMany }, + generalAccessLog: { findMany: generalAccessLogFindMany }, }; const accessTokenStore = new RedisAccessTokenStore( { @@ -136,15 +154,24 @@ const buildContext = (options: { flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; - return { context, requestCommand, generalTurnFindMany }; + return { context, requestCommand, generalTurnFindMany, rankDataFindMany, generalAccessLogFindMany }; }; describe('troop router permissions and mutations', () => { - it('returns the Ref general progress inputs for same-nation troop popups', async () => { + it('returns the shared general information panel source for authorized same-nation troop popups', async () => { const me = buildGeneral({ troopId: 1, + officerLevel: 2, + dedication: 900, + crewTypeId: 1100, + recentWarTime: new Date('2026-01-01T00:12:34.000Z'), meta: { explevel: 4, + dedlevel: 3, + officerCity: 1, + defence_train: 90, + killturn: 7, + belong: 11, leadership_exp: 7, strength_exp: 8, intel_exp: 9, @@ -155,9 +182,23 @@ describe('troop router permissions and mutations', () => { dex5: 12_650, }, }); - const fixture = buildContext({ me, result: null }); + const fixture = buildContext({ + me, + turns: [{ generalId: 1, turnIdx: 0, actionCode: 'che_집합' }], + rankRows: [ + { generalId: 1, type: 'warnum', value: 17 }, + { generalId: 1, type: 'killnum', value: 11 }, + { generalId: 1, type: 'deathnum', value: 6 }, + { generalId: 1, type: 'firenum', value: 5 }, + { generalId: 1, type: 'killcrew', value: 1234 }, + { generalId: 1, type: 'deathcrew', value: 432 }, + ], + accessRows: [{ generalId: 1, refreshScore: 13, refreshScoreTotal: 800 }], + result: null, + }); await expect(appRouter.createCaller(fixture.context).troop.getList()).resolves.toMatchObject({ + nation: { id: 1, name: '테스트국', color: '#123456' }, troops: [ { members: [ @@ -170,6 +211,35 @@ describe('troop router permissions and mutations', () => { statUpgradeLimit: 20, dex: [350, 1_375, 3_500, 7_125, 12_650], }, + panel: { + general: { + name: '부대장', + officerLevelText: '종사', + officerCityName: '북평', + generalType: '평범', + defenceTrain: 90, + killTurn: 7, + troop: { name: '백마대', status: 'present' }, + refreshScore: { current: 13, total: 800, text: '열심' }, + progression: { + dedicationLevel: 3, + dedicationText: '28품관', + }, + }, + summary: { + experience: 0, + dedicationText: '28품관', + bill: 1_000, + warnum: 17, + wins: 11, + losses: 6, + strategies: 5, + serviceYears: 11, + killCrew: 1_234, + deathCrew: 432, + recentWar: '2026-01-01T00:12:34.000Z', + }, + }, }, ], }, @@ -177,6 +247,17 @@ describe('troop router permissions and mutations', () => { }); }); + it('keeps detailed panel data out of the permission-zero troop response', async () => { + const fixture = buildContext({ me: buildGeneral({ troopId: 1, officerLevel: 1 }), result: null }); + + const response = await appRouter.createCaller(fixture.context).troop.getList(); + + expect(response.permission).toBe(0); + expect(response.troops[0]?.members[0]?.panel).toBeNull(); + expect(fixture.rankDataFindMany).not.toHaveBeenCalled(); + expect(fixture.generalAccessLogFindMany).not.toHaveBeenCalled(); + }); + it('returns only the first five Ref-redacted troop command labels without exposing action codes', async () => { const fixture = buildContext({ me: buildGeneral({ troopId: 1 }), diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts index 9d05df00..6a1ff378 100644 --- a/app/game-frontend/e2e/troop.spec.ts +++ b/app/game-frontend/e2e/troop.spec.ts @@ -22,6 +22,17 @@ const readReferenceImage = async (filename: string): Promise => { throw new Error(`Reference image not found: ${filename}`); }; +const readReferenceIcon = async (filename: string): Promise => { + for (const gameImageRoot of imageRoots) { + try { + return await readFile(resolve(gameImageRoot, '..', 'icons', filename)); + } catch { + // The main checkout and nested feature worktrees have different parents. + } + } + throw new Error(`Reference icon not found: ${filename}`); +}; + type Member = { id: number; name: string; @@ -35,22 +46,137 @@ type Member = { statUpgradeLimit: number; dex: number[]; }; + panel: { + general: { + id: number; + name: string; + picture: string | null; + imageServer: number; + npcState: number; + officerLevel: number; + officerLevelText: string; + officerCityName: string | null; + generalType: string; + leadershipBonus: number; + stats: { leadership: number; strength: number; intelligence: number }; + gold: number; + rice: number; + crew: number; + train: number; + atmos: number; + injury: number; + experience: number; + dedication: number; + age: number; + retirementYear: number; + turnTime: string; + defenceTrain: number; + killTurn: number; + remainingMinutes: number; + troopId: number; + troop: { name: string; status: 'present' }; + refreshScore: { current: number; total: number; text: string }; + crewTypeId: number; + crewTypeName: string; + traits: { personal: string; specialDomestic: string; specialWar: string }; + progression: { + experienceLevel: number; + dedicationLevel: number; + dedicationText: string; + statExperience: { leadership: number; strength: number; intelligence: number }; + statUpgradeLimit: number; + dex: number[]; + }; + itemNames: { horse: string; weapon: string; book: string; item: string }; + }; + summary: { + available: true; + experience: number; + dedicationText: string; + bill: number; + warnum: number; + wins: number; + losses: number; + strategies: number; + serviceYears: number; + killCrew: number; + deathCrew: number; + recentWar: string; + }; + }; }; -const member = (id: number, name: string, cityId: number, cityName: string): Member => ({ - id, - name, - cityId, - cityName, - stats: { leadership: 70, strength: 60, intelligence: 50 }, - experience: 450, - progression: { +const member = (id: number, name: string, cityId: number, cityName: string, troopName = '백마대'): Member => { + const stats = { leadership: 70, strength: 60, intelligence: 50 }; + const progression = { experienceLevel: 4, + dedicationLevel: 3, + dedicationText: '28품관', statExperience: { leadership: 7, strength: 8, intelligence: 9 }, statUpgradeLimit: 20, dex: [350, 1_375, 3_500, 7_125, 1_275_975], - }, -}); + }; + return { + id, + name, + cityId, + cityName, + stats, + experience: 450, + progression, + panel: { + general: { + id, + name, + picture: 'default.jpg', + imageServer: 0, + npcState: 0, + officerLevel: 1, + officerLevelText: '일반', + officerCityName: null, + generalType: '용장', + leadershipBonus: 0, + stats, + gold: 1_234, + rice: 4_321, + crew: 987, + train: 88, + atmos: 77, + injury: 0, + experience: 450, + dedication: 900, + age: 31, + retirementYear: 70, + turnTime: '2026-07-25T08:22:33.000Z', + defenceTrain: 90, + killTurn: 7, + remainingMinutes: 3, + troopId: 1, + troop: { name: troopName, status: 'present' }, + refreshScore: { current: 13, total: 800, text: '열심' }, + crewTypeId: 1100, + crewTypeName: '보병', + traits: { personal: '대담', specialDomestic: '농업', specialWar: '맹장' }, + progression, + itemNames: { horse: '명마', weapon: '명검', book: '병서', item: '도구' }, + }, + summary: { + available: true, + experience: 450, + dedicationText: '28품관', + bill: 1_000, + warnum: 17, + wins: 11, + losses: 6, + strategies: 5, + serviceYears: 11, + killCrew: 1_234, + deathCrew: 432, + recentWar: '2026-07-25T08:12:34.000Z', + }, + }, + }; +}; type TroopFixture = { id: number; name: string; @@ -105,7 +231,7 @@ const baseTroops = (): TroopFixture[] => [ picture: 'default.jpg', imageServer: 0, }, - members: [member(2, '관우', 2, '계')], + members: [member(2, '관우', 2, '계', '청룡대')], }, ]; @@ -157,14 +283,18 @@ const installApiFixture = async (page: Page, state: FixtureState) => { }); }); } - await page.route('**/image/icons/**', async (route) => { + await page.route('**/game/crewtype1100.png', async (route) => { await route.fulfill({ status: 200, contentType: 'image/png', - body: Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', - 'base64' - ), + body: await readReferenceImage('crewtype1100.png'), + }); + }); + await page.route('**/icons/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readReferenceIcon('default.jpg'), }); }); await page.route(gameTrpcRoute, async (route) => { @@ -179,7 +309,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => { } if (operation === 'troop.getList') { return response({ - nation: { id: 1, name: '테스트국' }, + nation: { id: 1, name: '테스트국', color: '#123456' }, me: state.me, permission: state.permission, troops: state.troops, @@ -206,7 +336,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => { picture: 'default.jpg', imageServer: 0, }, - members: [member(createdId, '유비', 1, '북평')], + members: [member(createdId, '유비', 1, '북평', '신규대')], }); return response({ ok: true, troopId: createdId, troopName: '신규대' }); } @@ -301,7 +431,7 @@ test('renders the legacy desktop grid with matching computed geometry and states paddingTop: '7px', paddingLeft: '9.8px', textAlign: 'left', - fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"', + fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", sans-serif', fontSize: '14px', lineHeight: '21px', }); @@ -317,9 +447,16 @@ test('renders the legacy desktop grid with matching computed geometry and states expect(hoverStyle.borderBottomWidth).toBe('3px'); await page.locator('.troopMember').nth(1).hover(); - await expect(page.getByRole('tooltip')).toContainText('조운'); - await expect(page.getByRole('tooltip').locator('[role="progressbar"]')).toHaveCount(14); - await expect(page.getByRole('tooltip').locator('[aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); + const popup = page.getByRole('tooltip'); + await expect(popup).toContainText('조운'); + await expect(popup.locator('[data-general-information-panel]')).toHaveCount(1); + await expect(popup.locator('[data-general-basic-card]')).toHaveCount(1); + await expect(popup.locator('[data-general-battle-summary]')).toHaveCount(1); + await expect(popup).toContainText('봉급1,000'); + await expect(popup).toContainText('승률64.71%'); + await expect(popup).toContainText('살상률285.65%'); + await expect(popup.locator('[role="progressbar"]')).toHaveCount(14); + await expect(popup.locator('[aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); expect( await page .getByRole('tooltip') @@ -327,10 +464,21 @@ test('renders the legacy desktop grid with matching computed geometry and states .first() .evaluate((bar) => getComputedStyle(bar).backgroundImage) ).toContain('/game/pr8.gif'); - expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo( - 500, - 0 - ); + const popupGeometry = await popup.evaluate((tooltip) => { + const rect = tooltip.getBoundingClientRect(); + const generalIcon = tooltip.querySelector('.general-icon')!.getBoundingClientRect(); + const crewIcon = tooltip.querySelector('.general-crew-type-icon')!.getBoundingClientRect(); + return { + width: rect.width, + generalIcon: { width: generalIcon.width, height: generalIcon.height }, + crewIcon: { width: crewIcon.width, height: crewIcon.height }, + backgroundImage: getComputedStyle(tooltip.querySelector('.general-icon')!).backgroundImage, + }; + }); + expect(popupGeometry.width).toBeCloseTo(500, 0); + expect(popupGeometry.generalIcon).toEqual({ width: 64, height: 64 }); + expect(popupGeometry.crewIcon).toEqual({ width: 64, height: 64 }); + expect(popupGeometry.backgroundImage).toContain('/icons/default.jpg'); await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true }); }); @@ -371,6 +519,12 @@ test('matches the legacy 500px responsive placement', async ({ page }) => { expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 }); expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 }); expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 }); + await page.locator('.troopMember').nth(1).hover(); + const mobilePopup = page.getByRole('tooltip'); + await expect(mobilePopup.locator('[data-general-information-panel]')).toHaveCount(1); + await expect(mobilePopup.locator('[role="progressbar"]')).toHaveCount(14); + expect(await mobilePopup.evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(500, 0); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(500); await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true }); }); diff --git a/app/game-frontend/src/views/TroopView.vue b/app/game-frontend/src/views/TroopView.vue index 4ddc7165..078114ea 100644 --- a/app/game-frontend/src/views/TroopView.vue +++ b/app/game-frontend/src/views/TroopView.vue @@ -6,6 +6,7 @@ import { useRouter } from 'vue-router'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { trpc } from '../utils/trpc'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; +import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; type TroopList = Awaited>; @@ -340,11 +341,20 @@ onMounted(() => {