fix(game-ui): 내정보와 감찰부 장수 패널을 통일한다

두 화면이 같은 장수 정보 패널에서 전투 통계와 숙련도를 렌더링하도록 정리한다.

감찰부 통계를 rank_data에서 읽고 삭턴, 사관, 최근 전투와 승률·살상률 계약을 맞춘다.
This commit is contained in:
2026-08-21 16:20:50 +00:00
parent 76f58222bd
commit d5d57bbabc
9 changed files with 267 additions and 117 deletions
+58 -7
View File
@@ -102,6 +102,9 @@ const myGeneral = (state: FixtureState) => ({
dedication: 200,
age: 30,
turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00',
defenceTrain: 80,
killTurn: 6,
crewTypeId: 1,
crewTypeName: '보병',
crewTypeInfo: state.richMyInfo
@@ -277,7 +280,7 @@ const battleCenter = (state: FixtureState) => ({
cityId: 1,
turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00',
warnum: 3,
warnum: 8,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 100,
dedication: 200,
@@ -288,6 +291,8 @@ const battleCenter = (state: FixtureState) => ({
train: 80,
atmos: 90,
age: 30,
defenceTrain: 80,
killTurn: 6,
crewTypeId: 1,
crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
@@ -301,7 +306,8 @@ const battleCenter = (state: FixtureState) => ({
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: [] },
serviceYears: 4,
battleStats: { kills: 5, deaths: 3, fire: 12, killCrew: 12_345, deathCrew: 6_789, dex: [] },
},
{
id: 8,
@@ -323,6 +329,8 @@ const battleCenter = (state: FixtureState) => ({
train: 60,
atmos: 60,
age: 20,
defenceTrain: 80,
killTurn: 4,
crewTypeId: 1,
crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
@@ -336,6 +344,7 @@ const battleCenter = (state: FixtureState) => ({
statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0],
},
serviceYears: 1,
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] },
},
],
@@ -1213,6 +1222,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-information-panel', '');
const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
{ width: 64, height: 64 },
@@ -1220,11 +1230,32 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
]);
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
await expect(page.locator('.legacy-general-details')).toContainText('전투 8 · 계략 12 · 사관 4년');
await expect(page.locator('.legacy-general-details')).toContainText('승률 62.50% · 승리 5 · 패배 3');
await expect(page.locator('.legacy-general-details')).toContainText('살상률 181.84% · 사살 12,345 · 피살 6,789');
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('전투8회');
await expect(page.locator('.battle-general-extra')).toContainText('계략12');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
await expect(page.locator('.battle-general-extra')).toContainText('사살12,345');
await expect(page.locator('.battle-general-extra')).toContainText('피살6,789');
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
await expect(page.locator('.legacy-general-details')).toHaveCount(0);
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계급',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
]);
await expect(page.locator('.item-group')).toContainText('명마');
await expect(page.locator('#container')).not.toContainText('che_');
await expect(page.locator('.title-row')).toContainText('내 정 보');
@@ -2006,6 +2037,26 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
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', '');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-information-panel', '');
await expect(page.locator('.battle-general-card')).toContainText('삭턴6 턴');
await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계급',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
]);
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2);
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
@@ -22,6 +22,7 @@ interface GeneralProgression {
dedicationText?: string;
statExperience?: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit?: number;
dex?: number[];
}
interface ItemDisplayNames {
@@ -65,7 +66,7 @@ interface GeneralRefreshScore {
text: string;
}
interface GeneralInfo {
export interface GeneralBasicCardData {
id: number;
name: string;
picture?: string | null;
@@ -107,7 +108,7 @@ interface GeneralInfo {
const props = withDefaults(
defineProps<{
general: GeneralInfo | null;
general: GeneralBasicCardData | null;
loading: boolean;
nationColor?: string | null;
defenceText?: string | null;
@@ -11,6 +11,7 @@ export type GeneralBattleSummaryData = {
wins?: number | null;
losses?: number | null;
strategies?: number | null;
serviceYears?: number | null;
killCrew?: number | null;
deathCrew?: number | null;
winRate?: number | null;
@@ -30,7 +31,7 @@ const props = withDefaults(
const numberText = (value: number | null | undefined): string =>
typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-';
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(1)}%`;
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(2)}%`;
const winRate = computed(() => {
if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) {
@@ -39,7 +40,7 @@ const winRate = computed(() => {
const battles = props.summary.warnum;
const wins = props.summary.wins;
if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-';
return `${((wins / battles) * 100).toFixed(1)}%`;
return `${((wins / battles) * 100).toFixed(2)}%`;
});
const killRate = computed(() => {
@@ -49,7 +50,7 @@ const killRate = computed(() => {
const killed = props.summary.killCrew;
const lost = props.summary.deathCrew;
if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-';
return `${((killed / lost) * 100).toFixed(1)}%`;
return `${((killed / lost) * 100).toFixed(2)}%`;
});
</script>
@@ -64,8 +65,15 @@ const killRate = computed(() => {
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong>
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
><strong>{{ numberText(summary.strategies) }}</strong> <span>사살</span
><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
><strong>{{ numberText(summary.strategies) }}</strong>
<template v-if="summary.serviceYears !== undefined">
<span>사관</span
><strong
>{{ numberText(summary.serviceYears)
}}<template v-if="summary.serviceYears != null"></template></strong
>
</template>
<span>사살</span><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
><strong>{{ numberText(summary.deathCrew) }}</strong>
<template v-if="showWinRate">
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import GeneralBasicCard, { type GeneralBasicCardData } from './GeneralBasicCard.vue';
import GeneralBattleSummary, { type GeneralBattleSummaryData } from './GeneralBattleSummary.vue';
import LegacyGeneralProgress from '../ui/LegacyGeneralProgress.vue';
type BasicProgression = NonNullable<GeneralBasicCardData['progression']>;
export type GeneralInformationPanelData = GeneralBasicCardData & {
progression: BasicProgression & {
statExperience: NonNullable<BasicProgression['statExperience']>;
statUpgradeLimit: number;
dex: number[];
};
};
const props = withDefaults(
defineProps<{
general: GeneralInformationPanelData | null;
summary: GeneralBattleSummaryData | null;
loading: boolean;
nationColor?: string | null;
defenceText?: string | null;
killTurn?: number | null;
remainingMinutes?: number | null;
troopText?: string | null;
penaltyText?: string | number | null;
}>(),
{
nationColor: '#173d27',
defenceText: null,
killTurn: null,
remainingMinutes: null,
troopText: null,
penaltyText: null,
}
);
</script>
<template>
<GeneralBasicCard
data-general-information-panel
:general="props.general"
:loading="props.loading"
:nation-color="props.nationColor"
:defence-text="props.defenceText"
:kill-turn="props.killTurn"
:remaining-minutes="props.remainingMinutes"
:troop-text="props.troopText"
:penalty-text="props.penaltyText"
>
<template v-if="props.general" #details>
<GeneralBattleSummary v-if="props.summary" :summary="props.summary" show-win-rate />
<LegacyGeneralProgress :general="props.general" :show-primary="false" />
</template>
</GeneralBasicCard>
</template>
@@ -3,9 +3,7 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
import {
GENERAL_RECORD_TYPES,
@@ -273,30 +271,29 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<GeneralBasicCard
<GeneralInformationPanel
class="battle-general-card"
:general="selectedGeneral"
:summary="
selectedGeneral
? {
available: true,
experience: selectedGeneral.experience,
dedicationText: selectedGeneral.progression.dedicationText,
warnum: selectedGeneral.warnum,
wins: selectedGeneral.battleStats.kills,
losses: selectedGeneral.battleStats.deaths,
strategies: selectedGeneral.battleStats.fire,
serviceYears: selectedGeneral.serviceYears,
killCrew: selectedGeneral.battleStats.killCrew,
deathCrew: selectedGeneral.battleStats.deathCrew,
recentWar: selectedGeneral.recentWar,
}
: null
"
:loading="loading"
:nation-color="data?.nation.color"
>
<template v-if="selectedGeneral" #details>
<GeneralBattleSummary
:summary="{
available: true,
experience: selectedGeneral.experience,
dedicationText: selectedGeneral.progression.dedicationText,
warnum: selectedGeneral.warnum,
wins: selectedGeneral.battleStats.kills,
losses: selectedGeneral.battleStats.deaths,
strategies: selectedGeneral.battleStats.fire,
killCrew: selectedGeneral.battleStats.killCrew,
deathCrew: selectedGeneral.battleStats.deathCrew,
recentWar: selectedGeneral.recentWar,
}"
/>
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template>
</GeneralBasicCard>
/>
</PanelCard>
</div>
+20 -56
View File
@@ -7,8 +7,7 @@ import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import {
@@ -136,9 +135,6 @@ const statusLine = computed(() =>
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const numberText = (value: number): string => value.toLocaleString('ko-KR');
const percentText = (numerator: number, denominator: number): string =>
`${((numerator / Math.max(denominator, 1)) * 100).toFixed(2)}%`;
const noDefencePenaltyWaived = computed(() => {
const environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect(
@@ -416,55 +412,31 @@ onMounted(() => {
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<GeneralBasicCard
<GeneralInformationPanel
class="general-table"
:general="data?.general ?? null"
:summary="
data
? {
available: true,
experience: data.general.experience,
dedicationText: data.general.progression?.dedicationText,
warnum: data.general.records.battles,
wins: data.general.records.wins,
losses: data.general.records.losses,
strategies: data.general.records.strategies,
serviceYears: data.general.records.serviceYears,
killCrew: data.general.records.killedCrew,
deathCrew: data.general.records.lostCrew,
recentWar: data.general.recentWar,
}
: null
"
:loading="loading"
:nation-color="data?.nation?.color"
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:penalty-text="penalties.length || '-'"
>
<template v-if="data" #details>
<div class="legacy-general-details">
<div>
명망
<strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
</div>
<div>
전투 {{ numberText(data.general.records.battles) }} · 계략
{{ numberText(data.general.records.strategies) }} · 사관
{{ numberText(data.general.records.serviceYears) }}
</div>
<div>
승률 {{ percentText(data.general.records.wins, data.general.records.battles) }} · 승리
{{ numberText(data.general.records.wins) }} · 패배
{{ numberText(data.general.records.losses) }}
</div>
<div>
살상률
{{ percentText(data.general.records.killedCrew, data.general.records.lostCrew) }} · 사살
{{ numberText(data.general.records.killedCrew) }} · 피살
{{ numberText(data.general.records.lostCrew) }}
</div>
<div>
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
</div>
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
</div>
</template>
</GeneralBasicCard>
/>
</div>
<div class="settings-column">
@@ -875,14 +847,6 @@ button:disabled {
.legacy-general-info-compat {
display: none;
}
.legacy-general-details {
background: #172a52 var(--sammo-texture-blue);
line-height: 20px;
text-align: center;
}
.legacy-general-details > div {
border-top: 1px solid #557;
}
.legacy-credit {
max-width: 100%;
overflow: hidden;