From 322a8533fbe3a6b729bd2824638fba031b337184 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 11 Aug 2026 00:40:38 +0000 Subject: [PATCH] feat(game-ui): port Ref progress bars --- app/game-api/src/router/general/index.ts | 20 +- .../nation/endpoints/getBattleCenter.ts | 18 ++ app/game-api/src/router/troop/index.ts | 51 ++++- .../test/inGameMenuPermissions.test.ts | 94 +++++++- app/game-api/test/troopRouter.test.ts | 46 +++- app/game-frontend/e2e/inGameMenus.spec.ts | 52 ++++- app/game-frontend/e2e/mainNavigation.spec.ts | 95 ++++++++ app/game-frontend/e2e/troop.spec.ts | 53 ++++- .../src/components/main/CityBasicCard.vue | 115 ++++++++-- .../src/components/main/GeneralBasicCard.vue | 211 +++++++++++++++--- .../components/ui/LegacyGeneralProgress.vue | 161 +++++++++++++ .../src/components/ui/LegacyProgressBar.vue | 71 ++++++ app/game-frontend/src/utils/legacyProgress.ts | 78 +++++++ .../src/views/BattleCenterView.vue | 5 +- app/game-frontend/src/views/MyPageView.vue | 15 +- app/game-frontend/src/views/TroopView.vue | 16 +- app/game-frontend/test/legacyProgress.test.ts | 35 +++ .../reference-progress-bars.mjs | 114 ++++++++++ 18 files changed, 1161 insertions(+), 89 deletions(-) create mode 100644 app/game-frontend/src/components/ui/LegacyGeneralProgress.vue create mode 100644 app/game-frontend/src/components/ui/LegacyProgressBar.vue create mode 100644 app/game-frontend/src/utils/legacyProgress.ts create mode 100644 app/game-frontend/test/legacyProgress.test.ts create mode 100644 tools/frontend-legacy-parity/reference-progress-bars.mjs diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 3f5c007b..8bbbaff4 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -249,7 +249,7 @@ export const generalRouter = router({ return null; } - const [city, nation] = await Promise.all([ + const [city, nation, worldState] = await Promise.all([ general.cityId > 0 ? ctx.db.city.findUnique({ where: { id: general.cityId }, @@ -259,11 +259,20 @@ export const generalRouter = router({ level: true, nationId: true, population: true, + populationMax: true, agriculture: true, + agricultureMax: true, commerce: true, + commerceMax: true, security: true, + securityMax: true, + trust: true, + trade: true, defence: true, + defenceMax: true, wall: true, + wallMax: true, + region: true, supplyState: true, frontState: true, }, @@ -285,9 +294,12 @@ export const generalRouter = router({ }, }) : null, + ctx.db.worldState.findFirst({ select: { config: true } }), ]); const metaRecord = asRecord(general.meta); + const worldConfig = asRecord(worldState?.config); + const constValues = asRecord(worldConfig.const ?? worldConfig.consts); const settings = resolveUserSettings(metaRecord); const penalties = resolvePenalty(general.penalty); @@ -326,6 +338,12 @@ export const generalRouter = router({ progression: { experienceLevel: readNumber(metaRecord.explevel, 0), dedicationLevel: readNumber(metaRecord.dedlevel, 0), + statExperience: { + leadership: readNumber(metaRecord.leadership_exp, 0), + strength: readNumber(metaRecord.strength_exp, 0), + intelligence: readNumber(metaRecord.intel_exp, 0), + }, + statUpgradeLimit: readNumber(constValues.upgradeLimit, 30), dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)), }, items: { diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts index eba207ab..c5e3e1f2 100644 --- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { asRecord } from '@sammo-ts/common'; import { LogCategory } from '@sammo-ts/logic'; import { accessAuthedProcedure } from '../../../trpc.js'; @@ -91,6 +92,13 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { } } + const worldConfig = asRecord(worldState.config); + const constValues = asRecord(worldConfig.const ?? worldConfig.consts); + const statUpgradeLimit = + typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit) + ? constValues.upgradeLimit + : 30; + const generals = generalRows.map((general) => { const meta = general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta) @@ -137,6 +145,16 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { specialDomestic: general.specialCode, specialWar: general.special2Code, }, + progression: { + experienceLevel: metaNumber('explevel'), + 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}`)), + }, battleStats: { kills: metaNumber('rank_killnum') || metaNumber('killnum'), deaths: metaNumber('deathnum'), diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index 75c43cbf..f1bf9bb5 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import type { TurnDaemonCommandResult } from '@sammo-ts/common'; +import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common'; import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic'; import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; @@ -39,7 +39,7 @@ export const troopRouter = router({ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); } - const [nation, troops, generals, cities] = await Promise.all([ + const [nation, troops, generals, cities, worldState] = await Promise.all([ ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { id: true, name: true, meta: true }, @@ -58,11 +58,17 @@ export const troopRouter = router({ picture: true, imageServer: true, turnTime: true, + leadership: true, + strength: true, + intel: true, + experience: true, + meta: true, }, }), ctx.db.city.findMany({ select: { id: true, name: true }, }), + ctx.db.worldState.findFirst({ select: { config: true } }), ]); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); @@ -80,6 +86,12 @@ export const troopRouter = router({ 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 worldConfig = asRecord(worldState?.config); + const constValues = asRecord(worldConfig.const ?? worldConfig.consts); + const statUpgradeLimit = + typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit) + ? constValues.upgradeLimit + : 30; for (const turn of turns) { const list = reservedByLeader.get(turn.generalId) ?? []; list.push(turn.actionCode); @@ -107,12 +119,35 @@ export const troopRouter = router({ : null, members: generals .filter((general) => general.troopId === troop.troopLeaderId) - .map((general) => ({ - id: general.id, - name: general.name, - cityId: general.cityId, - cityName: cityNames.get(general.cityId) ?? '알 수 없음', - })), + .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 { + id: general.id, + name: general.name, + cityId: general.cityId, + cityName: cityNames.get(general.cityId) ?? '알 수 없음', + stats: { + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + }, + experience: general.experience, + progression: { + experienceLevel: metaNumber('explevel'), + 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}`)), + }, + }; + }), }; }) .sort((left, right) => { diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 04796253..a8f79a25 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -75,6 +75,7 @@ const auth: GameSessionTokenPayload = { const createContext = (options: { me?: GeneralRow | null; + city?: Record | null; targets?: GeneralRow[]; nationMeta?: Record; requestCommand?: ReturnType; @@ -95,7 +96,7 @@ const createContext = (options: { findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))), update: vi.fn(), }, - city: { findUnique: vi.fn(async () => null) }, + city: { findUnique: vi.fn(async () => options.city ?? null) }, nation: { findUnique: vi.fn(async () => ({ id: 1, @@ -115,6 +116,7 @@ const createContext = (options: { currentYear: 185, currentMonth: 1, tickSeconds: 600, + config: { const: { upgradeLimit: 20 } }, })), }, logEntry: { @@ -162,6 +164,90 @@ const createContext = (options: { }; describe('in-game my information ownership', () => { + it('returns every ref progress-bar input from the owned general and current city read model', async () => { + const fixture = createContext({ + me: buildGeneral({ + meta: { + explevel: 4, + dedlevel: 3, + leadership_exp: 7, + strength_exp: 8, + intel_exp: 9, + dex1: 350, + dex2: 1_375, + dex3: 3_500, + dex4: 7_125, + dex5: 12_650, + }, + }), + city: { + id: 1, + name: '계', + level: 5, + nationId: 1, + population: 322_886, + populationMax: 388_500, + agriculture: 6_911, + agricultureMax: 7_500, + commerce: 7_451, + commerceMax: 8_000, + security: 5_792, + securityMax: 6_000, + trust: 72, + trade: 101, + defence: 7_529, + defenceMax: 7_800, + wall: 7_819, + wallMax: 8_100, + region: 1, + supplyState: 1, + frontState: 0, + }, + }); + + await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ + general: { + progression: { + experienceLevel: 4, + dedicationLevel: 3, + statExperience: { leadership: 7, strength: 8, intelligence: 9 }, + statUpgradeLimit: 20, + dex: [350, 1_375, 3_500, 7_125, 12_650], + }, + }, + city: { + population: 322_886, + populationMax: 388_500, + agriculture: 6_911, + agricultureMax: 7_500, + commerce: 7_451, + commerceMax: 8_000, + security: 5_792, + securityMax: 6_000, + trust: 72, + trade: 101, + defence: 7_529, + defenceMax: 7_800, + wall: 7_819, + wallMax: 8_100, + }, + }); + expect(fixture.db.city.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + populationMax: true, + agricultureMax: true, + commerceMax: true, + securityMax: true, + trust: true, + trade: true, + defenceMax: true, + wallMax: true, + }), + }) + ); + }); + it('reads legacy top-level settings and dispatches only the session-owned general', async () => { const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); const fixture = createContext({ requestCommand }); @@ -422,6 +508,12 @@ describe('battle-center general and user permissions', () => { id: 7, picture: 'default.jpg', imageServer: 0, + progression: { + experienceLevel: 0, + statExperience: { leadership: 0, strength: 0, intelligence: 0 }, + statUpgradeLimit: 20, + dex: [0, 0, 0, 0, 0], + }, battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [0, 0, 0, 0, 0] }, }, ], diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts index 1bf0a4ac..46f54619 100644 --- a/app/game-api/test/troopRouter.test.ts +++ b/app/game-api/test/troopRouter.test.ts @@ -90,17 +90,24 @@ const buildContext = (options: { } return options.target?.id === where.id ? options.target : null; }), + findMany: vi.fn(async () => [me, ...(options.target ? [options.target] : [])]), }, nation: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => - where.id === me.nationId ? { id: me.nationId, meta: options.nationMeta ?? {} } : null + where.id === me.nationId ? { id: me.nationId, name: '테스트국', meta: options.nationMeta ?? {} } : null ), }, troop: { findUnique: vi.fn(async ({ where }: { where: { troopLeaderId: number } }) => options.troop?.troopLeaderId === where.troopLeaderId ? options.troop : null ), + findMany: vi.fn(async () => + options.troop ? [options.troop] : [{ troopLeaderId: me.id, nationId: me.nationId, name: '백마대' }] + ), }, + city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) }, + worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) }, + generalTurn: { findMany: vi.fn(async () => []) }, }; const accessTokenStore = new RedisAccessTokenStore( { @@ -127,6 +134,43 @@ const buildContext = (options: { }; describe('troop router permissions and mutations', () => { + it('returns the Ref general progress inputs for same-nation troop popups', async () => { + const me = buildGeneral({ + troopId: 1, + meta: { + explevel: 4, + leadership_exp: 7, + strength_exp: 8, + intel_exp: 9, + dex1: 350, + dex2: 1_375, + dex3: 3_500, + dex4: 7_125, + dex5: 12_650, + }, + }); + const fixture = buildContext({ me, result: null }); + + await expect(appRouter.createCaller(fixture.context).troop.getList()).resolves.toMatchObject({ + troops: [ + { + members: [ + { + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + progression: { + experienceLevel: 4, + statExperience: { leadership: 7, strength: 8, intelligence: 9 }, + statUpgradeLimit: 20, + dex: [350, 1_375, 3_500, 7_125, 12_650], + }, + }, + ], + }, + ], + }); + }); + 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/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index d77898ff..9992ea83 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -72,6 +72,17 @@ const myGeneral = (state: FixtureState) => ({ injury: 0, experience: 100, dedication: 200, + age: 30, + turnTime: '2026-01-01 00:10:00', + crewTypeId: 1, + traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, + progression: { + experienceLevel: 1, + dedicationLevel: 2, + statExperience: { leadership: 7, strength: 8, intelligence: 9 }, + statUpgradeLimit: 20, + dex: [350, 1_375, 3_500, 7_125, 1_275_975], + }, items: { horse: 'che_명마', weapon: null, book: null, item: null }, }, city: { id: 1, name: '업', level: 8, nationId: 1 }, @@ -118,6 +129,17 @@ const battleCenter = (state: FixtureState) => ({ crew: 300, train: 80, atmos: 90, + age: 30, + crewTypeId: 1, + equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, + traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, + progression: { + experienceLevel: 1, + statExperience: { leadership: 7, strength: 8, intelligence: 9 }, + statUpgradeLimit: 20, + dex: [350, 1_375, 3_500, 7_125, 1_275_975], + }, + battleStats: { kills: 1, deaths: 2, fire: 0, killCrew: 300, deathCrew: 100, dex: [] }, }, { id: 8, @@ -137,6 +159,17 @@ const battleCenter = (state: FixtureState) => ({ crew: 100, train: 60, atmos: 60, + age: 20, + crewTypeId: 1, + equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, + traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, + progression: { + experienceLevel: 0, + statExperience: { leadership: 0, strength: 0, intelligence: 0 }, + statUpgradeLimit: 20, + dex: [0, 0, 0, 0, 0], + }, + battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] }, }, ], }); @@ -153,10 +186,15 @@ const install = async (page: Page, state: FixtureState) => { ); await page.route('**/image/game/**', async (route) => { const filename = basename(new URL(route.request().url()).pathname); - if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) { + if ( + legacyImageRoot && + ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg', 'pr5.gif', 'pb5.gif', 'pr8.gif', 'pb8.gif'].includes( + filename + ) + ) { await route.fulfill({ status: 200, - contentType: 'image/jpeg', + contentType: filename.endsWith('.gif') ? 'image/gif' : 'image/jpeg', body: await readFile(resolve(legacyImageRoot, filename)), }); return; @@ -455,6 +493,8 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac await page.goto('my-page'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14); + await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0); expect(state.accessPages).not.toContain('my-page'); const noDefenceOption = page.locator('option[value="999"]'); @@ -911,6 +951,14 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await page.getByRole('button', { name: '다음 ▶' }).click(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); + await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); + await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); + expect( + await page + .locator('.battle-general-card [role="progressbar"]') + .first() + .evaluate((bar) => getComputedStyle(bar).backgroundImage) + ).toContain('/game/pr8.gif'); const geometry = await page.locator('.battle-page').evaluate((element) => { const selector = element.querySelector('.selector-row')!; const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 44880b54..5e7b4a56 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -54,6 +54,13 @@ const generalContext = (state: NavigationFixture) => ({ injury: 0, experience: 100, dedication: 200, + progression: { + experienceLevel: 1, + dedicationLevel: 2, + statExperience: { leadership: 5, strength: 10, intelligence: 15 }, + statUpgradeLimit: 20, + dex: [350, 100_000, 500_000, 1_000_000, 1_275_975], + }, items: { horse: null, weapon: null, book: null, item: null }, }, city: { @@ -385,6 +392,94 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('main city and general cards render every ref bar plus dual dexterity progress at ref heights', async ({ + page, +}) => { + const state: NavigationFixture = { + officerLevel: 1, + permission: 0, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + const cityBars = page.locator('[data-main-target="city"] [role="progressbar"]'); + const statBars = page.locator('[data-stat-progress] [role="progressbar"]'); + const experienceBar = page.locator('[data-experience-progress] [role="progressbar"]'); + const dexRows = page.locator('[data-dex-progress]'); + await expect(cityBars).toHaveCount(8); + await expect(statBars).toHaveCount(3); + await expect(experienceBar).toHaveCount(1); + await expect(dexRows).toHaveCount(5); + await expect(dexRows.locator('[role="progressbar"]')).toHaveCount(10); + + expect(await cityBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(9); + expect(await statBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(12); + expect(await experienceBar.evaluate((element) => element.getBoundingClientRect().height)).toBe(12); + const firstDexBars = dexRows.first().locator('[role="progressbar"]'); + expect(await firstDexBars.nth(0).evaluate((element) => element.getBoundingClientRect().height)).toBe(12); + expect(await firstDexBars.nth(1).evaluate((element) => element.getBoundingClientRect().height)).toBe(9); + + await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(0)).toHaveAttribute('aria-valuemax', '100'); + await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(0)).toHaveAttribute( + 'aria-label', + /1,000,000 \/ 1,275,975 \(EX\+\)/ + ); + await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(1)).toHaveAttribute('aria-label', /까지 .* 남음/); + await expect(dexRows.nth(4).locator('[role="progressbar"]').nth(1)).toHaveAttribute('aria-label', /EX\+ 달성/); + + const texture = await cityBars.first().evaluate((element) => getComputedStyle(element).backgroundImage); + const fillTexture = await cityBars + .first() + .locator('.legacy-progress__fill') + .evaluate((element) => getComputedStyle(element).backgroundImage); + expect(texture).toContain('/game/pr5.gif'); + expect(fillTexture).toContain('/game/pb5.gif'); + + const captureProgress = async (name: string) => { + if (!artifactRoot) return; + await mkdir(artifactRoot, { recursive: true }); + const measurement = await page.locator('.legacy-progress').evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const fill = element.querySelector('.legacy-progress__fill'); + return { + label: element.getAttribute('aria-label'), + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + borderTop: style.borderTop, + borderBottom: style.borderBottom, + backgroundImage: style.backgroundImage, + fillWidth: fill?.getBoundingClientRect().width ?? 0, + fillBackgroundImage: fill ? getComputedStyle(fill).backgroundImage : '', + }; + }) + ); + await Promise.all([ + page.screenshot({ path: resolve(artifactRoot, `progress-bars-${name}.png`), fullPage: true }), + writeFile(resolve(artifactRoot, `progress-bars-${name}.json`), `${JSON.stringify(measurement, null, 2)}\n`), + ]); + }; + await captureProgress('desktop-1200'); + + await page.setViewportSize({ width: 500, height: 900 }); + await expect(page.locator('.layout-mobile')).toBeVisible(); + await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8); + await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(14); + expect( + await page + .locator('[data-main-target="city"] [role="progressbar"]') + .first() + .evaluate((element) => element.getBoundingClientRect().height) + ).toBe(9); + await captureProgress('mobile-500'); +}); + test('the 939/940 boundary switches to the Ref-style 502px single document', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts index dfbc4807..67fc7e71 100644 --- a/app/game-frontend/e2e/troop.spec.ts +++ b/app/game-frontend/e2e/troop.spec.ts @@ -22,7 +22,35 @@ const readReferenceImage = async (filename: string): Promise => { throw new Error(`Reference image not found: ${filename}`); }; -type Member = { id: number; name: string; cityId: number; cityName: string }; +type Member = { + id: number; + name: string; + cityId: number; + cityName: string; + stats: { leadership: number; strength: number; intelligence: number }; + experience: number; + progression: { + experienceLevel: number; + statExperience: { leadership: number; strength: number; intelligence: number }; + statUpgradeLimit: number; + dex: number[]; + }; +}; + +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: { + experienceLevel: 4, + statExperience: { leadership: 7, strength: 8, intelligence: 9 }, + statUpgradeLimit: 20, + dex: [350, 1_375, 3_500, 7_125, 1_275_975], + }, +}); type TroopFixture = { id: number; name: string; @@ -61,11 +89,7 @@ const baseTroops = (): TroopFixture[] => [ picture: 'default.jpg', imageServer: 0, }, - members: [ - { id: 1, name: '공손찬', cityId: 1, cityName: '북평' }, - { id: 3, name: '조운', cityId: 1, cityName: '북평' }, - { id: 4, name: '전예', cityId: 2, cityName: '계' }, - ], + members: [member(1, '공손찬', 1, '북평'), member(3, '조운', 1, '북평'), member(4, '전예', 2, '계')], }, { id: 2, @@ -81,7 +105,7 @@ const baseTroops = (): TroopFixture[] => [ picture: 'default.jpg', imageServer: 0, }, - members: [{ id: 2, name: '관우', cityId: 2, cityName: '계' }], + members: [member(2, '관우', 2, '계')], }, ]; @@ -124,11 +148,11 @@ const installApiFixture = async (page: Page, state: FixtureState) => { window.localStorage.setItem('sammo-game-token', 'ga_playwright'); window.localStorage.setItem('sammo-game-profile', profile); }, gameProfile); - for (const filename of ['back_walnut.jpg', 'back_green.jpg']) { + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'pr5.gif', 'pb5.gif', 'pr8.gif', 'pb8.gif']) { await page.route(`**/image/game/${filename}`, async (route) => { await route.fulfill({ status: 200, - contentType: 'image/jpeg', + contentType: filename.endsWith('.gif') ? 'image/gif' : 'image/jpeg', body: await readReferenceImage(filename), }); }); @@ -182,7 +206,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => { picture: 'default.jpg', imageServer: 0, }, - members: [{ id: createdId, name: '유비', cityId: 1, cityName: '북평' }], + members: [member(createdId, '유비', 1, '북평')], }); return response({ ok: true, troopId: createdId, troopName: '신규대' }); } @@ -270,6 +294,15 @@ test('renders the legacy desktop grid with matching computed geometry and states 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); + expect( + await page + .getByRole('tooltip') + .locator('[role="progressbar"]') + .first() + .evaluate((bar) => getComputedStyle(bar).backgroundImage) + ).toContain('/game/pr8.gif'); expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo( 500, 0 diff --git a/app/game-frontend/src/components/main/CityBasicCard.vue b/app/game-frontend/src/components/main/CityBasicCard.vue index 92759f3e..5f122258 100644 --- a/app/game-frontend/src/components/main/CityBasicCard.vue +++ b/app/game-frontend/src/components/main/CityBasicCard.vue @@ -1,5 +1,8 @@ @@ -420,13 +424,19 @@ onMounted(() => { z-index: 10; width: 500px; min-height: 58px; - padding: 8px; + padding: 0; display: flex; flex-direction: column; border: 1px solid #999; background: #202020; } +.popup-title { + display: flex; + justify-content: space-between; + padding: 3px 6px; +} + .additionalTroopOptions { margin-top: 1em; } diff --git a/app/game-frontend/test/legacyProgress.test.ts b/app/game-frontend/test/legacyProgress.test.ts new file mode 100644 index 00000000..23fab639 --- /dev/null +++ b/app/game-frontend/test/legacyProgress.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { DEX_EX_PLUS, dexProgress, legacyExperiencePercent, ratioPercent } from '../src/utils/legacyProgress.ts'; + +void describe('legacy progress calculations', () => { + void it('uses the real EX+ threshold for the full dexterity bar', () => { + assert.equal(DEX_EX_PLUS, 1_275_975); + assert.equal(dexProgress(1_000_000).overallPercent, (1_000_000 / 1_275_975) * 100); + assert.equal(dexProgress(DEX_EX_PLUS).overallPercent, 100); + }); + + void it('tracks progress inside the current dexterity grade separately', () => { + assert.deepEqual(dexProgress(350), { + level: 1, + name: 'F', + color: 'navy', + overallPercent: (350 / DEX_EX_PLUS) * 100, + gradePercent: 0, + nextName: 'F+', + remaining: 1_025, + }); + assert.equal(dexProgress(1_275_975).gradePercent, 100); + assert.equal(dexProgress(1_275_975).nextName, null); + }); + + void it('preserves ref experience-level and guarded ratio percentages', () => { + assert.equal(legacyExperiencePercent(55, 0), 55); + assert.equal(legacyExperiencePercent(450, 4), 50); + assert.equal(legacyExperiencePercent(1_210, 11), 0); + assert.equal(legacyExperiencePercent(1_440, 11), 100); + assert.equal(ratioPercent(10, 0), 0); + assert.equal(ratioPercent(15, 10), 100); + }); +}); diff --git a/tools/frontend-legacy-parity/reference-progress-bars.mjs b/tools/frontend-legacy-parity/reference-progress-bars.mjs new file mode 100644 index 00000000..86d0c1fe --- /dev/null +++ b/tools/frontend-legacy-parity/reference-progress-bars.mjs @@ -0,0 +1,114 @@ +import { createHash } from 'node:crypto'; +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { chromium } from '@playwright/test'; + +const baseUrl = process.env.REF_MAIN_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_USER_ID ?? 'refuser1'; +const passwordFile = + process.env.REF_USER_PASSWORD_FILE ?? + '/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password'; +const artifactRoot = resolve(process.env.REF_PROGRESS_ARTIFACT_DIR ?? 'artifacts/ref-progress-bars'); + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true, mode: 0o700 }); +const browser = await chromium.launch({ headless: true }); + +try { + const context = await browser.newContext({ + colorScheme: 'dark', + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'UTC', + }); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + if (!response.ok()) throw new Error(`reference login failed: HTTP ${response.status()}`); + const loginPayload = await response.json(); + if (loginPayload?.result !== true) { + throw new Error(`reference login rejected: ${String(loginPayload?.reason ?? loginPayload)}`); + } + + if (process.env.REF_CREATE_GENERAL === '1') { + await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + const createButton = page.getByRole('button', { name: '장수 생성', exact: true }); + if (await createButton.isVisible()) { + page.on('dialog', (dialog) => dialog.accept()); + await createButton.click(); + await page.waitForURL(/\/hwe\/?$/u); + } + } + + for (const viewport of [ + { name: 'desktop-1000', width: 1000, height: 900 }, + { name: 'mobile-500', width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + try { + await page + .locator('.city-card-basic .sammo-bar, .bar_out') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }); + } catch { + throw new Error( + `reference main progress bars missing: ${JSON.stringify({ + url: page.url(), + title: await page.title(), + cityCards: await page.locator('.city-card-basic').count(), + generalCards: await page.locator('.general-card-basic').count(), + legacyBars: await page.locator('.bar_out').count(), + })}` + ); + } + const measurement = await page.evaluate(() => { + const inspect = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const fill = element.querySelector('.sammo-bar-in'); + const fillStyle = fill ? getComputedStyle(fill) : null; + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + borderTop: style.borderTop, + borderBottom: style.borderBottom, + backgroundImage: getComputedStyle(element.querySelector('.sammo-bar-base')).backgroundImage, + fillWidth: fill?.getBoundingClientRect().width ?? 0, + fillBackgroundImage: fillStyle?.backgroundImage ?? '', + }; + }; + return { + viewport: { width: innerWidth, height: innerHeight }, + city: [...document.querySelectorAll('.city-card-basic .sammo-bar')].map(inspect), + general: [...document.querySelectorAll('.general-card-basic .sammo-bar')].map(inspect), + cityCard: document.querySelector('.city-card-basic').getBoundingClientRect().toJSON(), + generalCard: document.querySelector('.general-card-basic').getBoundingClientRect().toJSON(), + }; + }); + await Promise.all([ + page.screenshot({ path: resolve(artifactRoot, `ref-${viewport.name}.png`), fullPage: true }), + writeFile(resolve(artifactRoot, `ref-${viewport.name}.json`), `${JSON.stringify(measurement, null, 2)}\n`, { + mode: 0o600, + }), + ]); + await chmod(resolve(artifactRoot, `ref-${viewport.name}.png`), 0o600); + console.log( + JSON.stringify({ + viewport: viewport.name, + cityBars: measurement.city.length, + generalBars: measurement.general.length, + cityHeight: measurement.city[0]?.rect.height, + generalHeight: measurement.general[0]?.rect.height, + }) + ); + } +} finally { + await browser.close(); +}