diff --git a/app/game-frontend/e2e/infoMobileLayout.spec.ts b/app/game-frontend/e2e/infoMobileLayout.spec.ts index 34003356..6b18908d 100644 --- a/app/game-frontend/e2e/infoMobileLayout.spec.ts +++ b/app/game-frontend/e2e/infoMobileLayout.spec.ts @@ -234,6 +234,7 @@ const measure = async (page: Page, root: string, table: string, name: string) => rect: rect(row), cells: Array.from(row.querySelectorAll('td'), (cell) => ({ field: cell.dataset.field, + label: getComputedStyle(cell, '::before').content, gridArea: getComputedStyle(cell).gridArea, rect: rect(cell), text: cell.innerText.replace(/\s+/g, ''), @@ -276,6 +277,12 @@ for (const [route, root, table] of [ await page.setViewportSize({ width: 1000, height: 900 }); await page.goto(route); const desktop = await measure(page, root, table, `${route.replace('/', '-')}-1000`); + const firstGeneral = page.locator(`${table} tbody tr`).first(); + await expect(firstGeneral.locator('[data-field="crew"]')).toHaveText('12,345'); + if (route === 'nation/secret') { + await expect(firstGeneral.locator('[data-field="gold"]')).toHaveText('123,456,789'); + await expect(firstGeneral.locator('[data-field="rice"]')).toHaveText('234,567'); + } for (const width of [500, 501, 800]) { await page.setViewportSize({ width, height: 900 }); const mobile = await measure(page, root, table, `${route.replace('/', '-')}-${width}`); @@ -293,6 +300,18 @@ for (const [route, root, table] of [ for (const row of mobile.rows) { const turns = row.cells.find((cell) => cell.field === 'turns')!; expect(turns.rect.width).toBeCloseTo(166, 0); + const train = row.cells.find((cell) => cell.field === 'train')!; + const atmos = row.cells.find((cell) => cell.field === 'atmos')!; + expect(train.label).toBe('"훈/사"'); + expect(atmos.label).toBe('"/"'); + expect(train.rect.y).toBe(atmos.rect.y); + expect(train.rect.x + train.rect.width).toBeCloseTo(atmos.rect.x, 1); + if (route === 'nation/secret') { + const gold = row.cells.find((cell) => cell.field === 'gold')!; + const rice = row.cells.find((cell) => cell.field === 'rice')!; + expect(gold.rect.width).toBeCloseTo(rice.rect.width, 1); + expect(gold.rect.y).toBe(rice.rect.y); + } for (const [index, cell] of row.cells.entries()) { const reference = mobile.rows[0]!.cells[index]!; expect(cell.gridArea).toBe(reference.gridArea); diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 34ba1704..68eedb71 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -126,12 +126,12 @@ const npcColorStates = [0, 1, 2, 4, 5, 6] as const; const npcColorGenerals = npcColorStates.map((npcState) => ({ ...general, id: 100 + npcState, - name: `색상장수${npcState}`, + name: `${({ 4: 'ⓖ', 5: '㉥', 6: 'ⓤ' } as Record)[npcState] ?? ''}색상장수${npcState}`, npcState, })); const npcColorSecretGenerals = npcColorStates.map((npcState) => ({ id: 100 + npcState, - name: `색상장수${npcState}`, + name: `${({ 4: 'ⓖ', 5: '㉥', 6: 'ⓤ' } as Record)[npcState] ?? ''}색상장수${npcState}`, npcState, injury: 0, baseStats: { leadership: 70, strength: 60, intelligence: 50 }, @@ -292,6 +292,9 @@ test('NPC general names use the complete Ref palette in secret and nation lists for (const npcState of npcColorStates) { const name = table.locator(`[data-npc-state="${npcState}"] [data-general-name]`); await expect(name).toBeVisible(); + if (npcState === 4) await expect(name).toHaveText('ⓖ색상장수4'); + if (npcState === 5) await expect(name).toHaveText('㉥색상장수5'); + if (npcState === 6) await expect(name).toHaveText('ⓤ색상장수6'); if (npcState === 0) { expect(await name.evaluate((element) => (element as HTMLElement).style.color)).toBe(''); } else { diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue index 28b7bcc6..fbc77ee9 100644 --- a/app/game-frontend/src/views/CurrentCityView.vue +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -284,7 +284,7 @@ const commandBrief = (command: ReservedCommand): string => {{ defenceTrainText(general.defenceTrain) }} {{ general.crewTypeName ?? '?' }} - {{ general.crew ?? '?' }} + {{ show(general.crew) }} {{ general.train ?? '?' }} {{ general.atmos ?? '?' }} @@ -590,6 +590,22 @@ const commandBrief = (command: ReservedCommand): string => .generals [data-field='crew'] { grid-area: crew; } + /* 같은 척도의 훈련·사기는 한 쌍으로 읽되 원래 데이터 셀과 값을 보존한다. */ + .generals tbody td[data-field='train'] { + text-align: right; + padding-right: 0; + } + .generals tbody td[data-field='train']::before { + content: '훈/사'; + } + .generals tbody td[data-field='atmos'] { + text-align: left; + padding-left: 0; + } + .generals tbody td[data-field='atmos']::before { + content: '/'; + margin-inline: 3px; + } .generals [data-field='train'] { grid-area: train; } diff --git a/app/game-frontend/src/views/NationSecretView.vue b/app/game-frontend/src/views/NationSecretView.vue index 16d571c4..fd263b9b 100644 --- a/app/game-frontend/src/views/NationSecretView.vue +++ b/app/game-frontend/src/views/NationSecretView.vue @@ -50,7 +50,7 @@ const generals = computed(() => ); const closeWindow = () => window.close(); const displayName = (general: { name: string; npcState: number }) => - general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `ⓝ${general.name}` : general.name; + general.npcState > 0 && !/^[ⓜⓝⓖ㉥ⓤⓞⓧ]/u.test(general.name) ? `ⓝ${general.name}` : general.name; const injuryInfo = (injury: number) => generalInjuryPresentation(injury); const injuryDescription = (general: Result['generals'][number]): string => general.injury > 0 ? `부상 ${general.injury}% · ${injuryInfo(general.injury).text}` : ''; @@ -312,12 +312,12 @@ onMounted(load); > {{ general.troopName ?? '-' }} - {{ general.gold }} - {{ general.rice }} + {{ general.gold.toLocaleString('ko-KR') }} + {{ general.rice.toLocaleString('ko-KR') }} {{ general.cityName ?? '-' }} {{ general.defenceTrainText }} {{ general.crewTypeName }} - {{ general.crew }} + {{ general.crew.toLocaleString('ko-KR') }} {{ general.train }} {{ general.atmos }} @@ -571,12 +571,12 @@ th, } .list tbody tr { display: grid; - grid-template-columns: repeat(12, minmax(0, 1fr)) 166px; + grid-template-columns: repeat(24, minmax(0, 1fr)) 166px; grid-template-areas: - 'name name name name name name stats stats stats stats stats stats turns' - 'troop troop troop troop troop troop city city city city defence defence turns' - 'gold gold gold gold gold rice rice rice rice time time time turns' - 'type type type crew crew crew train train atmos atmos kill kill turns'; + 'name name name name name name name name name name name name stats stats stats stats stats stats stats stats stats stats stats stats turns' + 'troop troop troop troop troop troop troop troop troop troop troop troop city city city city city city city city defence defence defence defence turns' + 'gold gold gold gold gold gold gold gold gold rice rice rice rice rice rice rice rice rice time time time time time time turns' + 'type type type type type type crew crew crew crew crew crew train train train train train atmos atmos atmos kill kill kill kill turns'; height: auto; border: 1px solid gray; border-top: 0; @@ -601,7 +601,11 @@ th, display: none; } .general-level { - margin-left: 4px; + margin-left: 3px; + font-size: 11px; + line-height: 1; + color: #bbb; + white-space: nowrap; } .list tbody .turns { font-size: 11px; @@ -637,6 +641,22 @@ th, .list [data-field='crew'] { grid-area: crew; } + /* 같은 척도의 훈련·사기는 한 쌍으로 읽되 원래 데이터 셀과 값을 보존한다. */ + .list tbody td[data-field='train'] { + text-align: right; + padding-right: 0; + } + .list tbody td[data-field='train']::before { + content: '훈/사'; + } + .list tbody td[data-field='atmos'] { + text-align: left; + padding-left: 0; + } + .list tbody td[data-field='atmos']::before { + content: '/'; + margin-inline: 3px; + } .list [data-field='train'] { grid-area: train; } diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 37364577..1541aaa0 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -128,7 +128,12 @@ cell, command and image from the desktop dataset. Statistics share vertical spac with reserved commands; current-city portraits remain 64px. Both lists reserve 166px of the 500px table for commands and 332px for information (plus borders). Secret rows use the same four-line field positions for players, NPCs and empty -reservations. Long content wraps without truncation. +reservations. Long content wraps without truncation. Gold and rice have equal +widths; training and morale form a compact `훈/사 value / value` pair in both +views. The secret level stays visible as secondary 11px text. Existing decorated +NPC names, including volunteer `ⓖ` and `ⓤ`, are not prefixed with an extra `ⓝ`. +Crew, gold and rice values use Korean-locale thousands separators in both modes; +redacted current-city crew values remain `?`. The fixture measures 100 generals with five-command and NPC rows at CSS viewport widths 1000, 500, 501 and 800. The mixed-list height must stay below 1.5 times the