diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 84f4f50..18a688b 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { RANK_DATA_TYPES } from '@sammo-ts/common'; import type { RedisConnector } from '@sammo-ts/infra'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; @@ -32,7 +33,24 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const generalRows = [ +interface RankingGeneralRow { + id: number; + name: string; + nationId: number; + userId: string | null; + npcState: number; + picture: string | null; + imageServer: number; + meta: Record; + experience: number; + dedication: number; + horseCode: string; + weaponCode: string; + bookCode: string; + itemCode: string; +} + +const generalRows: RankingGeneralRow[] = [ { id: 1, name: '유비', @@ -81,13 +99,16 @@ const generalRows = [ bookCode: 'None', itemCode: 'None', }, -] as const; +]; const buildContext = (options?: { authenticated?: boolean; isUnited?: boolean; includeOwnerDisplayName?: boolean; + generals?: RankingGeneralRow[]; + rankRows?: Array<{ generalId: number; type: string; value: number }>; }): GameApiContext => { + const selectedGeneralRows = options?.generals ?? generalRows; const db = { worldState: { findFirst: async () => ({ @@ -112,21 +133,22 @@ const buildContext = (options?: { }, general: { findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) => - generalRows.filter((general) => + selectedGeneralRows.filter((general) => args.where.npcState.gte !== undefined ? general.npcState >= args.where.npcState.gte : general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY) ), }, rankData: { - findMany: async () => [ - { generalId: 1, type: 'firenum', value: 10 }, - { generalId: 2, type: 'firenum', value: 20 }, - { generalId: 3, type: 'firenum', value: 30 }, - { generalId: 1, type: 'dex1', value: 999 }, - { generalId: 2, type: 'dex1', value: 999 }, - { generalId: 3, type: 'dex1', value: 999 }, - ], + findMany: async () => + options?.rankRows ?? [ + { generalId: 1, type: 'firenum', value: 10 }, + { generalId: 2, type: 'firenum', value: 20 }, + { generalId: 3, type: 'firenum', value: 30 }, + { generalId: 1, type: 'dex1', value: 999 }, + { generalId: 2, type: 'dex1', value: 999 }, + { generalId: 3, type: 'dex1', value: 999 }, + ], }, auction: { findMany: async () => [{ targetCode: 'che_명마_15_적토마' }], @@ -237,6 +259,58 @@ describe('ranking.getBestGeneral', () => { }); }); + it('returns positions one through ten for every populated ranking section', async () => { + const generals = Array.from({ length: 12 }, (_, index) => { + const id = index + 1; + const value = id * 1_000; + return { + id, + name: `상위${id}`, + nationId: 1, + userId: `top-${id}`, + npcState: 0, + picture: null, + imageServer: 0, + meta: { dex1: value, dex2: value, dex3: value, dex4: value, dex5: value }, + experience: value, + dedication: value, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }; + }); + const rankRows = generals.flatMap((general) => + RANK_DATA_TYPES.filter( + (type) => type !== 'experience' && type !== 'dedication' && !type.startsWith('dex') + ).map((type) => ({ + generalId: general.id, + type, + value: + type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person' + ? 1_000 + : type === 'ttd' || type === 'ttl' || type === 'tld' || type === 'tll' || + type === 'tsd' || type === 'tsl' || type === 'tid' || type === 'til' || + type === 'betgold' + ? 1_000 + : general.id * 1_000, + })) + ); + const result = await appRouter + .createCaller(buildContext({ isUnited: true, generals, rankRows })) + .ranking.getBestGeneral({ view: 'user' }); + + expect(result.sections).toHaveLength(26); + for (const section of result.sections) { + expect(section.entries, section.title).toHaveLength(10); + expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(10); + expect(section.entries.every((entry) => entry.value > 0), section.title).toBe(true); + } + expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)).toEqual([ + 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, + ]); + }); + it('matches PHP number_format rounding and the legacy fixed color table', () => { expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01'); expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60'); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 4c89803..420cfa7 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -176,13 +176,14 @@ const runTournamentToCompletion = async (options: { store: TournamentStore; prisma: ReturnType; baseSeed: string; + daemonTransport?: TurnDaemonTransport; }): Promise => { let state = await options.store.getState(); if (!state) { throw new Error('토너먼트 상태가 없습니다.'); } - const daemonTransport = createNoopDaemonTransport(); + const daemonTransport = options.daemonTransport ?? createNoopDaemonTransport(); for (let i = 0; i < 2000; i += 1) { if (state.stage === 0) { @@ -198,7 +199,7 @@ const runTournamentToCompletion = async (options: { continue; } if (state.stage >= 7 && state.stage <= 10) { - state = await applyBattle(options.store, state, options.baseSeed); + state = await applyBattle(options.store, state, options.baseSeed, daemonTransport); continue; } break; @@ -270,6 +271,61 @@ describe('tournament worker (in-memory)', () => { }); }); + it('runs all four tournament types and emits enough rank and NPC-betting commands for a top ten', async () => { + for (const type of [ + TournamentType.TOTAL, + TournamentType.LEADERSHIP, + TournamentType.STRENGTH, + TournamentType.INTEL, + ]) { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys(`ranking-audit-${type}`)); + const participants = createParticipants(16, 16, 32); + await store.setParticipants(participants); + await store.setState(createTournamentState({ stage: 1, type })); + const npcBetting = participants.slice(0, 12).map((entry) => ({ + ...entry, + meta: {}, + npcState: 2, + gold: 10_000, + })); + const commands: TurnDaemonCommand[] = []; + const transport: TurnDaemonTransport = { + sendCommand: async (command) => { + commands.push(command); + return 'ok'; + }, + requestCommand: async () => null, + requestStatus: async () => null, + }; + + await runTournamentToCompletion({ + store, + prisma: createPrismaMock({ baseSeed: `ranking-audit-${type}`, npcBetting, currentYear: 10 }), + baseSeed: `ranking-audit-${type}`, + daemonTransport: transport, + }); + + const matchCommands = commands.filter((command) => command.type === 'tournamentMatchResult'); + const rankedGeneralIds = new Set( + matchCommands.flatMap((command) => + command.type === 'tournamentMatchResult' ? [command.attackerId, command.defenderId] : [] + ) + ); + expect(matchCommands.length).toBeGreaterThan(50); + expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10); + expect(commands).toContainEqual( + expect.objectContaining({ + type: 'adjustGeneralMeta', + reason: 'tournamentNpcBet', + adjustments: expect.arrayContaining([ + expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }), + ]), + }) + ); + } + }); + it('우승 결과에 따라 베팅 정산 명령이 생성된다', async () => { const redis = new MemoryRedis(); const store = new TournamentStore(redis, buildTournamentKeys('test-bet')); diff --git a/app/game-engine/test/bestGeneralRankPersistence.integration.test.ts b/app/game-engine/test/bestGeneralRankPersistence.integration.test.ts new file mode 100644 index 0000000..f60b6e0 --- /dev/null +++ b/app/game-engine/test/bestGeneralRankPersistence.integration.test.ts @@ -0,0 +1,209 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { GeneralMeta } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 992_400; +const nationId = 992_400; +const cityId = 992_400; +const generalIds = Array.from({ length: 12 }, (_, index) => 992_401 + index); + +const makeGeneral = (id: number): TurnGeneral => ({ + id, + name: `랭킹감사${id}`, + nationId, + cityId, + troopId: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + turnTime: new Date('0190-01-01T00:10:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1, + train: 100, + atmos: 100, + age: 30, + npcState: 2, +}); + +integration('best-general rank persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async () => { + await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.general.deleteMany({ where: { id: { in: generalIds } } }); + await db.city.deleteMany({ where: { id: cityId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('flushes every ranking field for twelve active NPCs and keeps the ordered top ten', async () => { + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'best-general-rank-persistence', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + await db.nation.create({ + data: { + id: nationId, + name: '랭킹감사국', + color: '#330000', + level: 1, + }, + }); + await db.city.create({ + data: { + id: cityId, + name: '랭킹감사성', + level: 5, + nationId, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + region: 1, + }, + }); + const initialGenerals = generalIds.map(makeGeneral); + await db.general.createMany({ + data: initialGenerals.map((general) => ({ + id: general.id, + name: general.name, + nationId, + cityId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + turnTime: general.turnTime, + meta: general.meta, + })), + }); + + const state: TurnWorldState = { + id: worldId, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0190-01-01T00:00:00.000Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + generals: initialGenerals, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test', + name: '랭킹 감사 지도', + cities: [], + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'test' }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + for (const [index, generalId] of generalIds.entries()) { + const value = index + 1; + const meta: GeneralMeta = { killturn: 24 }; + for (const type of RANK_DATA_TYPES) { + if (type !== 'experience' && type !== 'dedication') { + meta[rankDataMetaKey(type)] = value; + } + } + world.updateGeneral(generalId, { + experience: value, + dedication: value, + meta, + }); + } + + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: generalIds.length, + processedTurns: generalIds.length, + durationMs: 0, + partial: false, + }); + } finally { + await dbHooks.close(); + } + + const rows = await db.rankData.findMany({ + where: { generalId: { in: generalIds } }, + orderBy: [{ type: 'asc' }, { value: 'desc' }, { generalId: 'asc' }], + }); + expect(rows).toHaveLength(generalIds.length * RANK_DATA_TYPES.length); + for (const type of RANK_DATA_TYPES) { + const topTen = rows.filter((row) => row.type === type).slice(0, 10); + expect(topTen.map((row) => row.value)).toEqual([12, 11, 10, 9, 8, 7, 6, 5, 4, 3]); + expect(topTen.map((row) => row.generalId)).toEqual(generalIds.slice(2).reverse()); + } + + const persistedGenerals = await db.general.findMany({ + where: { id: { in: generalIds } }, + orderBy: { experience: 'desc' }, + select: { id: true, experience: true, dedication: true, meta: true }, + }); + expect(persistedGenerals.slice(0, 10).map((general) => general.id)).toEqual(generalIds.slice(2).reverse()); + }); +}); diff --git a/app/game-engine/test/npcNationUprisingUnification.test.ts b/app/game-engine/test/npcNationUprisingUnification.test.ts index a074456..a443fe4 100644 --- a/app/game-engine/test/npcNationUprisingUnification.test.ts +++ b/app/game-engine/test/npcNationUprisingUnification.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { performance } from 'node:perf_hooks'; +import { RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common'; import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js'; @@ -153,6 +154,7 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => { describe('NPC 건국/통일 장기 시뮬레이션', () => { it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => { const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1'; + const rankingAuditEnabled = process.env.NPC_RANKING_AUDIT === '1'; const profileStartedAtMs = performance.now(); const cities = buildLargeTestCities().map(maxCityStats); for (const city of cities) { @@ -206,7 +208,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { }; const generals: TurnGeneral[] = []; - for (let i = 0; i < 300; i += 1) { + const initialGeneralCount = rankingAuditEnabled ? 150 : 300; + for (let i = 0; i < initialGeneralCount; i += 1) { const cityId = cities[i % cities.length]!.id; const stats = i % 2 === 0 @@ -580,6 +583,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { } if ( + (rankingAuditEnabled && sortieCount >= 50) || world.getState().currentYear > 260 || (world.getState().currentYear === 260 && world.getState().currentMonth >= 1) ) { @@ -595,11 +599,85 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { expect(meta.isUnited).toBe(2); expect(hasUnificationLog).toBe(true); } else { - expect(prevNationCount).toBeLessThan(foundedNationCount); + if (!rankingAuditEnabled) { + expect(prevNationCount).toBeLessThan(foundedNationCount); + } expect(meta.isUnited ?? 0).toBe(0); } expect(sortieCount).toBeGreaterThan(0); + if (rankingAuditEnabled) { + const rankingAudit = RANK_DATA_TYPES.map((type) => { + const entries = world + .listGenerals() + .map((general) => { + const rawValue = + type === 'experience' + ? general.experience + : type === 'dedication' + ? general.dedication + : general.meta[rankDataMetaKey(type)]; + const value = typeof rawValue === 'number' && Number.isFinite(rawValue) ? rawValue : 0; + return { generalId: general.id, name: general.name, value }; + }) + .filter((entry) => entry.value > 0) + .sort((lhs, rhs) => rhs.value - lhs.value || lhs.generalId - rhs.generalId) + .slice(0, 10); + return { type, entries }; + }); + const byType = new Map(rankingAudit.map((entry) => [entry.type, entry.entries])); + expect(byType.get('firenum')).toHaveLength(0); + for (const type of [ + 'experience', + 'dedication', + 'warnum', + 'killnum', + 'deathnum', + 'killcrew', + 'deathcrew', + 'killcrew_person', + 'deathcrew_person', + 'dex1', + 'dex2', + 'dex3', + 'dex4', + 'dex5', + ] as const) { + expect(byType.get(type), type).toHaveLength(10); + } + const reportPath = resolve( + process.env.NPC_RANKING_AUDIT_REPORT_PATH ?? 'test-results/npc-ranking-audit.json' + ); + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync( + reportPath, + `${JSON.stringify( + { + year: world.getState().currentYear, + month: world.getState().currentMonth, + initialGeneralCount, + finalGeneralCount: world.listGenerals().length, + declarationCount, + sortieCount, + rankingAudit, + }, + null, + 2 + )}\n`, + 'utf8' + ); + console.log( + `[NPC_RANKING_AUDIT]${JSON.stringify({ + reportPath, + year: world.getState().currentYear, + month: world.getState().currentMonth, + declarationCount, + sortieCount, + topTenTypes: Array.from(byType.values()).filter((entries) => entries.length === 10).length, + })}` + ); + } + if (memoryProfiler) { expect(typeof globalThis.gc).toBe('function'); expect(unifiedAt).not.toBeNull(); diff --git a/app/game-engine/test/tournamentCommands.test.ts b/app/game-engine/test/tournamentCommands.test.ts index 6e1db68..2369c36 100644 --- a/app/game-engine/test/tournamentCommands.test.ts +++ b/app/game-engine/test/tournamentCommands.test.ts @@ -5,6 +5,7 @@ import type { TurnSchedule } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { buildPersistedRankRows } from '../src/turn/rankData.js'; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; @@ -116,6 +117,46 @@ describe('tournament world commands', () => { expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_betwin'); }); + it('records all four tournament types and NPC betting for at least ten generals', async () => { + const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1)); + const world = buildWorld(generals); + const handler = createTurnDaemonCommandHandler({ world }); + + for (const tournamentType of [0, 1, 2, 3] as const) { + for (const general of generals) { + const result = await handler.handle({ + type: 'tournamentMatchResult', + tournamentType, + attackerId: general.id, + defenderId: (general.id % generals.length) + 1, + result: 'attacker', + }); + expect(result).toMatchObject({ ok: true }); + } + } + await handler.handle({ + type: 'adjustGeneralMeta', + reason: 'tournamentNpcBet', + adjustments: generals.map((general) => ({ + generalId: general.id, + metaDelta: { betgold: 1_000 }, + })), + }); + await handler.handle({ + type: 'tournamentBettingPayout', + bettingId: 1, + payouts: generals.map((general) => ({ generalId: general.id, amount: 2_000 })), + }); + + for (const type of ['ttw', 'tlw', 'tsw', 'tiw', 'betgold', 'betwin', 'betwingold'] as const) { + const positiveRows = world + .listGenerals() + .flatMap(buildPersistedRankRows) + .filter((row) => row.type === type && row.value > 0); + expect(positiveRows, type).toHaveLength(12); + } + }); + it('enforces a command-specific minimum remaining gold atomically', async () => { const world = buildWorld([buildGeneral(1)]); const handler = createTurnDaemonCommandHandler({ world }); diff --git a/packages/logic/test/bestGeneralSabotageAudit.test.ts b/packages/logic/test/bestGeneralSabotageAudit.test.ts new file mode 100644 index 0000000..96bf406 --- /dev/null +++ b/packages/logic/test/bestGeneralSabotageAudit.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import type { City, General, Nation } from '../src/domain/entities.js'; +import { commandSpec as fireSpec } from '../src/actions/turn/general/che_화계.js'; +import type { TurnCommandEnv } from '../src/actions/turn/commandEnv.js'; +import type { WorldSnapshot } from '../src/world/types.js'; +import { MINIMAL_MAP } from './fixtures/minimalMap.js'; +import { InMemoryWorld, TestGameRunner } from './testEnv.js'; + +const commandEnv: TurnCommandEnv = { + develCost: 100, + trainDelta: 35, + atmosDelta: 35, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.1, + sabotageDefenceCoefByGeneralCount: 0.1, + sabotageDamageMin: 10, + sabotageDamageMax: 30, + openingPartYear: 180, + maxGeneral: 10, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultCrewTypeId: 1, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + initialNationGenLimit: 10, + maxTechLevel: 10, + baseGold: 1_000, + baseRice: 1_000, + maxResourceActionAmount: 1_000, +}; + +const makeNation = (id: number): Nation => ({ + id, + name: `계략감사국${id}`, + color: '#330000', + capitalCityId: id, + chiefGeneralId: id, + gold: 10_000, + rice: 10_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, +}); + +const makeCity = (id: number, nationId: number): City => ({ + id, + name: `계략감사성${id}`, + nationId, + level: 1, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 2_000, + agricultureMax: 2_000, + commerce: 2_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 500, + defenceMax: 500, + wall: 500, + wallMax: 500, + supplyState: 1, + frontState: 0, + meta: { trust: 50 }, +}); + +const makeGeneral = (id: number, nationId: number, cityId: number): General => ({ + id, + name: `계략감사장${id}`, + nationId, + cityId, + troopId: 0, + npcState: 0, + experience: 0, + dedication: 0, + officerLevel: 5, + gold: 100_000, + rice: 100_000, + crew: 1_000, + crewTypeId: 1, + train: 100, + atmos: 100, + injury: 0, + age: 30, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, +}); + +describe('best-general sabotage audit', () => { + it('repeats real fire-attack turns until one succeeds and increments firenum', async () => { + const attackerNation = makeNation(1); + const defenderNation = makeNation(2); + const attackerCity = makeCity(1, 1); + const defenderCity = makeCity(2, 2); + const attacker = makeGeneral(1, 1, 1); + const defender = makeGeneral(2, 2, 2); + const snapshot: WorldSnapshot = { + scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' } } as never, + scenarioMeta: { startYear: 180 } as never, + map: MINIMAL_MAP, + unitSet: { id: 'default', name: 'default', crewTypes: [] }, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity], + generals: [attacker, defender], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryWorld(snapshot); + const runner = new TestGameRunner(world, 180, 1, 'best-general-sabotage-audit-2'); + const fire = fireSpec.createDefinition(commandEnv); + let attempts = 0; + + while ((world.getGeneral(attacker.id)?.meta.firenum ?? 0) === 0 && attempts < 20) { + attempts += 1; + await runner.runTurn([ + { + generalId: attacker.id, + commandKey: 'che_화계', + resolver: fire, + args: { destCityId: defenderCity.id }, + context: { + destCity: world.getCity(defenderCity.id), + destNation: defenderNation, + destGenerals: [world.getGeneral(defender.id)], + distance: 1, + env: commandEnv, + map: MINIMAL_MAP, + }, + }, + ]); + } + + expect(attempts).toBe(3); + expect(world.getGeneral(attacker.id)?.meta.firenum).toBe(1); + }); +});