feat: NPC와 국방 정책 변경을 감사 버전으로 저장
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user