diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 4a61ce2c..23f90c3b 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -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), diff --git a/app/game-api/test/dashboardRouter.test.ts b/app/game-api/test/dashboardRouter.test.ts index 38ea547b..399cd547 100644 --- a/app/game-api/test/dashboardRouter.test.ts +++ b/app/game-api/test/dashboardRouter.test.ts @@ -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 }, diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index b83cffb2..55ec58b4 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -85,6 +85,7 @@ const createContext = (options: { troopLeaderAction?: string | null; refreshScore?: number; refreshScoreTotal?: number; + rankRows?: Array<{ type: string; value: number }>; requestId?: string; transaction?: ReturnType; }) => { @@ -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({ diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 3a9515cf..a7e6c5e5 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -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('내 정 보'); diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 4c4cdf5a..11fba12c 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -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(() => { }}) -
전투 0 · 계략 0 · 사관 7년
-
승률 0% · 승리 0 · 패배 0
-
살상률 0% · 사살 0 · 피살 0
+
+ 전투 {{ numberText(data.general.records.battles) }} · 계략 + {{ numberText(data.general.records.strategies) }} · 사관 + {{ numberText(data.general.records.serviceYears) }}년 +
+
+ 승률 {{ percentText(data.general.records.wins, data.general.records.battles) }} · 승리 + {{ numberText(data.general.records.wins) }} · 패배 + {{ numberText(data.general.records.losses) }} +
+
+ 살상률 + {{ percentText(data.general.records.killedCrew, data.general.records.lostCrew) }} · 사살 + {{ numberText(data.general.records.killedCrew) }} · 피살 + {{ numberText(data.general.records.lostCrew) }} +
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기 diff --git a/packages/logic/test/eventDomesticTrait.test.ts b/packages/logic/test/eventDomesticTrait.test.ts index 9525de67..0cce83f3 100644 --- a/packages/logic/test/eventDomesticTrait.test.ts +++ b/packages/logic/test/eventDomesticTrait.test.ts @@ -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_견고'); diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 119cabd5..eccc16d8 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -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;