feat: NPC와 국방 정책 변경을 감사 버전으로 저장

This commit is contained in:
2026-09-16 04:48:58 +00:00
parent 10f3f0ebc6
commit d549cbf887
19 changed files with 803 additions and 98 deletions
@@ -166,6 +166,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany();
await db.logEntry.deleteMany();
await db.playAuditPolicy.deleteMany({ where: { serverId: profile } });
worldStateId = (await db.worldState.findFirstOrThrow()).id;
await db.playAuditMonth.deleteMany({
where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } },
@@ -528,6 +529,59 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
});
}, 30_000);
it('commits a policy version with its actor, durable input sequence and nation pointer', async () => {
const actor = runtime!.world.listGenerals().find((general) => general.userId === userId)!;
const old = { nationId: actor.nationId, officerLevel: actor.officerLevel };
runtime!.world.addNation({
id: 99091,
name: '감사정책국',
color: '#ffffff',
capitalCityId: null,
chiefGeneralId: actor.id,
gold: 1000,
rice: 1000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: {},
});
runtime!.world.updateGeneral(actor.id, { nationId: 99091, officerLevel: 12 });
const requestId = 'select-pool-audit-policy';
try {
const result = await turnDaemon.requestCommand({
type: 'setNpcPolicy',
requestId,
userId,
generalId: actor.id,
nationId: 99091,
expectedUpdatedAt: null,
mutation: { kind: 'nationPolicy', values: { reqNationGold: 4321 } },
});
expect(result).toMatchObject({ type: 'setNpcPolicy', ok: true });
const input = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
expect(input.status).toBe('SUCCEEDED');
const rows = await db.playAuditPolicy.findMany({
where: { serverId: profile, nationId: 99091, area: 'NPC_VALUES' },
orderBy: { revision: 'asc' },
});
expect(rows).toHaveLength(2);
expect(rows[1]).toMatchObject({
source: 'CHANGE',
requestId,
inputSequence: input.sequence,
actor: { userId, generalId: actor.id, officerLevel: 12 },
after: { reqNationGold: 4321 },
});
expect(await db.nation.findUniqueOrThrow({ where: { id: 99091 } })).toMatchObject({
meta: {
_playAuditPolicy: { NPC_VALUES: { id: rows[1]!.id, revision: 2 } },
},
});
} finally {
runtime!.world.updateGeneral(actor.id, old);
}
});
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
const logicalNow = runtime!.world.getGameNow(new Date());
const logicalNowMs = logicalNow.getTime();
+155
View File
@@ -0,0 +1,155 @@
import { createHash } from 'node:crypto';
import { asRecord } from '@sammo-ts/common';
import type { Nation } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
import type { TurnGeneral } from '../turn/types.js';
import { DEFAULT_NATION_POLICY } from '../turn/npcPolicyDefaults.js';
export const AUDIT_POLICY_AREAS = ['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE'] as const;
export type AuditPolicyArea = (typeof AUDIT_POLICY_AREAS)[number];
type PolicyData = Record<string, unknown>;
type PolicyHead = { id: string; revision: number; hash: string; serverId: string };
export interface PendingAuditPolicy {
schemaVersion: 1;
id: string;
serverId: string;
nationId: number;
area: AuditPolicyArea;
revision: number;
previousId: string | null;
source: 'BASELINE' | 'CHANGE' | 'OBSERVED_GAP';
year: number;
month: number;
tick: number;
requestId: string | null;
ordinal: number;
actor: {
userId: string | null;
generalId: number;
name: string;
nationId: number;
officerLevel: number;
npcState: number;
permission: number;
} | null;
before: PolicyData | null;
after: PolicyData;
}
const canonical = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
return Object.fromEntries(Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
}
return item;
});
export const auditPolicyHash = (value: unknown): string => createHash('sha256').update(canonical(value)).digest('hex');
const pick = (source: Record<string, unknown>, keys: readonly string[]): PolicyData =>
JSON.parse(JSON.stringify(Object.fromEntries(keys.map((key) => [key, source[key] ?? null])))) as PolicyData;
/** 설정 누락(null)은 상속을 뜻한다. AI의 개인별 보정이나 난수를 재계산하지 않는다. */
export const projectAuditPolicy = (meta: Record<string, unknown>, area: AuditPolicyArea): PolicyData => {
const nation = asRecord(meta.npc_nation_policy);
switch (area) {
case 'NPC_VALUES':
return pick(asRecord(nation.values), Object.keys(DEFAULT_NATION_POLICY));
case 'NPC_NATION_PRIORITY':
return pick(nation, ['priority']);
case 'NPC_GENERAL_PRIORITY':
return pick(asRecord(meta.npc_general_policy), ['priority']);
case 'DEFENCE':
return pick(meta, ['war', 'scout', 'secretlimit']);
}
};
export const recordAuditPolicyChange = (options: {
world: InMemoryTurnWorld;
nation: Nation;
area: AuditPolicyArea;
nextMeta: Record<string, unknown>;
actor?: TurnGeneral;
permission?: number;
requestId?: string;
}): { _playAuditPolicy?: Record<string, PolicyHead> } => {
const { world, nation, area } = options;
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return {};
const rawHeads = asRecord(nation.meta._playAuditPolicy);
const heads: Record<string, PolicyHead> = {};
for (const key of AUDIT_POLICY_AREAS) {
const value = asRecord(rawHeads[key]);
if (
value.serverId === serverId &&
typeof value.id === 'string' &&
typeof value.revision === 'number' &&
Number.isSafeInteger(value.revision) &&
value.revision > 0 &&
typeof value.hash === 'string' &&
value.id === auditPolicyHash([serverId, nation.id, key, value.revision])
) {
heads[key] = { id: value.id, revision: value.revision, hash: value.hash, serverId };
}
}
let head: PolicyHead | null = heads[area] ?? null;
const before = projectAuditPolicy(nation.meta, area);
const after = projectAuditPolicy(options.nextMeta, area);
const append = (source: PendingAuditPolicy['source'], old: PolicyData | null, value: PolicyData) => {
const revision = (head?.revision ?? 0) + 1;
const id = auditPolicyHash([serverId, nation.id, area, revision]);
const actor = options.actor;
world.queueAuditPolicy({
schemaVersion: 1,
id,
serverId,
nationId: nation.id,
area,
revision,
previousId: head?.id ?? null,
source,
year: state.currentYear,
month: state.currentMonth,
tick: world.getGameClockState().tick,
requestId: source === 'CHANGE' ? (options.requestId ?? null) : null,
ordinal: world.nextAuditOrdinal(),
actor:
source === 'CHANGE' && actor
? {
userId: actor.userId ?? null,
generalId: actor.id,
name: actor.name,
nationId: actor.nationId,
officerLevel: actor.officerLevel,
npcState: actor.npcState,
permission: options.permission ?? 0,
}
: null,
before: old,
after: value,
});
head = { id, revision, hash: auditPolicyHash(value), serverId };
};
if (!head) append('BASELINE', null, before);
else if (head.hash !== auditPolicyHash(before)) append('OBSERVED_GAP', null, before);
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
if (!head) throw new Error('Play audit policy baseline missing');
return { _playAuditPolicy: { ...heads, [area]: head } };
};
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void => {
let nation = world.getNationById(nationId);
if (!nation) return;
for (const area of AUDIT_POLICY_AREAS) {
const patch = recordAuditPolicyChange({ world, nation, area, nextMeta: nation.meta });
if (
Object.keys(patch).length &&
auditPolicyHash(patch._playAuditPolicy) !== auditPolicyHash(nation.meta._playAuditPolicy ?? {})
) {
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
}
}
};
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
};
@@ -0,0 +1,42 @@
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
import { auditPolicyHash, type PendingAuditPolicy } from './policy.js';
export const persistAuditPolicies = async (
tx: GamePrisma.TransactionClient,
policies: readonly PendingAuditPolicy[],
command?: { requestId: string; sequence: bigint; actorUserId: string | null }
): Promise<void> => {
for (let offset = 0; offset < policies.length; offset += 200) {
const batch = policies.slice(offset, offset + 200).map((policy) => {
if (policy.requestId && !command) throw new Error('Play audit policy input event context missing');
if (
policy.requestId &&
command &&
(policy.requestId !== command.requestId || policy.actor?.userId !== command.actorUserId)
) {
throw new Error('Play audit policy actor/request mismatch');
}
return {
...policy,
inputSequence: policy.requestId && command ? command.sequence : null,
actor: policy.actor ? (JSON.parse(JSON.stringify(policy.actor)) as InputJsonValue) : GamePrisma.DbNull,
before: policy.before
? (JSON.parse(JSON.stringify(policy.before)) as InputJsonValue)
: GamePrisma.DbNull,
after: JSON.parse(JSON.stringify(policy.after)) as InputJsonValue,
hash: auditPolicyHash({
...policy,
inputSequence: policy.requestId && command ? command.sequence.toString() : null,
}),
};
});
await tx.playAuditPolicy.createMany({ data: batch, skipDuplicates: true });
const saved = await tx.playAuditPolicy.findMany({
where: { id: { in: batch.map((row) => row.id) } },
select: { id: true, hash: true },
});
const hashes = new Map(saved.map((row) => [row.id, row.hash]));
if (batch.some((row) => hashes.get(row.id) !== row.hash))
throw new Error('Play audit policy replay payload conflict');
}
};
@@ -20,6 +20,18 @@ export const prunePreviousAuditBatch = async (
if (!lock?.locked) return { status: 'busy', deleted: 0 };
const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } });
if (asRecord(world?.meta).serverId !== expectedServerId) return { status: 'identityChanged', deleted: 0 };
const policies = await tx.playAuditPolicy.findMany({
where: { serverId: { not: expectedServerId } },
orderBy: { id: 'asc' },
take: AUDIT_RETENTION_BATCH_SIZE,
select: { id: true },
});
if (policies.length) {
const deleted = await tx.playAuditPolicy.deleteMany({
where: { id: { in: policies.map((row) => row.id) } },
});
return { status: 'progress', deleted: deleted.count };
}
// 부모를 잠가 늦은 child INSERT와 빈 header 삭제의 경쟁도 차단한다.
const [sample] = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM play_audit_month WHERE server_id <> ${expectedServerId}
+13 -1
View File
@@ -1,3 +1,4 @@
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js';
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
@@ -1145,6 +1146,7 @@ export const createDatabaseTurnHooks = async (
pendingNationBettingFinishes,
pendingYearbookSnapshots,
pendingAuditMonths,
pendingAuditPolicies,
pendingUnificationFinalizations,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -1303,15 +1305,18 @@ export const createDatabaseTurnHooks = async (
cutWallAt: unificationCutWallAt!,
})
: null;
let auditCommand: { requestId: string; sequence: bigint; actorUserId: string | null } | undefined;
if (commandCompletion) {
const commandFence = await prisma.$queryRaw<
Array<{
status: string;
sequence: bigint;
actor_user_id: string | null;
processing_clock_revision: bigint | null;
processing_deadline_generation: bigint | null;
}>
>(GamePrisma.sql`
SELECT status,
SELECT status, sequence, actor_user_id,
processing_clock_revision,
processing_deadline_generation
FROM input_event
@@ -1320,6 +1325,12 @@ export const createDatabaseTurnHooks = async (
FOR UPDATE
`);
const event = commandFence[0];
if (event)
auditCommand = {
requestId: commandCompletion.requestId,
sequence: event.sequence,
actorUserId: event.actor_user_id,
};
const unificationRevisionTransition =
commandCompletion.result.type === 'messageRespond' &&
commandCompletion.result.ok &&
@@ -1876,6 +1887,7 @@ export const createDatabaseTurnHooks = async (
data: pendingLogRows,
});
}
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
for (const snapshot of pendingAuditMonths) {
await persistAuditMonth(prisma, snapshot);
}
+23
View File
@@ -1,3 +1,4 @@
import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js';
import type { PendingAuditMonth } from '../playAudit/persistence.js';
import type {
City,
@@ -201,6 +202,7 @@ export interface TurnWorldChanges {
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
}
@@ -242,6 +244,7 @@ export interface InMemoryTurnWorldStateSnapshot {
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
pendingRealtimeBacklogShiftTicks: number;
}
@@ -547,6 +550,7 @@ export class InMemoryTurnWorld {
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
private readonly pendingAuditMonths: PendingAuditMonth[] = [];
private readonly pendingAuditPolicies: PendingAuditPolicy[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig;
@@ -1096,6 +1100,7 @@ export class InMemoryTurnWorld {
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
pendingAuditMonths: this.pendingAuditMonths,
pendingAuditPolicies: this.pendingAuditPolicies,
pendingUnificationFinalizations: this.pendingUnificationFinalizations,
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
} satisfies InMemoryTurnWorldStateSnapshot);
@@ -1143,6 +1148,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths);
this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies);
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
}
@@ -1358,6 +1364,19 @@ export class InMemoryTurnWorld {
});
}
nextAuditOrdinal(): number {
const previous = this.state.meta._playAuditOrdinal;
const ordinal =
typeof previous === 'number' && Number.isSafeInteger(previous) && previous >= 0 ? previous + 1 : 1;
if (ordinal > 2_147_483_647) throw new Error('Play audit ordinal exhausted');
this.updateWorldMeta({ _playAuditOrdinal: ordinal });
return ordinal;
}
queueAuditPolicy(policy: PendingAuditPolicy): void {
this.pendingAuditPolicies.push(structuredClone(policy));
}
queueAuditMonth(snapshot: PendingAuditMonth): void {
this.pendingAuditMonths.push(structuredClone(snapshot));
}
@@ -1595,6 +1614,7 @@ export class InMemoryTurnWorld {
this.dirtyNationIds.add(nation.id);
this.createdNationIds.add(nation.id);
this.ensureDiplomacyMatrix();
initializeNationAuditPolicies(this, nation.id);
return true;
}
@@ -2205,6 +2225,7 @@ export class InMemoryTurnWorld {
}));
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
const pendingAuditMonths = structuredClone(this.pendingAuditMonths);
const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies);
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
(left, right) => left - right
@@ -2238,6 +2259,7 @@ export class InMemoryTurnWorld {
pendingNationBettingFinishes,
pendingYearbookSnapshots,
pendingAuditMonths,
pendingAuditPolicies,
pendingUnificationFinalizations,
};
}
@@ -2277,6 +2299,7 @@ export class InMemoryTurnWorld {
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length);
this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length);
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
}
@@ -1,3 +1,4 @@
import { recordAuditPolicyChange } from '../playAudit/policy.js';
import { createHash } from 'node:crypto';
import { asRecord, formatServerDateTime, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
@@ -112,7 +113,11 @@ export const applyNationSettingMutation = (options: {
break;
}
case 'rate':
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 5 || command.mutation.amount > 30) {
if (
!Number.isInteger(command.mutation.amount) ||
command.mutation.amount < 5 ||
command.mutation.amount > 30
) {
return reject('BAD_REQUEST', '올바른 세율을 입력해주세요.', command.nationId);
}
updates = { rate: command.mutation.amount };
@@ -128,7 +133,11 @@ export const applyNationSettingMutation = (options: {
updates = { bill: command.mutation.amount };
break;
case 'secretLimit':
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 1 || command.mutation.amount > 99) {
if (
!Number.isInteger(command.mutation.amount) ||
command.mutation.amount < 1 ||
command.mutation.amount > 99
) {
return reject('BAD_REQUEST', '올바른 기밀 공개 기준을 입력해주세요.', command.nationId);
}
updates = { secretlimit: command.mutation.amount };
@@ -154,10 +163,22 @@ export const applyNationSettingMutation = (options: {
}
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
const audit = ['blockWar', 'blockScout', 'secretLimit'].includes(command.mutation.kind)
? recordAuditPolicyChange({
world,
nation,
area: 'DEFENCE',
nextMeta: { ...nation.meta, ...updates },
actor,
permission,
requestId: command.requestId,
})
: {};
world.updateNation(command.nationId, {
meta: {
...nation.meta,
...updates,
...audit,
_updatedAt: updatedAt,
},
});
@@ -0,0 +1,91 @@
export type NationPolicy = {
reqNationGold: number;
reqNationRice: number;
CombatForce: Record<number, [number, number]>;
SupportForce: number[];
DevelopForce: number[];
reqHumanWarUrgentGold: number;
reqHumanWarUrgentRice: number;
reqHumanWarRecommandGold: number;
reqHumanWarRecommandRice: number;
reqHumanDevelGold: number;
reqHumanDevelRice: number;
reqNPCWarGold: number;
reqNPCWarRice: number;
reqNPCDevelGold: number;
reqNPCDevelRice: number;
minimumResourceActionAmount: number;
maximumResourceActionAmount: number;
minNPCWarLeadership: number;
minWarCrew: number;
minNPCRecruitCityPopulation: number;
safeRecruitCityPopulationRatio: number;
properWarTrainAtmos: number;
cureThreshold: number;
};
export const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
export const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
export const DEFAULT_NATION_POLICY: NationPolicy = {
reqNationGold: 10000,
reqNationRice: 12000,
CombatForce: {},
SupportForce: [],
DevelopForce: [],
reqHumanWarUrgentGold: 0,
reqHumanWarUrgentRice: 0,
reqHumanWarRecommandGold: 0,
reqHumanWarRecommandRice: 0,
reqHumanDevelGold: 10000,
reqHumanDevelRice: 10000,
reqNPCWarGold: 0,
reqNPCWarRice: 0,
reqNPCDevelGold: 0,
reqNPCDevelRice: 500,
minimumResourceActionAmount: 1000,
maximumResourceActionAmount: 10000,
minNPCWarLeadership: 40,
minWarCrew: 1500,
minNPCRecruitCityPopulation: 50000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
cureThreshold: 10,
};
+30 -92
View File
@@ -1,3 +1,4 @@
import { recordAuditPolicyChange } from '../playAudit/policy.js';
import { createHash } from 'node:crypto';
import {
@@ -11,97 +12,18 @@ import { resolveTroopSecretPermission } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
export type NationPolicy = {
reqNationGold: number;
reqNationRice: number;
CombatForce: Record<number, [number, number]>;
SupportForce: number[];
DevelopForce: number[];
reqHumanWarUrgentGold: number;
reqHumanWarUrgentRice: number;
reqHumanWarRecommandGold: number;
reqHumanWarRecommandRice: number;
reqHumanDevelGold: number;
reqHumanDevelRice: number;
reqNPCWarGold: number;
reqNPCWarRice: number;
reqNPCDevelGold: number;
reqNPCDevelRice: number;
minimumResourceActionAmount: number;
maximumResourceActionAmount: number;
minNPCWarLeadership: number;
minWarCrew: number;
minNPCRecruitCityPopulation: number;
safeRecruitCityPopulationRatio: number;
properWarTrainAtmos: number;
cureThreshold: number;
};
export const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
export const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
export const DEFAULT_NATION_POLICY: NationPolicy = {
reqNationGold: 10000,
reqNationRice: 12000,
CombatForce: {},
SupportForce: [],
DevelopForce: [],
reqHumanWarUrgentGold: 0,
reqHumanWarUrgentRice: 0,
reqHumanWarRecommandGold: 0,
reqHumanWarRecommandRice: 0,
reqHumanDevelGold: 10000,
reqHumanDevelRice: 10000,
reqNPCWarGold: 0,
reqNPCWarRice: 0,
reqNPCDevelGold: 0,
reqNPCDevelRice: 500,
minimumResourceActionAmount: 1000,
maximumResourceActionAmount: 10000,
minNPCWarLeadership: 40,
minWarCrew: 1500,
minNPCRecruitCityPopulation: 50000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
cureThreshold: 10,
};
import {
DEFAULT_NATION_POLICY,
DEFAULT_NATION_PRIORITY,
DEFAULT_GENERAL_PRIORITY,
type NationPolicy,
} from './npcPolicyDefaults.js';
export {
DEFAULT_NATION_POLICY,
DEFAULT_NATION_PRIORITY,
DEFAULT_GENERAL_PRIORITY,
type NationPolicy,
} from './npcPolicyDefaults.js';
const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>);
@@ -306,7 +228,8 @@ export const applyNpcPolicyMutation = (options: {
if (!nation) {
return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', { nationId: command.nationId });
}
if (resolveTroopSecretPermission(actor, nation.meta, true) < 3) {
const permission = resolveTroopSecretPermission(actor, nation.meta, true);
if (permission < 3) {
return reject('FORBIDDEN', '권한이 부족합니다. 군주, 외교권자, 조언자가 아닙니다.', {
nationId: command.nationId,
});
@@ -385,10 +308,25 @@ export const applyNpcPolicyMutation = (options: {
// accepted commands can share the same time. Include the durable request
// identity to keep the strict CAS token unique.
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
const audit = recordAuditPolicyChange({
world,
nation,
area:
command.mutation.kind === 'nationPolicy'
? 'NPC_VALUES'
: command.mutation.kind === 'nationPriority'
? 'NPC_NATION_PRIORITY'
: 'NPC_GENERAL_PRIORITY',
nextMeta: { ...nation.meta, ...updates },
actor,
permission,
requestId: command.requestId,
});
world.updateNation(command.nationId, {
meta: {
...nation.meta,
...updates,
...audit,
// Keep the policy CAS independent from notice/tax/scout settings.
// The legacy shared _updatedAt remains a one-time migration fallback.
_npcPolicyUpdatedAt: updatedAt,
+2
View File
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
import { createPlayAuditHandler } from '../playAudit/collection.js';
import { randomUUID } from 'node:crypto';
@@ -803,6 +804,7 @@ const createTurnDaemonRuntimeWithLease = async (
};
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world;
initializeAuditPolicies(world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
@@ -421,4 +421,49 @@ describe('nation setting mutation', () => {
});
expect(world.getNationById(1)?.meta).toEqual(before);
});
it('records a baseline and changed defence policy, preserving no-op and rollback semantics', () => {
const world = createWorld({ worldMeta: { serverId: 'policy-fixture' }, nationMeta: { scout: 0 } });
const saved = world.captureState();
const result = applyNationSettingMutation({
world,
acceptedAt,
command: command({ kind: 'blockScout', value: true }),
});
expect(result.ok).toBe(true);
const records = world.peekDirtyState().pendingAuditPolicies;
expect(records).toHaveLength(2);
expect(records[0]).toMatchObject({
area: 'DEFENCE',
source: 'BASELINE',
before: null,
after: { scout: 0 },
actor: null,
});
expect(records[1]).toMatchObject({
source: 'CHANGE',
revision: 2,
previousId: records[0]!.id,
requestId: 'nation-setting-test',
before: { scout: 0 },
after: { scout: 1 },
actor: { userId: 'owner-1', generalId: 1, name: '테스트군주', officerLevel: 12 },
});
const next = applyNationSettingMutation({
world,
acceptedAt,
command: command({ kind: 'blockScout', value: true }, { requestId: 'same-value' }),
});
expect(next.ok).toBe(true);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
applyNationSettingMutation({
world,
acceptedAt,
command: command({ kind: 'blockScout', value: false }, { userId: 'intruder' }),
});
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
world.restoreState(saved);
expect(world.peekDirtyState().pendingAuditPolicies).toEqual([]);
expect(world.getNationById(1)?.meta.scout).toBe(0);
expect(world.getState().meta._playAuditOrdinal).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { describe, expect, it } from 'vitest';
import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
@@ -177,7 +178,11 @@ const unitSet: UnitSetDefinition = {
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 world = new InMemoryTurnWorld(
{ ...state, meta: { ...state.meta, serverId: 'npc-policy-next-consumer' } },
snapshot,
{ schedule }
);
const first = applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
@@ -467,4 +472,74 @@ describe('NPC policy lifecycle', () => {
})
).toMatchObject({ ok: false, code: 'FORBIDDEN' });
});
it('records configured NPC policy changes but ignores setter time and unchanged values', () => {
const world = new InMemoryTurnWorld(
{ ...state, meta: { ...state.meta, serverId: 'npc-policy-audit' } },
structuredClone(snapshot),
{ schedule }
);
const apply = (requestId: string, expectedUpdatedAt: string | null) =>
applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:06Z'),
command: {
type: 'setNpcPolicy',
requestId,
userId: 'owner-1',
generalId: 1,
nationId: 1,
expectedUpdatedAt,
mutation: { kind: 'nationPolicy', values: { reqNationGold: 4_321 } },
},
});
const first = apply('audit-first', '2026-01-01T00:00:00.000Z');
expect(first.ok).toBe(true);
if (!first.ok) throw new Error(first.reason);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
expect(world.peekDirtyState().pendingAuditPolicies[1]).toMatchObject({
area: 'NPC_VALUES',
source: 'CHANGE',
after: { reqNationGold: 4321 },
actor: { name: 'NPC군주' },
});
expect(apply('audit-noop', first.updatedAt).ok).toBe(true);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
expect(apply('audit-conflict', first.updatedAt).ok).toBe(false);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
});
it('captures four baselines once, preserves their heads on reload and scopes copied metadata to a new nation', () => {
const world = new InMemoryTurnWorld(
{ ...state, meta: { ...state.meta, serverId: 'policy-baselines' } },
structuredClone(snapshot),
{ schedule }
);
initializeAuditPolicies(world);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(4);
world.acknowledgeDirtyState(world.peekDirtyState());
const reloaded = new InMemoryTurnWorld(
structuredClone(world.getState()),
{ ...structuredClone(snapshot), nations: world.listNations() },
{ schedule }
);
initializeAuditPolicies(reloaded);
expect(reloaded.peekDirtyState().pendingAuditPolicies).toHaveLength(0);
reloaded.addNation({ ...reloaded.getNationById(1)!, id: 2 });
const added = reloaded.peekDirtyState().pendingAuditPolicies;
expect(added).toHaveLength(4);
expect(added.every((row) => row.nationId === 2 && row.revision === 1 && row.source === 'BASELINE')).toBe(true);
expect(reloaded.getNationById(2)?.meta._playAuditPolicy).not.toEqual(
reloaded.getNationById(1)?.meta._playAuditPolicy
);
const nation = reloaded.getNationById(1)!;
reloaded.updateNation(1, { meta: { ...nation.meta, scout: 1 } });
initializeAuditPolicies(reloaded);
expect(reloaded.peekDirtyState().pendingAuditPolicies.at(-1)).toMatchObject({
nationId: 1,
area: 'DEFENCE',
source: 'OBSERVED_GAP',
actor: null,
before: null,
after: { scout: 1 },
});
});
});
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditPolicies } from '../src/playAudit/policyPersistence.js';
import type { PendingAuditPolicy } from '../src/playAudit/policy.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const serverId = 'policy-persistence-fixture';
const requestId = 'policy-persistence-request';
integration('immutable policy persistence', () => {
let db: GamePrismaClient;
let close: () => Promise<void>;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
await db.playAuditPolicy.deleteMany({ where: { serverId } });
await db.inputEvent.deleteMany({ where: { requestId } });
});
afterAll(async () => {
await db.playAuditPolicy.deleteMany({ where: { serverId } });
await db.inputEvent.deleteMany({ where: { requestId } });
await db.nation.deleteMany({ where: { id: 999915 } });
await close();
});
it('rolls back policy and state together, binds durable sequence, and rejects conflicting replay', async () => {
const input = await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'setNationSetting',
actorUserId: 'audit-owner',
status: 'PROCESSING',
},
});
const context = { requestId, sequence: input.sequence, actorUserId: 'audit-owner' };
const baseline: PendingAuditPolicy = {
schemaVersion: 1,
id: `${serverId}:1`,
serverId,
nationId: 999915,
area: 'DEFENCE',
revision: 1,
previousId: null,
source: 'BASELINE',
year: 190,
month: 1,
tick: 1,
requestId: null,
ordinal: 1,
actor: null,
before: null,
after: { scout: 0 },
};
const change: PendingAuditPolicy = {
...baseline,
id: `${serverId}:2`,
revision: 2,
previousId: baseline.id,
source: 'CHANGE',
requestId,
ordinal: 2,
actor: {
userId: 'audit-owner',
generalId: 1,
name: '기록 군주',
nationId: 999915,
officerLevel: 12,
npcState: 0,
permission: 4,
},
before: { scout: 0 },
after: { scout: 1 },
};
await expect(
db.$transaction(async (tx) => {
await tx.nation.create({ data: { id: 999915, name: '정책국', color: '#ffffff', meta: { scout: 1 } } });
await persistAuditPolicies(tx, [baseline, change], context);
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED' } });
throw new Error('policy rollback');
})
).rejects.toThrow('policy rollback');
expect(await db.playAuditPolicy.count({ where: { serverId } })).toBe(0);
expect(await db.nation.findUnique({ where: { id: 999915 } })).toBeNull();
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('PROCESSING');
await db.$transaction(async (tx) => {
await tx.nation.create({ data: { id: 999915, name: '정책국', color: '#ffffff', meta: { scout: 1 } } });
await persistAuditPolicies(tx, [baseline, change], context);
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED' } });
});
await db.$transaction((tx) => persistAuditPolicies(tx, [baseline, change], context));
const rows = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { revision: 'asc' } });
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ inputSequence: null, actor: null });
expect(rows[1]).toMatchObject({
inputSequence: input.sequence,
requestId,
before: { scout: 0 },
after: { scout: 1 },
});
await expect(
db.$transaction((tx) => persistAuditPolicies(tx, [{ ...change, after: { scout: 0 } }], context))
).rejects.toThrow('replay payload conflict');
await expect(
db.$transaction((tx) => persistAuditPolicies(tx, [change], { ...context, actorUserId: 'other' }))
).rejects.toThrow('actor/request mismatch');
await expect(db.$transaction((tx) => persistAuditPolicies(tx, [change]))).rejects.toThrow(
'input event context missing'
);
expect((await db.playAuditPolicy.findUniqueOrThrow({ where: { id: change.id } })).after).toEqual({ scout: 1 });
});
});
@@ -23,6 +23,7 @@ integration('bounded previous-season audit retention', () => {
it('keeps the active season, bounds each transaction and retries after rollback', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany();
const world = await db.worldState.create({
data: {
@@ -121,6 +122,7 @@ integration('bounded previous-season audit retention', () => {
});
it('starts bounded cleanup only after seed commits, including a reserved opening', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany();
await db.worldState.create({
data: {
@@ -184,4 +186,33 @@ integration('bounded previous-season audit retention', () => {
message: '이전 플레이 감사 자료의 나머지는 서버 시작 후 정리합니다.',
});
});
it('prunes old policy versions by key and preserves the active policy history', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.updateMany({ data: { meta: { serverId: 'policy-active' } } });
const row = (id: string, serverId: string, revision: number) => ({
id,
serverId,
nationId: 1,
area: 'DEFENCE',
revision,
source: 'BASELINE',
year: 190,
month: 1,
ordinal: revision,
after: {},
hash: id,
});
await db.playAuditPolicy.createMany({
data: [
...Array.from({ length: 201 }, (_, index) => row(`old-policy-${index}`, 'old-policy', index + 1)),
row('active-policy', 'policy-active', 1),
],
});
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'progress', deleted: 200 });
expect(await db.playAuditPolicy.count({ where: { serverId: 'old-policy' } })).toBe(1);
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'progress', deleted: 1 });
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'complete', deleted: 0 });
expect(await db.playAuditPolicy.findUnique({ where: { id: 'active-policy' } })).not.toBeNull();
});
});
@@ -117,6 +117,7 @@ describe('durable read-model change journal mapping', () => {
pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [],
pendingAuditMonths: [],
pendingAuditPolicies: [],
pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges();