fix(game-ui): 내 정보 전투 누적 수치를 실제 기록에 연결
명장 일람과 같은 rank_data 원천을 general.me에 투영하고 Ref 비율식으로 표시한다. 전투 누적과 무쌍 승리수 배율을 회귀 테스트로 고정한다.
This commit is contained in:
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import {
|
||||
@@ -63,6 +63,14 @@ const zImmediateActionInput = z
|
||||
})
|
||||
.optional();
|
||||
const MAIN_RECORD_LIMIT = 15;
|
||||
const PERSONAL_RECORD_TYPES = [
|
||||
'firenum',
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
] as const satisfies readonly RankDataType[];
|
||||
const NEUTRAL_NATION_CONTEXT = {
|
||||
id: 0,
|
||||
name: '재야',
|
||||
@@ -276,7 +284,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0);
|
||||
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog] =
|
||||
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog, rankRows] =
|
||||
await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -346,6 +354,10 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
where: { generalId: general.id },
|
||||
select: { refreshScore: true, refreshScoreTotal: true },
|
||||
}),
|
||||
ctx.db.rankData.findMany({
|
||||
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
@@ -466,6 +478,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
};
|
||||
const refreshScore = accessLog?.refreshScore ?? 0;
|
||||
const refreshScoreTotal = accessLog?.refreshScoreTotal ?? 0;
|
||||
const rankValues = new Map(rankRows.map((row) => [row.type, row.value]));
|
||||
const rankValue = (type: (typeof PERSONAL_RECORD_TYPES)[number]): number => rankValues.get(type) ?? 0;
|
||||
const troopStatus: 'inactive' | 'present' | 'away' =
|
||||
troopLeaderFirstTurn?.actionCode !== undefined && troopLeaderFirstTurn.actionCode !== 'che_집합'
|
||||
? 'inactive'
|
||||
@@ -527,6 +541,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||
},
|
||||
records: {
|
||||
battles: rankValue('warnum'),
|
||||
strategies: rankValue('firenum'),
|
||||
serviceYears: readNumber(metaRecord.belong, 0),
|
||||
wins: rankValue('killnum'),
|
||||
losses: rankValue('deathnum'),
|
||||
killedCrew: rankValue('killcrew'),
|
||||
lostCrew: rankValue('deathcrew'),
|
||||
},
|
||||
items: {
|
||||
horse: normalizeItemCode(general.horseCode),
|
||||
weapon: normalizeItemCode(general.weaponCode),
|
||||
|
||||
@@ -83,6 +83,7 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
|
||||
general: {
|
||||
findFirst: findGeneral,
|
||||
},
|
||||
rankData: { findMany: async () => [] },
|
||||
city: { findUnique: findCity },
|
||||
nation: { findUnique: findNation },
|
||||
generalAccessLog: { findUnique: async () => null },
|
||||
|
||||
@@ -85,6 +85,7 @@ const createContext = (options: {
|
||||
troopLeaderAction?: string | null;
|
||||
refreshScore?: number;
|
||||
refreshScoreTotal?: number;
|
||||
rankRows?: Array<{ type: string; value: number }>;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
}) => {
|
||||
@@ -128,6 +129,9 @@ const createContext = (options: {
|
||||
refreshScoreTotal: options.refreshScoreTotal ?? 0,
|
||||
})),
|
||||
},
|
||||
rankData: {
|
||||
findMany: vi.fn(async () => options.rankRows ?? []),
|
||||
},
|
||||
city: {
|
||||
findUnique: vi.fn(async () => options.city ?? null),
|
||||
aggregate: vi.fn(async () => ({
|
||||
@@ -206,6 +210,41 @@ const createContext = (options: {
|
||||
};
|
||||
|
||||
describe('in-game my information ownership', () => {
|
||||
it('returns the owned general battle records from the same rank_data source used by rankings', async () => {
|
||||
const fixture = createContext({
|
||||
me: buildGeneral({ meta: { belong: 4, rank_killnum: 999 } }),
|
||||
rankRows: [
|
||||
{ type: 'firenum', value: 12 },
|
||||
{ type: 'warnum', value: 8 },
|
||||
{ type: 'killnum', value: 5 },
|
||||
{ type: 'deathnum', value: 3 },
|
||||
{ type: 'killcrew', value: 12_345 },
|
||||
{ type: 'deathcrew', value: 6_789 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||
general: {
|
||||
records: {
|
||||
battles: 8,
|
||||
strategies: 12,
|
||||
serviceYears: 4,
|
||||
wins: 5,
|
||||
losses: 3,
|
||||
killedCrew: 12_345,
|
||||
lostCrew: 6_789,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixture.db.rankData.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
generalId: 7,
|
||||
type: { in: ['firenum', 'warnum', 'killnum', 'deathnum', 'killcrew', 'deathcrew'] },
|
||||
},
|
||||
select: { type: true, value: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns every ref progress-bar input from the owned general and current city read model', async () => {
|
||||
const fixture = createContext({
|
||||
me: buildGeneral({
|
||||
|
||||
@@ -112,6 +112,15 @@ const myGeneral = (state: FixtureState) => ({
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
records: {
|
||||
battles: 8,
|
||||
strategies: 12,
|
||||
serviceYears: 4,
|
||||
wins: 5,
|
||||
losses: 3,
|
||||
killedCrew: 12_345,
|
||||
lostCrew: 6_789,
|
||||
},
|
||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
itemNames: { horse: '명마', weapon: null, book: null, item: null },
|
||||
},
|
||||
@@ -1184,6 +1193,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
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('.item-group')).toContainText('명마');
|
||||
await expect(page.locator('#container')).not.toContainText('che_');
|
||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||
|
||||
@@ -136,6 +136,9 @@ 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(
|
||||
@@ -437,9 +440,22 @@ onMounted(() => {
|
||||
}})</strong
|
||||
>
|
||||
</div>
|
||||
<div>전투 0 · 계략 0 · 사관 7년</div>
|
||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||
<div>살상률 0% · 사살 0 · 피살 0</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 ?? '-' }} · 내정특기
|
||||
|
||||
@@ -55,6 +55,18 @@ describe('Ref event domestic traits', () => {
|
||||
expect(eventMusang!.getWarPowerMultiplier?.(context, unit, unit)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('applies the persisted victory count to the ordinary 무쌍 battle multiplier', async () => {
|
||||
const musang = await new WarTraitLoader().load('che_무쌍');
|
||||
const unit = {
|
||||
getGeneral: () => ({ meta: { rank_killnum: 40 } }),
|
||||
} as unknown as WarUnit;
|
||||
const context = { unit } as unknown as WarActionContext;
|
||||
|
||||
const multiplier = musang.getWarPowerMultiplier?.(context, unit, unit);
|
||||
expect(multiplier?.[0]).toBeCloseTo(1.2, 12);
|
||||
expect(multiplier?.[1]).toBeCloseTo(0.92, 12);
|
||||
});
|
||||
|
||||
it('keeps event and ordinary 견고 injury-prevention triggers distinct by raise type', async () => {
|
||||
const [eventGyeongo] = await loadEventDomesticTraitModules(['che_event_견고']);
|
||||
const canonical = await new WarTraitLoader().load('che_견고');
|
||||
|
||||
@@ -224,6 +224,63 @@ describe('war triggers', () => {
|
||||
expect(general.atmos).toBeCloseTo(115.5, 12);
|
||||
});
|
||||
|
||||
it('accumulates battle, victory, loss, and casualty records on the persisted rank meta keys', () => {
|
||||
const attacker = buildGeneral(80);
|
||||
attacker.meta = { ...attacker.meta, rank_warnum: 2, rank_killnum: 3, rank_killcrew: 400 };
|
||||
const defender = {
|
||||
...buildGeneral(70),
|
||||
id: 2,
|
||||
name: 'Defender',
|
||||
nationId: 2,
|
||||
meta: { ...buildGeneral(70).meta, rank_warnum: 4, rank_deathnum: 1, rank_deathcrew: 500 },
|
||||
};
|
||||
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
|
||||
const attackerUnit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
attacker,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: attacker.id, nationId: attacker.nationId }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
const defenderUnit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
defender,
|
||||
{ ...buildCity(), nationId: 2 },
|
||||
{ ...buildNation(), id: 2 },
|
||||
false,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: defender.id, nationId: defender.nationId }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
|
||||
attackerUnit.setOppose(defenderUnit);
|
||||
defenderUnit.setOppose(attackerUnit);
|
||||
attackerUnit.increaseKilled(120);
|
||||
defenderUnit.decreaseHP(120);
|
||||
attackerUnit.addWin();
|
||||
defenderUnit.addLose();
|
||||
attackerUnit.finishBattle();
|
||||
defenderUnit.finishBattle();
|
||||
|
||||
expect(attacker.meta).toMatchObject({
|
||||
rank_warnum: 3,
|
||||
rank_killnum: 4,
|
||||
rank_killcrew: 520,
|
||||
rank_killcrew_person: 120,
|
||||
});
|
||||
expect(defender.meta).toMatchObject({
|
||||
rank_warnum: 5,
|
||||
rank_deathnum: 2,
|
||||
rank_deathcrew: 620,
|
||||
rank_deathcrew_person: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the legacy experience level and applies item experience modifiers immediately', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.experience = 90;
|
||||
|
||||
Reference in New Issue
Block a user