import { describe, expect, it } from 'vitest'; import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { AutorunNationPolicy } from '../src/turn/ai/policies.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js'; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; const general: TurnGeneral = { id: 1, userId: 'owner-1', name: 'NPC군주', nationId: 1, cityId: 1, troopId: 0, stats: { leadership: 75, strength: 40, intelligence: 70 }, turnTime: new Date('0185-01-01T00:00:00Z'), 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: 12, experience: 0, dedication: 0, injury: 0, gold: 1_000, rice: 1_000, crew: 0, crewTypeId: 1100, train: 0, atmos: 0, age: 30, npcState: 2, }; const snapshot: TurnWorldSnapshot = { generals: [general], cities: [ { id: 1, name: '허창', nationId: 1, level: 7, state: 0, population: 100_000, populationMax: 200_000, agriculture: 1_000, agricultureMax: 2_000, commerce: 1_000, commerceMax: 2_000, security: 1_000, securityMax: 2_000, supplyState: 1, frontState: 0, defence: 1_000, defenceMax: 2_000, wall: 1_000, wallMax: 2_000, meta: {}, }, ], nations: [ { id: 1, name: '위', color: '#777777', capitalCityId: 1, chiefGeneralId: 1, gold: 10_000, rice: 20_000, power: 0, level: 3, typeCode: 'che_법가', meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' }, }, ], troops: [{ id: 101, nationId: 1, name: '선봉부대' }], diplomacy: [], events: [], initialEvents: [], scenarioConfig: { stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 }, iconPath: '', map: {}, const: {}, environment: { mapName: 'test', unitSet: 'basic' }, }, scenarioMeta: { title: 'test', startYear: 180, life: null, fiction: null, history: [], ignoreDefaultEvents: false, }, map: { id: 'test', name: 'test', cities: [], defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, }, }; const state: TurnWorldState = { id: 1, currentYear: 185, currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('0185-01-01T00:00:00Z'), meta: { killturn: 24 }, }; const commandEnv: TurnCommandEnv = { baseGold: 1_000, baseRice: 1_000, develCost: 18, maxResourceActionAmount: 10_000, minAvailableRecruitPop: 30_000, trainDelta: 5, atmosDelta: 5, maxTrainByCommand: 100, maxAtmosByCommand: 100, sabotageDefaultProb: 0.5, sabotageProbCoefByStat: 0.01, sabotageDefenceCoefByGeneralCount: 0.01, sabotageDamageMin: 1, sabotageDamageMax: 10, defaultCrewTypeId: 1100, maxGeneral: 100, defaultNpcGold: 1_000, defaultNpcRice: 1_000, defaultSpecialDomestic: null, defaultSpecialWar: null, openingPartYear: 3, initialNationGenLimit: 10, maxTechLevel: 10, techLevelIncYear: 5, initialAllowedTechLevel: 1, }; const unitSet: UnitSetDefinition = { id: 'basic', name: 'basic', defaultCrewTypeId: 1100, armTypes: { 1: '보병' }, crewTypes: [ { id: 1100, armType: 1, name: '보병', attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9, requirements: [], attackCoef: {}, defenceCoef: {}, info: [], initSkillTrigger: null, phaseSkillTrigger: null, iActionList: null, }, ], }; describe('NPC policy lifecycle', () => { it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const first = applyNpcPolicyMutation({ world, acceptedAt: new Date('2026-02-03T04:05:06.000Z'), command: { type: 'setNpcPolicy', userId: 'owner-1', generalId: 1, nationId: 1, requestId: 'npc-policy-values', expectedUpdatedAt: '2026-01-01T00:00:00.000Z', mutation: { kind: 'nationPolicy', values: { reqNationGold: 4_321 } }, }, }); expect(first).toMatchObject({ type: 'setNpcPolicy', ok: true, nationId: 1, updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/), }); if (!first.ok) { throw new Error(first.reason); } world.updateNation(1, { meta: { ...world.getNationById(1)!.meta, _updatedAt: '2026-02-03T04:05:06.500Z#unrelated-setting', }, }); const second = applyNpcPolicyMutation({ world, acceptedAt: new Date('2026-02-03T04:05:07.000Z'), command: { type: 'setNpcPolicy', userId: 'owner-1', generalId: 1, nationId: 1, requestId: 'npc-policy-priority', expectedUpdatedAt: first.updatedAt, mutation: { kind: 'nationPriority', priority: ['천도'] }, }, }); expect(second).toMatchObject({ type: 'setNpcPolicy', ok: true, nationId: 1, updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/), }); if (!second.ok) { throw new Error(second.reason); } const nation = world.getNationById(1)!; expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: { values: { reqNationGold: 4_321 }, priority: ['천도'], valueSetter: 'NPC군주', prioritySetter: 'NPC군주', }, }); const policy = new AutorunNationPolicy({ general: world.getGeneralById(1)!, aiOptions: null, nationPolicy: asRecord(nation.meta).npc_nation_policy as Record, serverPolicy: null, nation, env: commandEnv, scenarioConfig: snapshot.scenarioConfig, unitSet, }); expect(policy.reqNationGold).toBe(4_321); expect(policy.priority).toEqual(['천도']); expect(policy.reqNpcDevelGold).toBe(540); expect(policy.reqNpcWarGold).toBe(3_900); expect(policy.reqNpcWarRice).toBe(3_900); const beforeConflict = structuredClone(world.getNationById(1)?.meta); expect( applyNpcPolicyMutation({ world, acceptedAt: new Date('2026-02-03T04:05:08.000Z'), command: { type: 'setNpcPolicy', userId: 'owner-1', generalId: 1, nationId: 1, expectedUpdatedAt: '2026-01-01T00:00:00.000Z', mutation: { kind: 'nationPolicy', values: { reqNationGold: 9_999 } }, }, }) ).toMatchObject({ type: 'setNpcPolicy', ok: false, code: 'CONFLICT', currentUpdatedAt: second.updatedAt, }); expect(world.getNationById(1)?.meta).toEqual(beforeConflict); expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({ reqNationGold: 4_321, }); expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 }); }); it('requires an exact nullable revision when policy metadata has never been versioned', () => { const noRevisionSnapshot = structuredClone(snapshot); delete noRevisionSnapshot.nations[0]!.meta._npcPolicyUpdatedAt; delete noRevisionSnapshot.nations[0]!.meta._updatedAt; const world = new InMemoryTurnWorld(state, noRevisionSnapshot, { schedule }); const initialMeta = structuredClone(world.getNationById(1)?.meta); expect( applyNpcPolicyMutation({ world, acceptedAt: new Date('2026-02-03T04:05:06.000Z'), command: { type: 'setNpcPolicy', userId: 'owner-1', generalId: 1, nationId: 1, expectedUpdatedAt: '2026-01-01T00:00:00.000Z', mutation: { kind: 'nationPriority', priority: ['천도'] }, }, }) ).toMatchObject({ type: 'setNpcPolicy', ok: false, code: 'CONFLICT', currentUpdatedAt: null }); expect(world.getNationById(1)?.meta).toEqual(initialMeta); const accepted = applyNpcPolicyMutation({ world, acceptedAt: new Date('2026-02-03T04:05:07.000Z'), command: { type: 'setNpcPolicy', requestId: 'initial-null-revision', userId: 'owner-1', generalId: 1, nationId: 1, expectedUpdatedAt: null, mutation: { kind: 'nationPriority', priority: ['천도'] }, }, }); expect(accepted).toMatchObject({ type: 'setNpcPolicy', ok: true, nationId: 1, updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/), }); if (!accepted.ok) { throw new Error(accepted.reason); } expect(world.getNationById(1)?.meta).toMatchObject({ preserved: 'yes', _npcPolicyUpdatedAt: accepted.updatedAt, npc_nation_policy: { priority: ['천도'] }, }); }); it('validates and merges policy intent against current ENGINE state without materialising defaults', () => { const world = new InMemoryTurnWorld( { ...state, clockBaseTime: new Date('0185-01-01T00:00:00.000Z'), clockTick: 0, clockMode: 'manual', clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'), lastTurnTick: 0, }, snapshot, { schedule } ); world.updateNation(1, { meta: { ...world.getNationById(1)!.meta, npc_nation_policy: { values: { reqNationRice: 456 }, preserved: 'root' }, }, }); const result = applyNpcPolicyMutation({ world, acceptedAt: new Date('2026-02-03T04:05:06.000Z'), command: { type: 'setNpcPolicy', userId: 'owner-1', generalId: 1, nationId: 1, expectedUpdatedAt: '2026-01-01T00:00:00.000Z', mutation: { kind: 'nationPolicy', values: { reqNationGold: -100, safeRecruitCityPopulationRatio: -0.5, CombatForce: {}, SupportForce: [101], }, }, }, }); expect(result).toMatchObject({ type: 'setNpcPolicy', ok: true, nationId: 1, updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/), }); expect(asRecord(world.getNationById(1)?.meta).npc_nation_policy).toEqual({ values: { reqNationRice: 456, reqNationGold: 0, safeRecruitCityPopulationRatio: -0.5, CombatForce: {}, SupportForce: [101], }, preserved: 'root', valueSetter: 'NPC군주', valueSetTime: '0185-01-01 09:00:00', }); }); it('rejects stale authority, empty input, malformed combat targets, and lost CAS inside ENGINE', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const baseCommand = { type: 'setNpcPolicy' as const, userId: 'owner-1', generalId: 1, nationId: 1, expectedUpdatedAt: '2026-01-01T00:00:00.000Z', }; const acceptedAt = new Date('2026-02-03T04:05:06.000Z'); expect( applyNpcPolicyMutation({ world, acceptedAt, command: { ...baseCommand, mutation: { kind: 'nationPolicy', values: {} } }, }) ).toMatchObject({ ok: false, code: 'BAD_REQUEST' }); expect( applyNpcPolicyMutation({ world, acceptedAt, command: { ...baseCommand, mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1, 1] } } }, }, }) ).toMatchObject({ ok: false, code: 'BAD_REQUEST', reason: '101의 입력양식이 올바르지 않습니다.' }); expect( applyNpcPolicyMutation({ world, acceptedAt, command: { ...baseCommand, mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1] } } }, }, }) ).toMatchObject({ ok: false, code: 'BAD_REQUEST', reason: '101의 도시 , 가 올바른 도시 번호가 아닙니다.', }); expect( applyNpcPolicyMutation({ world, acceptedAt, command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: [] } }, }) ).toMatchObject({ ok: false, code: 'BAD_REQUEST' }); expect( applyNpcPolicyMutation({ world, acceptedAt, command: { ...baseCommand, expectedUpdatedAt: '1999-01-01T00:00:00.000Z', mutation: { kind: 'nationPriority', priority: ['천도'] }, }, }) ).toMatchObject({ ok: false, code: 'CONFLICT' }); world.updateGeneral(1, { officerLevel: 2 }); expect( applyNpcPolicyMutation({ world, acceptedAt, command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: ['천도'] } }, }) ).toMatchObject({ ok: false, code: 'FORBIDDEN' }); }); });