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/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 955ceb2e..9eb5742f 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -72,6 +72,11 @@ const parseBuffRecord = (raw: unknown): Record => { const serializeBuffRecord = (buff: Record): string => JSON.stringify(buff); +const readStringList = (raw: unknown): string[] => { + const parsed = typeof raw === 'string' ? parseJson(raw) : raw; + return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : []; +}; + const readBuffLevel = (buff: Record, key: InheritBuffType): number => { const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0))); @@ -133,7 +138,7 @@ const patchGeneral = async ( strength?: number; intelligence?: number; }; - specialWar?: string; + specialWar?: string | null; } ): Promise => { const result = await ctx.turnDaemon.requestCommand({ @@ -530,16 +535,15 @@ export const inheritRouter = router({ } const meta = asRecord(general.meta); - const prevList = - parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; + const prevList = readStringList(meta.prev_types_special2); prevList.push(general.special2Code); await patchGeneral(ctx, general.id, { - specialWar: 'None', + specialWar: null, meta: { ...meta, inheritResetSpecialWar: nextLevel, - prev_types_special2: JSON.stringify(prevList), + prev_types_special2: prevList, }, }); 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-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 72d28db3..5860eec8 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -331,6 +331,90 @@ describe('inherit router actor and permission boundaries', () => { ); }); + it('reserves the selected Ref war trait and charges the authenticated owner once', async () => { + const fixture = buildContext({ + inheritancePoint: 5_000, + configConst: { availableSpecialWar: ['che_의술'] }, + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' }) + ).resolves.toEqual({ ok: true }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'patchGeneral', + generalId: 7, + patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } }, + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: '4000 포인트로 다음 전투 특기로 의술 지정', + }, + }); + }); + + it('does not dispatch or charge when a different war trait is already reserved', async () => { + const fixture = buildContext({ + inheritancePoint: 5_000, + general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }), + configConst: { availableSpecialWar: ['che_의술'] }, + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); + }); + + it('resets the current war trait to the in-memory null sentinel and preserves Ref history as an array', async () => { + const fixture = buildContext({ + inheritancePoint: 2_000, + general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }), + }); + + await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'patchGeneral', + generalId: 7, + patch: { + specialWar: null, + meta: { + prev_types_special2: ['che_돌격', 'che_선봉'], + marker: 3, + inheritResetSpecialWar: 0, + }, + }, + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: '1000 포인트로 전투 특기 초기화', + }, + }); + }); + + it('does not dispatch or charge when the current war trait is already blank', async () => { + const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) }); + + await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '이미 전투 특기가 공란입니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); + }); + it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => { const fixture = buildContext({ inheritancePoint: 2_000, diff --git a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts index a451faa0..084b3468 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts @@ -69,7 +69,31 @@ export const do징병 = (ai: GeneralAI) => { } const generalMeta = asRecord(ai.general.meta); - const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership); + const recruitContext = { + general: ai.general, + nation, + ...(ai.worldRef + ? { + worldView: { + listGenerals: () => ai.worldRef!.listGenerals(), + listGeneralsByCity: (cityId: number) => + ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => ai.worldRef!.listNations(), + }, + } + : {}), + time: { + year: ai.world.currentYear, + month: ai.world.currentMonth, + startYear: ai.startYear, + }, + }; + const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv); + // The cached AI stat follows the scenario/global classification cap, while + // che_징병 resolves the actual command capacity from the general's current + // stat and modules. NPC recruitment must request that same uncapped amount; + // otherwise a 300-leadership general can be stuck at a 100/140/255 cap. + const fullLeadership = recruitment.resolveFullLeadership(recruitContext); trace('population-policy', { population: city.population, populationMax: city.populationMax, @@ -175,26 +199,6 @@ export const do징병 = (ai: GeneralAI) => { // whether to halve the requested crew. In particular, that command caps // the charge at the actually refillable amount when the selected type is // already equipped, then applies traits/items and legacy rounding. - const recruitContext = { - general: ai.general, - nation, - ...(ai.worldRef - ? { - worldView: { - listGenerals: () => ai.worldRef!.listGenerals(), - listGeneralsByCity: (cityId: number) => - ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), - listNations: () => ai.worldRef!.listNations(), - }, - } - : {}), - time: { - year: ai.world.currentYear, - month: ai.world.currentMonth, - startYear: ai.startYear, - }, - }; - const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv); const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold; const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)); const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 2d218bb6..51ea335d 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -257,7 +257,7 @@ const zPatchGeneral = z.object({ intelligence: zFiniteNumber.optional(), }) .optional(), - specialWar: z.string().optional(), + specialWar: z.string().nullable().optional(), }), }); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 54e24035..e712f08d 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -734,10 +734,10 @@ async function handlePatchGeneral( ...command.patch.stats, }; } - if (typeof command.patch.specialWar === 'string') { + if (command.patch.specialWar !== undefined) { patch.role = { ...general.role, - specialWar: command.patch.specialWar, + specialWar: command.patch.specialWar === 'None' ? null : command.patch.specialWar, }; } diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 82b76ed2..89c00d34 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -955,6 +955,57 @@ describe('legacy NPC AI final-decision parity', () => { }); }); + it.each([ + [101, 100, 10_100], + [140, 100, 14_000], + [256, 255, 25_600], + [300, 140, 30_000], + [300, 255, 30_000], + ])( + 'requests the command-resolved full crew above the cached AI cap (leadership=%i, cached=%i)', + (leadership, cachedLeadership, amount) => { + const ai = makeAi({ + dipState: 2, + city: { population: 100_000, populationMax: 100_000 }, + general: { + stats: { leadership, strength: 70, intelligence: 70 }, + gold: 100_000, + rice: 100_000, + meta: { killturn: 100, fullLeadership: cachedLeadership, rank_killcrew: 0, rank_deathcrew: 1 }, + }, + rng: makeRng([], [0, 0]), + }); + + expect(do징병(ai)).toMatchObject({ + action: 'che_징병', + args: { crewType: 1, amount }, + }); + } + ); + + it('uses recruitment stat modules when resolving uncapped NPC crew capacity', () => { + const ai = makeAi({ + dipState: 2, + city: { population: 100_000, populationMax: 100_000 }, + general: { + stats: { leadership: 240, strength: 70, intelligence: 70 }, + gold: 100_000, + rice: 100_000, + meta: { killturn: 100, fullLeadership: 100, rank_killcrew: 0, rank_deathcrew: 1 }, + }, + generalActionModules: singleActionModuleStack({ + eventHandlers: {}, + onCalcStat: (_context, statName, value) => (statName === 'leadership' ? value * 1.25 : value), + }), + rng: makeRng([], [0, 0]), + }); + + expect(do징병(ai)).toMatchObject({ + action: 'che_징병', + args: { crewType: 1, amount: 30_000 }, + }); + }); + it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => { const ai = makeAi({ dipState: 2, diff --git a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts index 4d0c9e5f..d7b1a786 100644 --- a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts +++ b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest'; import { LogCategory, LogFormat } from '@sammo-ts/logic'; +import type { TurnDaemonCommand } from '@sammo-ts/common'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler, } from '../src/turn/monthlySpecialityBetrayAction.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const event: TurnEvent = { @@ -177,6 +180,84 @@ describe('monthly speciality and betrayal actions', () => { ]); }); + it.each([ + ['고정 후 초기화', ['reserve', 'reset']], + ['초기화 후 고정', ['reset', 'reserve']], + ] as const)('%s 순서에서도 다음 월에 지정한 전투 특기를 지급한다', async (_label, steps) => { + const world = buildWorld(); + const initial = world.getGeneralById(3)!; + const initialMeta = { ...initial.meta }; + delete initialMeta.inheritSpecificSpecialWar; + world.updateGeneral(3, { + role: { ...initial.role, specialWar: 'che_신산' }, + meta: initialMeta, + }); + world.acknowledgeDirtyState(world.peekDirtyState()); + + const commandHandler = createTurnDaemonCommandHandler({ world }); + let requestIndex = 0; + const dispatchPatch = async (patch: Extract['patch']) => { + requestIndex += 1; + const command = normalizeTurnDaemonCommand({ + requestId: `inherit-war-trait-${requestIndex}`, + sentAt: '2026-08-21T00:00:00.000Z', + command: { type: 'patchGeneral', generalId: 3, patch }, + }); + expect(command).not.toBeNull(); + await expect(commandHandler.handle(command!)).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); + }; + + for (const step of steps) { + const current = world.getGeneralById(3)!; + if (step === 'reserve') { + await dispatchPatch({ + meta: { ...current.meta, inheritSpecificSpecialWar: 'che_의술' }, + }); + } else { + await dispatchPatch({ + specialWar: null, + meta: { + ...current.meta, + inheritResetSpecialWar: 0, + prev_types_special2: ['che_신산'], + }, + }); + } + } + + expect(world.getGeneralById(3)?.role.specialWar).toBeNull(); + expect(world.getGeneralById(3)?.meta).toMatchObject({ + inheritSpecificSpecialWar: 'che_의술', + prev_types_special2: ['che_신산'], + }); + + await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], environment, event); + + expect(world.getGeneralById(3)?.role.specialWar).toBe('che_의술'); + expect(world.getGeneralById(3)?.meta).not.toHaveProperty('inheritSpecificSpecialWar'); + expect(world.getGeneralById(3)?.meta.prev_types_special2).toEqual(['che_신산']); + expect( + world + .peekDirtyState() + .logs.filter((log) => log.generalId === 3) + .map((log) => log.text) + ).toEqual(['특기 【의술】을 습득', '특기 【의술】을 익혔습니다!']); + }); + + it('normalizes the legacy None sentinel before monthly eligibility checks', async () => { + const world = buildWorld(); + const target = world.getGeneralById(3)!; + world.updateGeneral(3, { role: { ...target.role, specialWar: 'che_신산' } }); + world.acknowledgeDirtyState(world.peekDirtyState()); + const commandHandler = createTurnDaemonCommandHandler({ world }); + + await expect( + commandHandler.handle({ type: 'patchGeneral', generalId: 3, patch: { specialWar: 'None' } }) + ).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); + + expect(world.getGeneralById(3)?.role.specialWar).toBeNull(); + }); + it('does nothing before the three-year opening period ends', async () => { const world = buildWorld(); await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event); diff --git a/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts b/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts index b16990a6..df5c3269 100644 --- a/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts +++ b/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts @@ -9,6 +9,7 @@ import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler, } from '../src/turn/monthlySpecialityBetrayAction.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; @@ -105,7 +106,7 @@ integration('monthly speciality and betrayal persistence', () => { }), buildGeneral(generalIds[1], { domestic: 'che_경작', - war: null, + war: 'che_신산', meta: { specage: 99, specage2: 30, @@ -198,6 +199,22 @@ integration('monthly speciality and betrayal persistence', () => { const hooks = await createDatabaseTurnHooks(databaseUrl!, world); try { + const reservedGeneral = world.getGeneralById(generalIds[1])!; + const commandHandler = createTurnDaemonCommandHandler({ world }); + await expect( + commandHandler.handle({ + type: 'patchGeneral', + generalId: reservedGeneral.id, + patch: { + specialWar: null, + meta: { + ...reservedGeneral.meta, + inheritResetSpecialWar: 0, + prev_types_special2: ['che_신산'], + }, + }, + }) + ).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); await hooks.hooks.flushChanges?.({ lastTurnTime: state.lastTurnTime.toISOString(), @@ -214,7 +231,12 @@ integration('monthly speciality and betrayal persistence', () => { expect(rows[0]?.specialCode).not.toBe('None'); expect(rows[0]?.meta).toMatchObject({ betray: 2 }); expect(rows[1]).toMatchObject({ special2Code: 'che_의술' }); - expect(rows[1]?.meta).toMatchObject({ betray: 3, marker: 2 }); + expect(rows[1]?.meta).toMatchObject({ + betray: 3, + marker: 2, + inheritResetSpecialWar: 0, + prev_types_special2: ['che_신산'], + }); expect(rows[1]?.meta).not.toHaveProperty('inheritSpecificSpecialWar'); expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4); } finally { diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index a9580002..d9ae12af 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -2291,6 +2291,7 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => { const requests = await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('/'); const editor = page.locator('[data-command-scope="general"]'); @@ -2318,6 +2319,41 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await picker.getByRole('button', { name: '입력', exact: true }).click(); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행'); + const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) }); + await recentMenu.locator('summary').click(); + const recentBriefButton = recentMenu.getByRole('button', { name: '【허창】에 화계실행', exact: true }); + await expect(recentBriefButton).toBeVisible(); + await recentBriefButton.hover(); + await page.screenshot({ + path: test.info().outputPath('advanced-recent-command-brief-desktop-1200.png'), + fullPage: true, + }); + await recentMenu.locator('summary').click(); + await page.setViewportSize({ width: 500, height: 900 }); + await recentMenu.locator('summary').click(); + await expect(recentBriefButton).toBeVisible(); + await recentBriefButton.focus(); + await expect(recentBriefButton).toBeFocused(); + const mobileRecentGeometry = await editor.evaluate((element) => { + const menu = element.querySelector('details[open] .menu-items'); + const recentButton = menu?.querySelector('button'); + if (!menu || !recentButton) throw new Error('advanced recent command menu is missing'); + return { + horizontalOverflow: element.scrollWidth - element.clientWidth, + menuRight: menu.getBoundingClientRect().right, + buttonRight: recentButton.getBoundingClientRect().right, + }; + }); + expect(mobileRecentGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + expect(mobileRecentGeometry.menuRight).toBeLessThanOrEqual(500); + expect(mobileRecentGeometry.buttonRight).toBeLessThanOrEqual(500); + await page.screenshot({ + path: test.info().outputPath('advanced-recent-command-brief-mobile-500.png'), + fullPage: true, + }); + await recentMenu.locator('summary').click(); + await page.setViewportSize({ width: 1200, height: 900 }); + await drag(0, 2); await editor.locator('details.selected-menu > summary').click(); await editor.getByRole('button', { name: '복사하기', exact: true }).click(); 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/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 10ac3c95..d46f5791 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -142,11 +142,12 @@ const isRecruitmentCommand = computed( () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' ); const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); -const rowLabel = (row: ReservedCommandRow): string => - formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) || - row.label || - labelMap.value.get(row.action) || - row.action; +const commandBrief = (entry: { action: string; args: unknown; label?: string }): string => + formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) || + entry.label || + labelMap.value.get(entry.action) || + entry.action; +const rowLabel = (row: ReservedCommandRow): string => commandBrief(row); const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const pattern = () => extractPattern(props.rows, selectedIndices()); const touchMenus = () => (menuRevision.value += 1); @@ -467,7 +468,7 @@ const clickOutsideMenu = (event: Event) => { clickOutsideMenu($event); " > - {{ entry.label ?? labelMap.get(entry.action) ?? entry.action }} + {{ commandBrief(entry) }} 비어 있음 diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 1c576894..049ef965 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -796,12 +796,12 @@ onMounted(() => { width: min(100%, 1000px); margin: 0 auto; border: 1px solid #888; - overflow: hidden; + overflow-x: hidden; box-sizing: border-box; position: relative; padding: 0 7px; color: #fff; - height: 1597px; + min-height: 1597px; font: 14px/21px var(--sammo-font-sans); } @@ -1017,7 +1017,7 @@ a:not(.legacy-button):focus-visible { } .inherit-page { - height: 3047.5px; + min-height: 3047.5px; } .shop-item .buy-button { 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/docs/e2e-caddy-routing.md b/docs/e2e-caddy-routing.md index e30e4b92..8d3b41f7 100644 --- a/docs/e2e-caddy-routing.md +++ b/docs/e2e-caddy-routing.md @@ -1,11 +1,20 @@ -# Caddy prefix 계약 +# Core2026 환경별 Caddy prefix 계약 ## 환경과 ingress -| 환경 | 공개 주소 | 연결 계약 | -| ---- | --------- | --------- | -| 공개 | `dev-sam2026.hided.net` | 실제 외부 Core2026 서비스입니다. 로컬 Docker `14999`의 주소가 아닙니다. | -| E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → `172.30.1.54:14999` HTTP → Docker Caddy입니다. | +| 환경 | 공개 주소·prefix | 접속·연결 계약 | +| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Ref | `dev-sam-ref.hided.net` | 개발 호스트 `172.30.1.54:3400`의 PHP 기준 구현입니다. | +| 로컬 E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → 개발 호스트 `172.30.1.54:14999` HTTP → Docker Caddy입니다. | +| 공개 개발 | `dev-sam2026.hided.net` | `ssh serv`의 `core2026-dev-sam2026`/`hidche_ng_my`입니다. 로컬 E2E `14999`와 다른 호스트입니다. | +| sam Core 운영 | `sam.hided.net/gateway/`와 일곱 profile prefix | `ssh serv`의 `core2026-sam-production`/`hidche_core2026_my`입니다. | +| sam PHP 운영 | `sam.hided.net/sam/`과 기존 PHP 경로 | `ssh serv`의 별도 `sam_hided_net` project입니다. | + +`dev-sam2026.hided.net`과 `sam.hided.net` Core prefix는 같은 Git 구현을 사용할 수 +있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된 +배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로 +사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는 +`docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다. 외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래 `Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는 @@ -26,7 +35,7 @@ HTTP_PORT=14999 `https://dev-sam-e2e.hided.net/gateway/oauth/callback`을 파생합니다. 도메인을 바꾼 뒤에는 Caddy뿐 아니라 runtime도 재생성하여 process 환경을 갱신합니다. -## 활성 경로 +## 로컬 E2E 활성 경로 | 서비스 | 공개 prefix | frontend | API | | ------- | ----------- | -------: | ------: | @@ -36,8 +45,9 @@ HTTP_PORT=14999 표의 port는 Docker 내부 Caddy가 연결하는 frontend/API listener입니다. 외부 Caddy가 이 port들에 직접 연결하지 않습니다. `kwe`, `pwe`, `twe`, `nya`, -`pya`는 resource·profile 이름으로 사용할 수 있지만 활성 Caddy route가 -아닙니다. +`pya`는 로컬 E2E에서 resource·profile 이름으로 사용할 수 있지만 활성 Caddy +route가 아닙니다. `sam.hided.net`의 별도 운영 Core stack에는 이 다섯 profile도 +활성 prefix이므로 환경별 계약을 섞지 않습니다. Caddy는 prefix를 보존해 upstream에 전달합니다. 앱은 root 배포를 가정하지 않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를 diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index b97c1bc5..6b530827 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -86,7 +86,7 @@ storage, route guards, and image loading. | best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error | | hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error | | yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | -| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error | +| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling | | nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | | public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | | survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index dcfdb6e4..045eb4ba 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -230,7 +230,7 @@ export type TurnDaemonCommand = strength?: number; intelligence?: number; }; - specialWar?: string; + specialWar?: string | null; }; } | { 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; diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 7b50b62a..8f3a121f 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -4,7 +4,7 @@ import { dirname, extname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const imageRoot = resolve(repositoryRoot, '../../image'); +const imageRoot = process.env.FRONTEND_PARITY_IMAGE_ROOT ?? resolve(repositoryRoot, '../../image'); const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`; @@ -117,9 +117,21 @@ const statusFixture = { currentStat: { leadership: 70, strength: 45, intel: 85 }, }; -const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { +interface InheritanceLogFixture { + id: number; + year: number; + month: number; + text: string; + createdAt: string; +} + +const installFixture = async ( + page: Page, + options: { failBuff?: boolean; logPages?: InheritanceLogFixture[][] } = {} +) => { let buffMutationCount = 0; let resetTurnMutationCount = 0; + let logRequestCount = 0; const uniqueAuctionRequests: unknown[] = []; await installImages(page); await page.addInitScript(() => { @@ -148,7 +160,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) }); } if (name === 'inherit.getLogs') { - return response([ + const defaultPage = [ { id: 2, year: 200, @@ -156,7 +168,11 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) text: '1000 포인트로 장수 소유자 확인', createdAt: '2026-07-26T00:00:00.000Z', }, - ]); + ]; + const pages = options.logPages ?? [defaultPage]; + const pageIndex = Math.min(logRequestCount, pages.length - 1); + logRequestCount += 1; + return response(pages[pageIndex] ?? []); } if (name === 'join.getConfig') { return response({ rules: { stat: { total: 200, min: 10, max: 100 } } }); @@ -184,6 +200,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) return { buffMutationCount: () => buffMutationCount, resetTurnMutationCount: () => resetTurnMutationCount, + logRequestCount: () => logRequestCount, uniqueAuctionRequests, }; }; @@ -333,6 +350,52 @@ test.describe('inheritance management legacy parity', () => { await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000'); }); + test('keeps every paged inheritance log reachable by document scrolling', async ({ page }) => { + const buildPage = (firstId: number, count: number): InheritanceLogFixture[] => + Array.from({ length: count }, (_, index) => { + const id = firstId - index; + return { + id, + year: 200, + month: 4, + text: `유산 포인트 변경 내역 ${id}`, + createdAt: `2026-07-${String((id % 27) + 1).padStart(2, '0')}T00:00:00.000Z`, + }; + }); + const fixture = await installFixture(page, { + logPages: [buildPage(60, 30), buildPage(30, 30), []], + }); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto(gameUrl); + await expect(page.locator('.log-row')).toHaveCount(30); + + const firstHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0); + const moreButton = page.getByRole('button', { name: '더 가져오기' }); + await moreButton.click(); + await expect(page.locator('.log-row')).toHaveCount(60); + await expect(page.locator('.log-row').last()).toContainText('유산 포인트 변경 내역 1'); + const expandedHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0); + expect(expandedHeight).toBeGreaterThan(firstHeight); + + await page.evaluate(() => window.scrollTo(0, document.scrollingElement?.scrollHeight ?? 0)); + await expect(page.locator('.log-row').last()).toBeInViewport(); + await expect(moreButton).toBeInViewport(); + expect( + await page.evaluate(() => + Math.abs( + window.scrollY + window.innerHeight - (document.scrollingElement?.scrollHeight ?? window.innerHeight) + ) + ) + ).toBeLessThanOrEqual(1); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-mobile-60-logs.png'), fullPage: true }); + } + + await moreButton.click(); + await expect.poll(fixture.logRequestCount).toBe(3); + await expect(moreButton).toBeDisabled(); + }); + test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => { const fixture = await installFixture(page); await page.goto(gameUrl);