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
+2
View File
@@ -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(
@@ -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<number, number>();
for (const row of battleCounts) {
if (row.generalId !== null) {
battleCountMap.set(row.generalId, row._count._all);
}
}
const rankValueMap = new Map<number, Map<RankDataType, number>>();
for (const row of rankRows) {
const values = rankValueMap.get(row.generalId) ?? new Map<RankDataType, number>();
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<string, unknown>)
: {};
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}`)),
},
};
@@ -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<typeof vi.fn>;
}) => {
@@ -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' } }),