feat: 사용자 군주 자율행동 업무를 개별 설정한다

NPC 군주의 기존 국가 운영은 유지하고 사용자 군주는 외교, 수뇌 임명, 재정 조정, 천도를 기본 비활성화한다.

내 정보 설정과 인증 API, 엔진 gate를 연결하고 자율행동 기간 및 국가턴 master를 함께 검증한다.
This commit is contained in:
2026-08-22 08:28:04 +00:00
parent 957445b0e0
commit 9c588dbd67
14 changed files with 309 additions and 28 deletions
+41 -17
View File
@@ -22,7 +22,12 @@ import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
import type { TurnGeneral, TurnWorldState } from '../../types.js';
import type { AiCommandCandidate, AiReservedTurnProvider, AiWorldView } from '../types.js';
import { AutorunGeneralPolicy, AutorunNationPolicy, AVAILABLE_INSTANT_TURN } from '../policies.js';
import {
AutorunGeneralPolicy,
AutorunNationPolicy,
canUseAutomatedNationAction,
canUseRulerAutomation,
} from '../policies.js';
import {
asRecord,
joinYearMonth,
@@ -401,10 +406,10 @@ export class GeneralAI {
this.categorizeNationCities();
this.categorizeNationGeneral();
if (this.general.npcState >= 2 && [3, 6, 9, 12].includes(this.world.currentMonth)) {
if (this.general.officerLevel === 12) {
if ([3, 6, 9, 12].includes(this.world.currentMonth)) {
if (this.general.officerLevel === 12 && canUseRulerAutomation(this.general, 'promotion')) {
this.chooseNpcPromotion();
} else {
} else if (this.general.npcState >= 2) {
this.chooseNonLordPromotion();
}
}
@@ -420,7 +425,7 @@ export class GeneralAI {
if (!this.nationPolicy.can(actionName)) {
continue;
}
if (this.general.npcState < 2 && !AVAILABLE_INSTANT_TURN[actionName]) {
if (!canUseAutomatedNationAction(this.general, actionName)) {
continue;
}
const handler = nationActionHandlers[actionName];
@@ -1129,7 +1134,7 @@ export class GeneralAI {
for (let chiefLevel = minChiefLevel; chiefLevel <= 12; chiefLevel += 1) {
const chief = this.chiefGenerals[chiefLevel];
if (!chief) {
if (!chief || chief.id === this.general.id) {
continue;
}
const penalty = asRecord(chief.penalty);
@@ -1148,6 +1153,9 @@ export class GeneralAI {
const minBelong = Math.min(readMetaNumber(asRecord(this.general.meta), 'belong', 0) - 1, 3);
const availableUserChiefCount = Object.values(this.userGenerals).filter((candidate) => {
if (candidate.id === this.general.id) {
return false;
}
const penalty = asRecord(candidate.penalty);
const killturn = readRequiredMetaNumber(asRecord(candidate.meta), 'killturn', `generalId=${candidate.id}`);
return (
@@ -1158,17 +1166,19 @@ export class GeneralAI {
}).length;
if (userChiefCount === 0 && availableUserChiefCount > 0 && (chiefSet & (1 << 11)) === 0) {
const userCandidates = Object.values(this.userGenerals).sort((left, right) => {
const leftPenalty = asRecord(left.penalty);
const rightPenalty = asRecord(right.penalty);
if ((leftPenalty.noChief === true) !== (rightPenalty.noChief === true)) {
return leftPenalty.noChief === true ? 1 : -1;
}
if ((leftPenalty.noAmbassador === true) !== (rightPenalty.noAmbassador === true)) {
return leftPenalty.noAmbassador === true ? 1 : -1;
}
return right.stats.leadership - left.stats.leadership;
});
const userCandidates = Object.values(this.userGenerals)
.filter((candidate) => candidate.id !== this.general.id)
.sort((left, right) => {
const leftPenalty = asRecord(left.penalty);
const rightPenalty = asRecord(right.penalty);
if ((leftPenalty.noChief === true) !== (rightPenalty.noChief === true)) {
return leftPenalty.noChief === true ? 1 : -1;
}
if ((leftPenalty.noAmbassador === true) !== (rightPenalty.noAmbassador === true)) {
return leftPenalty.noAmbassador === true ? 1 : -1;
}
return right.stats.leadership - left.stats.leadership;
});
for (const candidate of userCandidates) {
const penalty = asRecord(candidate.penalty);
const killturn = readRequiredMetaNumber(
@@ -1227,6 +1237,9 @@ export class GeneralAI {
}
}
const nextChief = generals.find((candidate) => {
if (candidate.id === this.general.id) {
return false;
}
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4) {
return false;
}
@@ -1470,4 +1483,15 @@ export const shouldUseAi = (general: TurnGeneral, world: TurnWorldState): boolea
return current < limit;
};
export const shouldUseNationAi = (general: TurnGeneral, world: TurnWorldState): boolean => {
if (!shouldUseAi(general, world)) {
return false;
}
if (general.npcState >= 2) {
return true;
}
// Ref TurnExecutionHelper는 사용자 수뇌의 국가 AI에만 이 개인 설정을 적용한다.
return readMetaNumber(asRecord(general.meta), 'use_auto_nation_turn', 1) !== 0;
};
export type { GeneralAIOptions, GeneralAiDebugState };
+37
View File
@@ -61,6 +61,43 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
NPC전방발령: true,
};
export type UserRulerAutomationFeature = 'diplomacy' | 'promotion' | 'finance' | 'capital';
const USER_RULER_AUTOMATION_META_KEY = {
diplomacy: 'use_auto_nation_diplomacy',
promotion: 'use_auto_nation_promotion',
finance: 'use_auto_nation_finance',
capital: 'use_auto_nation_capital',
} as const satisfies Record<UserRulerAutomationFeature, string>;
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
: 'diplomacy',
: 'diplomacy',
: 'capital',
};
/** NPC는 기존 계약을 유지하고, 사용자 군주는 명시적으로 고른 업무만 위임한다. */
export const canUseRulerAutomation = (
general: TurnGeneral,
feature: UserRulerAutomationFeature
): boolean => {
if (general.npcState >= 2) {
return true;
}
if (general.officerLevel !== 12) {
return false;
}
return readMetaNumber(asRecord(general.meta), USER_RULER_AUTOMATION_META_KEY[feature], 0) === 1;
};
export const canUseAutomatedNationAction = (general: TurnGeneral, actionName: string): boolean => {
if (general.npcState >= 2 || AVAILABLE_INSTANT_TURN[actionName]) {
return true;
}
const feature = USER_RULER_ACTION_FEATURE[actionName];
return feature ? canUseRulerAutomation(general, feature) : false;
};
const buildFlags = (entries: Array<[string, boolean]>): PolicyFlags => Object.fromEntries(entries);
const applyPriorityOverride = (priority: string[], override: unknown, flags: PolicyFlags): string[] => {
@@ -132,6 +132,10 @@ const zSetMySetting = z.object({
defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(),
use_auto_nation_diplomacy: z.number().int().optional(),
use_auto_nation_promotion: z.number().int().optional(),
use_auto_nation_finance: z.number().int().optional(),
use_auto_nation_capital: z.number().int().optional(),
}),
});
+5 -2
View File
@@ -18,7 +18,8 @@ import {
import type { TurnCalendarContext, TurnCalendarHandler, InMemoryTurnWorld } from './inMemoryWorld.js';
import { readNumber } from './ai/aiUtils.js';
import { AutorunNationPolicy } from './ai/policies.js';
import { shouldUseNationAi } from './ai/generalAi.js';
import { AutorunNationPolicy, canUseRulerAutomation } from './ai/policies.js';
const calcNationDevelopedRate = (cities: City[]): { pop: number; all: number } => {
if (cities.length === 0) {
@@ -108,7 +109,9 @@ const resolveNpcMonarch = (nation: Nation, world: InMemoryTurnWorld) => {
: world
.listGenerals()
.find((general) => general.nationId === nation.id && general.officerLevel === 12) ?? null;
return chief && chief.npcState >= 2 ? chief : null;
return chief && canUseRulerAutomation(chief, 'finance') && shouldUseNationAi(chief, world.getState())
? chief
: null;
};
type NpcFinanceOptions = {
@@ -58,7 +58,7 @@ import {
import { buildCommandEnv, buildReservedTurnDefinitions } from './reservedTurnCommands.js';
import { buildFrontStatePatches } from './frontStateHandler.js';
import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import { GeneralAI, shouldUseAi, shouldUseNationAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js';
import { withCanonicalArgumentAliases } from './ai/aiUtils.js';
import { rankMetaKey } from './rankData.js';
@@ -1672,7 +1672,7 @@ export const createReservedTurnHandler = async (options: {
let nationAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
let nationAiDecisionDurationNs = 0n;
let nationUsedAi = false;
if (worldView && shouldUseAi(currentGeneral, context.world)) {
if (worldView && shouldUseNationAi(currentGeneral, context.world)) {
nationUsedAi = true;
const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
sharedAi = new GeneralAI({
@@ -1702,7 +1702,17 @@ async function handleSetMySetting(
nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment));
}
if (settings.use_auto_nation_turn !== undefined) {
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn;
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn === 0 ? 0 : 1;
}
for (const key of [
'use_auto_nation_diplomacy',
'use_auto_nation_promotion',
'use_auto_nation_finance',
'use_auto_nation_capital',
] as const) {
if (settings[key] !== undefined) {
nextMeta[key] = settings[key] === 1 ? 1 : 0;
}
}
let nextTrain = general.train;