수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
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<string, unknown>,
|
|
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' });
|
|
});
|
|
});
|