diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index ab1b9f8d..29f39cbb 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -265,6 +265,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { dedication: true, age: true, turnTime: true, + recentWarTime: true, crewTypeId: true, personalCode: true, specialCode: true, @@ -519,6 +520,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { age: general.age, retirementYear, turnTime: general.turnTime.toISOString(), + recentWar: general.recentWarTime?.toISOString() ?? null, defenceTrain: settings.defence_train, killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), remainingMinutes: resolveRemainingMinutes( diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts index 5ce0cb83..0601df8a 100644 --- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { asRecord } from '@sammo-ts/common'; +import { asRecord, type RankDataType } from '@sammo-ts/common'; import { LogCategory } from '@sammo-ts/logic'; import { accessAuthedProcedure } from '../../../trpc.js'; @@ -14,6 +14,15 @@ import { import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, formatDateTime, loadTraitNames, resolveNationPermission } from '../shared.js'; +const BATTLE_CENTER_RECORD_TYPES = [ + 'firenum', + 'warnum', + 'killnum', + 'deathnum', + 'killcrew', + 'deathcrew', +] as const satisfies readonly RankDataType[]; + export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); @@ -81,23 +90,38 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { } const generalIds = generalRows.map((general) => general.id); - const battleCounts = + const [battleCounts, rankRows] = generalIds.length > 0 - ? await ctx.db.logEntry.groupBy({ - by: ['generalId'], - where: { - generalId: { in: generalIds }, - category: LogCategory.BATTLE_BRIEF, - }, - _count: { _all: true }, - }) - : []; + ? await Promise.all([ + ctx.db.logEntry.groupBy({ + by: ['generalId'], + where: { + generalId: { in: generalIds }, + category: LogCategory.BATTLE_BRIEF, + }, + _count: { _all: true }, + }), + ctx.db.rankData.findMany({ + where: { + generalId: { in: generalIds }, + type: { in: [...BATTLE_CENTER_RECORD_TYPES] }, + }, + select: { generalId: true, type: true, value: true }, + }), + ]) + : [[], []]; const battleCountMap = new Map(); for (const row of battleCounts) { if (row.generalId !== null) { battleCountMap.set(row.generalId, row._count._all); } } + const rankValueMap = new Map>(); + for (const row of rankRows) { + const values = rankValueMap.get(row.generalId) ?? new Map(); + values.set(row.type as (typeof BATTLE_CENTER_RECORD_TYPES)[number], row.value); + rankValueMap.set(row.generalId, values); + } const worldConfig = asRecord(worldState.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); @@ -141,10 +165,18 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta) ? (general.meta as Record) : {}; - const metaNumber = (key: string): number => { - const value = meta[key]; - return typeof value === 'number' && Number.isFinite(value) ? value : 0; + const metaNumber = (keys: string | string[], fallback = 0): number => { + for (const key of Array.isArray(keys) ? keys : [keys]) { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return fallback; }; + const rankValue = (type: (typeof BATTLE_CENTER_RECORD_TYPES)[number], fallback = 0): number => + rankValueMap.get(general.id)?.get(type) ?? fallback; + const warnum = rankValue('warnum', metaNumber(['rank_warnum', 'warnum'], battleCountMap.get(general.id) ?? 0)); const storedDedicationLevel = metaNumber('dedlevel'); const dedicationLevel = storedDedicationLevel > 0 @@ -161,7 +193,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { cityId: general.cityId, turnTime: formatDateTime(general.turnTime), recentWar: formatDateTime(general.recentWarTime), - warnum: battleCountMap.get(general.id) ?? 0, + warnum, stats: { leadership: general.leadership, strength: general.strength, @@ -176,6 +208,8 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { train: general.train, atmos: general.atmos, age: general.age, + defenceTrain: metaNumber('defence_train', 80), + killTurn: metaNumber(['killturn', 'killTurn']), crewTypeId: general.crewTypeId, crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', equipment: { @@ -207,12 +241,13 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { statUpgradeLimit, dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), }, + serviceYears: metaNumber('belong'), battleStats: { - kills: metaNumber('rank_killnum') || metaNumber('killnum'), - deaths: metaNumber('deathnum'), - fire: metaNumber('firenum'), - killCrew: metaNumber('killcrew'), - deathCrew: metaNumber('deathcrew'), + kills: rankValue('killnum', metaNumber(['rank_killnum', 'killnum'])), + deaths: rankValue('deathnum', metaNumber(['rank_deathnum', 'deathnum'])), + fire: rankValue('firenum', metaNumber(['rank_firenum', 'firenum'])), + killCrew: rankValue('killcrew', metaNumber(['rank_killcrew', 'killcrew'])), + deathCrew: rankValue('deathcrew', metaNumber(['rank_deathcrew', 'deathcrew'])), dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), }, }; diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 33b4be24..4ed4e01f 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -85,7 +85,7 @@ const createContext = (options: { troopLeaderAction?: string | null; refreshScore?: number; refreshScoreTotal?: number; - rankRows?: Array<{ type: string; value: number }>; + rankRows?: Array<{ generalId?: number; type: string; value: number }>; requestId?: string; transaction?: ReturnType; }) => { @@ -130,7 +130,9 @@ const createContext = (options: { })), }, rankData: { - findMany: vi.fn(async () => options.rankRows ?? []), + findMany: vi.fn(async () => + (options.rankRows ?? []).map((row) => ({ generalId: row.generalId ?? me?.id ?? 0, ...row })) + ), }, city: { findUnique: vi.fn(async () => options.city ?? null), @@ -812,7 +814,23 @@ describe('battle-center general and user permissions', () => { }); const tenured = createContext({ - me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }), + me: buildGeneral({ + officerLevel: 1, + meta: { + belong: 3, + permission: 'normal', + killturn: 6, + defence_train: 80, + }, + }), + rankRows: [ + { type: 'warnum', value: 8 }, + { type: 'killnum', value: 5 }, + { type: 'deathnum', value: 3 }, + { type: 'firenum', value: 12 }, + { type: 'killcrew', value: 12_345 }, + { type: 'deathcrew', value: 6_789 }, + ], nationMeta: { secretlimit: 3 }, }); await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({ @@ -823,6 +841,9 @@ describe('battle-center general and user permissions', () => { picture: 'default.jpg', imageServer: 0, officerLevelText: '일반', + warnum: 8, + defenceTrain: 80, + killTurn: 6, crewTypeName: '-', equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' }, traits: { personal: '-', specialDomestic: '-', specialWar: '-' }, @@ -834,10 +855,25 @@ describe('battle-center general and user permissions', () => { 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] }, + serviceYears: 3, + battleStats: { + kills: 5, + deaths: 3, + fire: 12, + killCrew: 12_345, + deathCrew: 6_789, + dex: [0, 0, 0, 0, 0], + }, }, ], }); + expect(tenured.db.rankData.findMany).toHaveBeenCalledWith({ + where: { + generalId: { in: [7] }, + type: { in: ['firenum', 'warnum', 'killnum', 'deathnum', 'killcrew', 'deathcrew'] }, + }, + select: { generalId: true, type: true, value: true }, + }); const auditor = createContext({ me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }), diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index d9b80cb3..3ed880fc 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -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'); diff --git a/app/game-frontend/src/components/main/GeneralBasicCard.vue b/app/game-frontend/src/components/main/GeneralBasicCard.vue index 227111f1..4fa8125e 100644 --- a/app/game-frontend/src/components/main/GeneralBasicCard.vue +++ b/app/game-frontend/src/components/main/GeneralBasicCard.vue @@ -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; diff --git a/app/game-frontend/src/components/main/GeneralBattleSummary.vue b/app/game-frontend/src/components/main/GeneralBattleSummary.vue index 55ba881e..982bd647 100644 --- a/app/game-frontend/src/components/main/GeneralBattleSummary.vue +++ b/app/game-frontend/src/components/main/GeneralBattleSummary.vue @@ -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)}%`; }); @@ -64,8 +65,15 @@ const killRate = computed(() => { >{{ numberText(summary.warnum) }} 승리{{ numberText(summary.wins) }} 패배{{ numberText(summary.losses) }} 계략{{ numberText(summary.strategies) }} 사살{{ numberText(summary.killCrew) }} 피살{{ numberText(summary.strategies) }} + + 사살{{ numberText(summary.killCrew) }} 피살{{ numberText(summary.deathCrew) }}