merge: 사용자 군주 자율행동 옵션을 main에 반영한다

This commit is contained in:
2026-08-22 08:29:32 +00:00
14 changed files with 309 additions and 28 deletions
+10
View File
@@ -55,6 +55,10 @@ const zGeneralSettings = 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().min(0).max(1).optional(),
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
use_auto_nation_capital: z.number().int().min(0).max(1).optional(),
});
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
@@ -211,6 +215,12 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
defence_train: readNumber(readSetting('defence_train'), 80),
use_treatment: readNumber(readSetting('use_treatment'), 10),
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
use_auto_nation_capital: readNumber(readSetting('use_auto_nation_capital'), 0),
myset,
};
};
@@ -584,6 +584,10 @@ describe('in-game my information ownership', () => {
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_promotion: 0,
use_auto_nation_finance: 0,
use_auto_nation_capital: 0,
myset: 3,
});
+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;
@@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '@sammo-ts/logic';
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import { GeneralAI } from '../src/turn/ai/generalAi.js';
import type { TurnGeneral } from '../src/turn/types.js';
import { GeneralAI, shouldUseNationAi } from '../src/turn/ai/generalAi.js';
import { canUseAutomatedNationAction, canUseRulerAutomation } from '../src/turn/ai/policies.js';
import type { TurnGeneral, TurnWorldState } from '../src/turn/types.js';
import {
calculateRecentWarTurn,
resolveLegacyAiStats,
@@ -672,13 +673,13 @@ describe('legacy NPC user-chief promotion parity', () => {
expect(ai.consumePromotionPatches().generals).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0 }]);
});
it('runs automatic appointments only on the quarterly NPC nation turn', () => {
const run = (currentMonth: number) => {
it('runs automatic appointments for NPC rulers and only opted-in user rulers in quarter months', () => {
const run = (currentMonth: number, npcState = 2, enabled = 0) => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
npcState,
meta: { killturn: 100, belong: 2, use_auto_nation_promotion: enabled },
});
const user = makePromotionGeneral({
id: 2,
@@ -710,6 +711,45 @@ describe('legacy NPC user-chief promotion parity', () => {
expect(run(2)).toEqual([]);
expect(run(3)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
expect(run(3, 0)).toEqual([]);
expect(run(3, 0, 1)).toEqual([
{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' },
]);
});
it('keeps user-ruler duties individually disabled until each setting is enabled', () => {
const ruler = makePromotionGeneral({ id: 1, officerLevel: 12, npcState: 0, meta: { killturn: 0 } });
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false);
expect(canUseAutomatedNationAction(ruler, '천도')).toBe(false);
expect(canUseRulerAutomation(ruler, 'finance')).toBe(false);
ruler.meta = {
...ruler.meta,
use_auto_nation_diplomacy: 1,
use_auto_nation_capital: 1,
use_auto_nation_finance: 1,
};
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(true);
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true);
expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true);
expect(canUseRulerAutomation(ruler, 'finance')).toBe(true);
});
it('honors the existing automatic nation-turn master switch for user chiefs only', () => {
const world = {
currentYear: 190,
currentMonth: 3,
meta: {},
} as TurnWorldState;
const build = (npcState: number, useAutoNationTurn: number) =>
makePromotionGeneral({
npcState,
meta: { killturn: 0, autorun_limit: 19004, use_auto_nation_turn: useAutoNationTurn },
});
expect(shouldUseNationAi(build(0, 0), world)).toBe(false);
expect(shouldUseNationAi(build(0, 1), world)).toBe(true);
expect(shouldUseNationAi(build(2, 0), world)).toBe(true);
});
});
@@ -468,4 +468,52 @@ describe('core monthly event actions at the real month boundary', () => {
expect(lowChiefBill).toBeTypeOf('number');
expect(highChiefBill).toBe(lowChiefBill);
});
it('keeps user-ruler finance unchanged by default and applies it only after opt-in', () => {
const config: TurnWorldSnapshot['scenarioConfig'] = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: { baseGold: 0, baseRice: 0 },
environment: { mapName: map.id, unitSet: 'default' },
};
const buildFinanceWorld = (settings: {
finance: number;
autorunLimit?: number;
nationTurn?: number;
}) => {
const chief = buildGeneral(1, 1, 3);
chief.npcState = 0;
chief.officerLevel = 12;
chief.meta = {
...chief.meta,
use_auto_nation_finance: settings.finance,
...(settings.autorunLimit === undefined ? {} : { autorun_limit: settings.autorunLimit }),
...(settings.nationTurn === undefined ? {} : { use_auto_nation_turn: settings.nationTurn }),
};
const subordinate = buildGeneral(2, 1, 3);
return buildWorld([], () => new Map(), {
currentMonth: 12,
generals: [chief, subordinate],
nations: [buildNation()],
cities: [buildCity()],
});
};
const options = { scenarioConfig: config, commandEnv: buildCommandEnv(config) };
const disabled = buildFinanceWorld({ finance: 0, autorunLimit: 19101 });
const outsideAutorun = buildFinanceWorld({ finance: 1 });
const masterDisabled = buildFinanceWorld({ finance: 1, autorunLimit: 19101, nationTurn: 0 });
const enabled = buildFinanceWorld({ finance: 1, autorunLimit: 19101, nationTurn: 1 });
expect(calculateNpcNationFinance(disabled, disabled.getNationById(1)!, 12, options)).toBeNull();
expect(
calculateNpcNationFinance(outsideAutorun, outsideAutorun.getNationById(1)!, 12, options)
).toBeNull();
expect(
calculateNpcNationFinance(masterDisabled, masterDisabled.getNationById(1)!, 12, options)
).toBeNull();
expect(calculateNpcNationFinance(enabled, enabled.getNationById(1)!, 12, options)).toEqual(
expect.objectContaining({ rate: expect.any(Number), bill: expect.any(Number) })
);
});
});
@@ -215,6 +215,10 @@ describe('my information world commands', () => {
defence_train: 94,
use_treatment: 200,
use_auto_nation_turn: 0,
use_auto_nation_diplomacy: 1,
use_auto_nation_promotion: 1,
use_auto_nation_finance: 1,
use_auto_nation_capital: 1,
},
})
).resolves.toMatchObject({ ok: true });
@@ -227,6 +231,10 @@ describe('my information world commands', () => {
defence_train: 999,
use_treatment: 100,
use_auto_nation_turn: 0,
use_auto_nation_diplomacy: 1,
use_auto_nation_promotion: 1,
use_auto_nation_finance: 1,
use_auto_nation_capital: 1,
myset: 2,
},
});
+20
View File
@@ -275,6 +275,10 @@ const myGeneral = (state: FixtureState) => ({
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_promotion: 0,
use_auto_nation_finance: 0,
use_auto_nation_capital: 0,
myset: state.myset,
},
penalties: {},
@@ -1336,6 +1340,16 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
'△(훈사40)',
'× [훈련 -3,사기 -6]',
]);
const rulerAutomation = page.locator('.ruler-automation-settings');
await expect(rulerAutomation).toBeVisible();
const diplomacyAutomation = page.getByRole('checkbox', { name: '자동 외교 (불가침 제의·선전포고)' });
const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' });
for (const checkbox of [diplomacyAutomation, promotionAutomation, financeAutomation, capitalAutomation]) {
await expect(checkbox).not.toBeChecked();
await checkbox.check();
}
const desktop = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -1394,6 +1408,12 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await page.locator('#set_my_setting').click();
await expect.poll(() => state.settingMutations.length).toBe(1);
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
expect(state.settingMutations[0]).toMatchObject({
use_auto_nation_diplomacy: 1,
use_auto_nation_promotion: 1,
use_auto_nation_finance: 1,
use_auto_nation_capital: 1,
});
for (const [effectIndex, scenarioEffect] of [
'event_UnlimitedDefenceThresholdChange',
@@ -31,6 +31,10 @@ type SettingForm = {
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
use_auto_nation_diplomacy: number;
use_auto_nation_promotion: number;
use_auto_nation_finance: number;
use_auto_nation_capital: number;
};
const data = ref<MyGeneralResponse | null>(null);
@@ -63,6 +67,10 @@ const form = reactive<SettingForm>({
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_promotion: 0,
use_auto_nation_finance: 0,
use_auto_nation_capital: 0,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
@@ -164,6 +172,7 @@ const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
const showUserRulerAutomation = computed(() => showAutoNationTurn.value && (data.value?.general.npcState ?? 2) < 2);
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
const actionAvailability = computed(() => {
const general = data.value?.general;
@@ -409,6 +418,51 @@ onMounted(() => {
수뇌가 되었을 휴식 턴이어도 적당한 턴을 알아서 넣는 것을 허용합니다.
</div>
<fieldset
v-if="showUserRulerAutomation"
class="ruler-automation-settings"
:disabled="form.use_auto_nation_turn === 0"
>
<legend>사용자 군주 자동 업무</legend>
<label>
<input
v-model="form.use_auto_nation_diplomacy"
type="checkbox"
:true-value="1"
:false-value="0"
/>
자동 외교 (불가침 제의·선전포고)
</label>
<label>
<input
v-model="form.use_auto_nation_promotion"
type="checkbox"
:true-value="1"
:false-value="0"
/>
자동 수뇌 임명
</label>
<label>
<input
v-model="form.use_auto_nation_finance"
type="checkbox"
:true-value="1"
:false-value="0"
/>
자동 세율·지급률 조정
</label>
<label>
<input
v-model="form.use_auto_nation_capital"
type="checkbox"
:true-value="1"
:false-value="0"
/>
자동 천도
</label>
<div class="hint"> 모두 기본값은 꺼짐이며 자율행동 기간에 군주일 때만 적용됩니다.</div>
</fieldset>
<label class="setting-line">
수비
<select
@@ -730,6 +784,21 @@ button:disabled {
margin: 0 0 13px;
color: orange;
}
.ruler-automation-settings {
margin: 8px 0 13px;
padding: 6px 8px;
border: 1px solid #666;
}
.ruler-automation-settings legend {
padding: 0 4px;
color: skyblue;
}
.ruler-automation-settings label {
display: block;
}
.ruler-automation-settings .hint {
margin: 5px 0 0;
}
.action-button {
--legacy-button-height: 30px;
--legacy-button-bg: #225500;
+4
View File
@@ -111,6 +111,10 @@ export type TurnDaemonCommand =
defence_train?: number;
use_treatment?: number;
use_auto_nation_turn?: number;
use_auto_nation_diplomacy?: number;
use_auto_nation_promotion?: number;
use_auto_nation_finance?: number;
use_auto_nation_capital?: number;
};
}
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }