diff --git a/app/game-api/src/router/playAudit/decisions.ts b/app/game-api/src/router/playAudit/decisions.ts index 7c335d63..ab3a1251 100644 --- a/app/game-api/src/router/playAudit/decisions.ts +++ b/app/game-api/src/router/playAudit/decisions.ts @@ -3,6 +3,39 @@ import { z } from 'zod'; import type { GamePrisma } from '@sammo-ts/infra'; import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; +const zEffectivePolicy = z.object({ + schemaVersion: z.literal(1), + general: z.object({ priority: z.array(z.string()), flags: z.record(z.string(), z.boolean()) }), + nation: z.object({ + priority: z.array(z.string()), + flags: z.record(z.string(), z.boolean()), + values: z.object({ + reqNationGold: z.number(), + reqNationRice: z.number(), + reqHumanWarUrgentGold: z.number(), + reqHumanWarUrgentRice: z.number(), + reqHumanWarRecommandGold: z.number(), + reqHumanWarRecommandRice: z.number(), + reqHumanDevelGold: z.number(), + reqHumanDevelRice: z.number(), + reqNpcWarGold: z.number(), + reqNpcWarRice: z.number(), + reqNpcDevelGold: z.number(), + reqNpcDevelRice: z.number(), + minimumResourceActionAmount: z.number(), + maximumResourceActionAmount: z.number(), + minNpcWarLeadership: z.number(), + minWarCrew: z.number(), + minNpcRecruitCityPopulation: z.number(), + safeRecruitCityPopulationRatio: z.number(), + properWarTrainAtmos: z.number(), + cureThreshold: z.number(), + }), + combatForce: z.record(z.string(), z.array(z.number()).length(2)), + supportForce: z.array(z.number()), + developForce: z.array(z.number()), + }), +}); const zId = z.string().regex(/^[a-f0-9]{64}$/); const zTick = z .string() @@ -71,7 +104,11 @@ const zStep = z.intersection( ) .max(5), }), - z.object({ kind: z.literal('DECISION_START'), reservedAction: z.string() }), + z.object({ + kind: z.literal('DECISION_START'), + reservedAction: z.string(), + effectivePolicy: zEffectivePolicy.optional(), + }), z.object({ kind: z.literal('DECISION_END'), action: z.string().nullable(), reason: z.string().nullable() }), z.object({ kind: z.literal('DECISION_ERROR') }), z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }), diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 7a5b3fb5..de589379 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2317,7 +2317,9 @@ integration('game API security over HTTP transport', () => { { decisionId: decisionIds[0]!, ordinal: 0, - steps: Array.from({ length: 128 }, (_, sequence) => ({ ...step, sequence })), + steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127 + ? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} } + : ({ ...step, sequence })), }, { decisionId: decisionIds[0]!, @@ -2398,6 +2400,8 @@ integration('game API security over HTTP transport', () => { }, }); expect(JSON.stringify(decisionPage.body)).not.toContain('decision-secret'); + expect(JSON.stringify(decisionPage.body)).toContain('effectivePolicy'); + expect(JSON.stringify(decisionPage.body)).toContain('4321'); expect((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({ result: { data: { diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index dc2149c5..49fc34cc 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -1,3 +1,4 @@ +import { snapshotEffectiveAiPolicy } from './effectivePolicy.js'; import { observeAiRng, type AiDecisionTraceObserver, type AiTraceStep } from './trace.js'; import type { City, @@ -216,7 +217,8 @@ export class GeneralAI { ): AiCommandCandidate | null { if (!this.onDecisionTrace) return choose(); this.tracePhase = phase; - this.trace({ kind: 'DECISION_START', reservedAction: reserved.action }); + this.trace({ kind: 'DECISION_START', reservedAction: reserved.action, + effectivePolicy: snapshotEffectiveAiPolicy(this.generalPolicy, this.nationPolicy) }); try { const result = choose(); this.trace({ kind: 'DECISION_END', action: result?.action ?? null, reason: result?.reason ?? null }); diff --git a/app/game-engine/src/turn/ai/generalAi/effectivePolicy.ts b/app/game-engine/src/turn/ai/generalAi/effectivePolicy.ts new file mode 100644 index 00000000..09ac7026 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/effectivePolicy.ts @@ -0,0 +1,37 @@ +import type { AutorunGeneralPolicy, AutorunNationPolicy } from '../policies.js'; + +/** 이미 합성된 정책만 복사한다. can() 재평가, world/meta 복사나 RNG 호출은 하지 않는다. */ +export const snapshotEffectiveAiPolicy = (general: AutorunGeneralPolicy, nation: AutorunNationPolicy) => ({ + schemaVersion: 1 as const, + general: { priority: [...general.priority], flags: { ...general.flags } }, + nation: { + priority: [...nation.priority], + flags: { ...nation.flags }, + values: { + reqNationGold: nation.reqNationGold, + reqNationRice: nation.reqNationRice, + reqHumanWarUrgentGold: nation.reqHumanWarUrgentGold, + reqHumanWarUrgentRice: nation.reqHumanWarUrgentRice, + reqHumanWarRecommandGold: nation.reqHumanWarRecommandGold, + reqHumanWarRecommandRice: nation.reqHumanWarRecommandRice, + reqHumanDevelGold: nation.reqHumanDevelGold, + reqHumanDevelRice: nation.reqHumanDevelRice, + reqNpcWarGold: nation.reqNpcWarGold, + reqNpcWarRice: nation.reqNpcWarRice, + reqNpcDevelGold: nation.reqNpcDevelGold, + reqNpcDevelRice: nation.reqNpcDevelRice, + minimumResourceActionAmount: nation.minimumResourceActionAmount, + maximumResourceActionAmount: nation.maximumResourceActionAmount, + minNpcWarLeadership: nation.minNpcWarLeadership, + minWarCrew: nation.minWarCrew, + minNpcRecruitCityPopulation: nation.minNpcRecruitCityPopulation, + safeRecruitCityPopulationRatio: nation.safeRecruitCityPopulationRatio, + properWarTrainAtmos: nation.properWarTrainAtmos, + cureThreshold: nation.cureThreshold, + }, + combatForce: Object.fromEntries(Object.entries(nation.combatForce).map(([id, cities]) => [id, [...cities]])), + supportForce: [...nation.supportForce], + developForce: [...nation.developForce], + }, +}); +export type EffectiveAiPolicy = ReturnType; diff --git a/app/game-engine/src/turn/ai/generalAi/trace.ts b/app/game-engine/src/turn/ai/generalAi/trace.ts index 3d3eb492..406cb9a8 100644 --- a/app/game-engine/src/turn/ai/generalAi/trace.ts +++ b/app/game-engine/src/turn/ai/generalAi/trace.ts @@ -1,3 +1,4 @@ +import type { EffectiveAiPolicy } from './effectivePolicy.js'; import type { RandUtil } from '@sammo-ts/common'; /** 원문 meta/seed/임의 객체를 받지 않는 관측 계약. 내부 후보 조건은 별도 계측으로 확장한다. */ @@ -22,7 +23,7 @@ export type AiExecutionAttempt = { }; export type AiTraceStep = | AiExecutionAttempt - | { kind: 'DECISION_START'; reservedAction: string } + | { kind: 'DECISION_START'; reservedAction: string; effectivePolicy?: EffectiveAiPolicy } | { kind: 'DECISION_END'; action: string | null; reason: string | null } | { kind: 'DECISION_ERROR' } | { kind: 'PROCEDURE_START'; procedure: string } diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index a6103ede..4091c327 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -366,10 +366,12 @@ const makeAi = ( }, }, generalPolicy: { + priority: [], flags: {}, can: (action: string) => !disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action), }, nationPolicy: { + priority: [], flags: {}, combatForce: {}, supportForce: [], developForce: [], minWarCrew: 1500, minNpcRecruitCityPopulation: 30_000, safeRecruitCityPopulationRatio: 0.5, @@ -2202,7 +2204,7 @@ describe('AI decision observation boundaries', () => { onDecisionTrace: (event: AiDecisionTraceEvent) => events.push(event), traceSequence: 0, updateInstance: () => undefined, categorizeNationCities: () => undefined, categorizeNationGeneral: () => undefined, - nationPolicy: { priority: ['disabled', 'unregistered'], can: (name: string) => { + nationPolicy: { ...ai.nationPolicy, priority: ['disabled', 'unregistered'], can: (name: string) => { calls.push(name); return name !== 'disabled'; } }, }); diff --git a/app/game-engine/test/npcNationWarDeclaration.test.ts b/app/game-engine/test/npcNationWarDeclaration.test.ts index e416070a..3fde463e 100644 --- a/app/game-engine/test/npcNationWarDeclaration.test.ts +++ b/app/game-engine/test/npcNationWarDeclaration.test.ts @@ -455,6 +455,13 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => { expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'EXECUTION_ATTEMPT')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'DECISION_START' && step.phase === 'nation')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'DECISION_END' && step.phase === 'general')).toBe(true); + const start = decisionTrace.find((step) => step.kind === 'DECISION_START'); + expect(start?.kind === 'DECISION_START' && start.effectivePolicy?.schemaVersion).toBe(1); + if (start?.kind === 'DECISION_START') { + expect(start.effectivePolicy?.nation.values.minimumResourceActionAmount).toBeGreaterThan(0); + expect(start.effectivePolicy?.general.priority.length).toBeGreaterThan(0); + } + expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'PROCEDURE_START')).toBe(true); expect(decisionTrace.some((step) => step.kind === 'CANDIDATE')).toBe(true); diff --git a/app/game-engine/test/npcPolicyLifecycle.test.ts b/app/game-engine/test/npcPolicyLifecycle.test.ts index 7b2c1edb..1fcf409f 100644 --- a/app/game-engine/test/npcPolicyLifecycle.test.ts +++ b/app/game-engine/test/npcPolicyLifecycle.test.ts @@ -1,3 +1,4 @@ +import { snapshotEffectiveAiPolicy } from '../src/turn/ai/generalAi/effectivePolicy.js'; import { initializeAuditPolicies } from '../src/playAudit/policy.js'; import { describe, expect, it } from 'vitest'; @@ -5,7 +6,7 @@ import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/ import { asRecord } from '@sammo-ts/common'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { AutorunNationPolicy } from '../src/turn/ai/policies.js'; +import { AutorunGeneralPolicy, AutorunNationPolicy } from '../src/turn/ai/policies.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js'; @@ -256,6 +257,15 @@ describe('NPC policy lifecycle', () => { scenarioConfig: snapshot.scenarioConfig, unitSet, }); + const generalPolicy = new AutorunGeneralPolicy(world.getGeneralById(1)!, null, null, null); + const captured = snapshotEffectiveAiPolicy(generalPolicy, policy); + expect(captured.nation.values.reqNationGold).toBe(4_321); + expect(captured.nation.values.reqNpcDevelGold).toBe(540); + expect(captured.nation.priority).toEqual(['천도']); + policy.supportForce.push(123); + policy.flags['천도'] = false; + expect(captured.nation.supportForce).toEqual([]); + expect(captured.nation.flags['천도']).toBe(true); expect(policy.reqNationGold).toBe(4_321); expect(policy.priority).toEqual(['천도']); expect(policy.reqNpcDevelGold).toBe(540); diff --git a/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts index f267efca..81d93e2a 100644 --- a/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts +++ b/app/game-engine/test/playAuditDecisionPersistence.integration.test.ts @@ -36,6 +36,7 @@ integration('decision persistence and bounded retention', () => { }); it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => { const decision = draft('decision-one'); + if (decision.steps[0]?.kind === 'DECISION_START') decision.steps[0].effectivePolicy = {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5]}}; decision.summary.codeVersion = 'a'.repeat(40); decision.summary.executionCoverage = 'ATTEMPTS'; decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 }); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index e52ce5db..4adaeebb 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -156,6 +156,7 @@ const install = async ( { ordinal: input.cursor === undefined ? 0 : 1, steps: [ + ...(input.cursor === undefined ? [{ phase: 'general', generalId: 1, nationId: 2, cityId: 3, npcState: 2, year: 190, month: 6, tick: 100, sequence: 0, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5]}} }] : []), { phase: 'general', generalId: 1, @@ -165,7 +166,7 @@ const install = async ( year: 190, month: 6, tick: 100, - sequence: input.cursor === undefined ? 0 : 128, + sequence: input.cursor === undefined ? 1 : 128, ...(input.cursor === undefined ? { kind: 'PROCEDURE_START', procedure: '징병판정' } : { @@ -1156,6 +1157,9 @@ test('NPC decisions are explicit, paginated, independently addressable and escap await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click(); await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정'); await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0); + await page.getByText('당시 합성 정책', { exact: true }).click(); + await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('국가 권장 금'); + await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('4321'); await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click(); await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('실행 시도 1'); await expect(page.getByRole('list', { name: '판단 절차' })).toContainText( diff --git a/app/game-frontend/src/components/playAudit/AuditEffectivePolicy.vue b/app/game-frontend/src/components/playAudit/AuditEffectivePolicy.vue new file mode 100644 index 00000000..e218f7ef --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditEffectivePolicy.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue index c82ca987..452f897a 100644 --- a/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue +++ b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue @@ -1,4 +1,5 @@