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
+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', {