fix: match scenario 2601 monthly seed progression

This commit is contained in:
2026-08-03 18:56:23 +00:00
parent 2f17584075
commit 58b9a230a7
92 changed files with 3696 additions and 587 deletions
+146 -33
View File
@@ -10,6 +10,7 @@ import type {
ScenarioConfig,
ScenarioMeta,
Troop,
GeneralTurnCommandKey,
TurnCommandProfile,
TurnCommandEnv,
UnitSetDefinition,
@@ -58,6 +59,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js';
import { withCanonicalArgumentAliases } from './ai/aiUtils.js';
import { rankMetaKey } from './rankData.js';
import {
hasScenarioStaticEventHandler,
@@ -66,6 +68,7 @@ import {
} from './scenarioStaticEvents.js';
const DEFAULT_ACTION = '휴식';
const AI_INTERNAL_GENERAL_ACTION_KEYS = ['che_NPC능동'] as const satisfies readonly GeneralTurnCommandKey[];
const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_소집해제',
@@ -96,6 +99,12 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_첩보',
'che_임관',
'che_상업투자',
// These legacy classes inherit che_상업투자::run(), including its
// unconditional checkStatChange() tail.
'che_농지개간',
'che_수비강화',
'che_성벽보수',
'che_치안강화',
'che_장비매매',
'che_장수대상임관',
'che_징병',
@@ -682,21 +691,6 @@ class WorldStateView implements StateView {
const extractArgsRecord = (value: unknown): Record<string, unknown> => asRecord(value);
const withCanonicalArgumentAliases = (args: Record<string, unknown>): Record<string, unknown> => {
const normalized = { ...args };
for (const [legacyKey, canonicalKey] of [
['destCityID', 'destCityId'],
['destNationID', 'destNationId'],
['destGeneralID', 'destGeneralId'],
['destTroopID', 'destTroopId'],
] as const) {
if (normalized[canonicalKey] === undefined && normalized[legacyKey] !== undefined) {
normalized[canonicalKey] = normalized[legacyKey];
}
}
return normalized;
};
const buildConstraintContext = (
general: TurnGeneral,
city: City | undefined,
@@ -737,6 +731,11 @@ export const createReservedTurnHandler = async (options: {
commandEnv?: TurnCommandEnv;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
calculateNpcNationFinance?: (
world: InMemoryTurnWorld,
nation: Nation,
currentMonth: number
) => Nation['meta'] | null;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -774,6 +773,17 @@ export const createReservedTurnHandler = async (options: {
};
const generalModuleLoader = new GeneralTurnCommandLoader();
const nationModuleLoader = new NationTurnCommandLoader();
// NPC AI emits a few engine-internal commands that are intentionally not
// exposed by the scenario's player command profile. Keep their definitions
// available to AI resolution without adding them to the public profile.
for (const key of AI_INTERNAL_GENERAL_ACTION_KEYS) {
const module = await generalModuleLoader.load(key);
if (!generalDefinitions.has(key)) {
generalDefinitions.set(key, module.commandSpec.createDefinition(env));
}
seenActionKeys.add(key);
applyActionContextBuilder(module);
}
for (const key of commandProfile.general) {
if (seenActionKeys.has(key)) {
continue;
@@ -830,6 +840,9 @@ export const createReservedTurnHandler = async (options: {
return {
execute(context): GeneralTurnResult {
// Legacy reads the current game_env.develcost for every command.
// Scenario const is only a fallback; the value changes with year.
env.develCost = readMetaNumber(asRecord(context.world.meta), 'develcost', env.develCost);
const worldRef = options.getWorld();
const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null;
const worldView = worldOverlay?.view ?? worldRef;
@@ -1034,6 +1047,12 @@ export const createReservedTurnHandler = async (options: {
specificContext = baseContext;
}
const actionContext = specificContext ?? baseContext;
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral };
process.stdout.write(
`AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n`
);
}
const executionDefinition = definition as unknown as {
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
@@ -1115,9 +1134,29 @@ export const createReservedTurnHandler = async (options: {
},
actionArgs
);
if (
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) &&
resolution.patches
) {
process.stdout.write(
`AI_ACTION_PATCH_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, patches: resolution.patches })}\n`
);
}
for (const troopId of resolution.deletedTroopIds ?? []) {
commandDeletedTroopIds.add(troopId);
}
for (const plan of resolution.reservedGeneralTurnPlans ?? []) {
for (let turnIdx = 0; turnIdx < plan.joinTurn; turnIdx += 1) {
options.reservedTurns.setGeneralTurn(plan.generalId, turnIdx, {
action: 'che_견문',
args: {},
});
}
options.reservedTurns.setGeneralTurn(plan.generalId, plan.joinTurn, {
action: 'che_임관',
args: { destNationId: plan.destNationId },
});
}
currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity;
@@ -1559,6 +1598,7 @@ export const createReservedTurnHandler = async (options: {
}
let hasReservedTurn = false;
let sharedAi: GeneralAI | undefined;
if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) {
let nationCommand = options.reservedTurns.getNationTurn(
currentNation.id,
@@ -1570,7 +1610,7 @@ export const createReservedTurnHandler = async (options: {
}
let nationAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
if (worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
sharedAi = new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -1587,13 +1627,66 @@ export const createReservedTurnHandler = async (options: {
generalFallback,
nationFallback,
});
const ai = sharedAi;
const candidate = ai.chooseNationTurn(nationCommand);
if (candidate) {
if (
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(
String(currentGeneral.id)
)
) {
process.stdout.write(
`AI_NATION_CANDIDATE_TRACE ${JSON.stringify({ generalId: currentGeneral.id, candidate })}\n`
);
}
nationCommand = { action: candidate.action, args: candidate.args };
}
const promotion = ai.consumePromotionPatches();
for (const entry of promotion.generals) {
const promotedGeneral = worldOverlay?.view.getGeneralById(entry.generalId);
const patch = {
officerLevel: entry.officerLevel,
...(promotedGeneral
? { meta: { ...promotedGeneral.meta, officer_city: entry.officerCity } }
: {}),
};
patches.generals.push({ id: entry.generalId, patch });
worldOverlay?.applyGeneralPatch(entry.generalId, patch);
if (entry.generalId === currentGeneral.id) {
currentGeneral = applyGeneralPatch(currentGeneral, patch);
}
}
if (promotion.nationMeta && currentNation) {
currentNation = { ...currentNation, meta: promotion.nationMeta as Nation['meta'] };
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
}
if (currentNation && currentGeneral.officerLevel === 12 && options.calculateNpcNationFinance) {
const baseWorld = options.getWorld();
const financeMeta = baseWorld
? options.calculateNpcNationFinance(baseWorld, currentNation, context.world.currentMonth)
: null;
if (financeMeta) {
currentNation = { ...currentNation, meta: financeMeta as Nation['meta'] };
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
}
}
nationAiState = ai.getDebugState();
}
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
if (
worldView &&
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
) {
process.stdout.write(
`AI_DIPLOMACY_TRACE ${JSON.stringify({
generalId: currentGeneral.id,
stage: 'after-nation-action',
entries: worldView
.listDiplomacy()
.filter((entry) => entry.fromNationId === currentGeneral.nationId && entry.state <= 1),
})}\n`
);
}
options.onActionResolved?.({
kind: 'nation',
generalId: currentGeneral.id,
@@ -1618,23 +1711,25 @@ export const createReservedTurnHandler = async (options: {
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
let generalAutorunMode = false;
if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
world: context.world,
worldRef: worldView,
reservedTurnProvider,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
commandEnv: env,
generalDefinitions,
nationDefinitions,
generalFallback,
nationFallback,
});
const ai =
sharedAi ??
new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
world: context.world,
worldRef: worldView,
reservedTurnProvider,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
commandEnv: env,
generalDefinitions,
nationDefinitions,
generalFallback,
nationFallback,
});
const candidate = ai.chooseGeneralTurn(generalCommand);
if (candidate) {
generalAutorunMode =
@@ -1642,6 +1737,18 @@ export const createReservedTurnHandler = async (options: {
JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {});
generalCommand = { action: candidate.action, args: candidate.args };
}
const aiMetaPatch = ai.consumePersistentGeneralMetaPatch();
if (Object.keys(aiMetaPatch.set).length > 0 || aiMetaPatch.unset.length > 0) {
const nextMeta = { ...currentGeneral.meta } as Record<string, unknown>;
for (const key of aiMetaPatch.unset) {
delete nextMeta[key];
}
currentGeneral = {
...currentGeneral,
meta: { ...nextMeta, ...aiMetaPatch.set } as TurnGeneral['meta'],
};
worldOverlay?.syncGeneral(currentGeneral);
}
generalAiState = ai.getDebugState();
}
const generalResult = isBlocked
@@ -1854,6 +1961,12 @@ export const createReservedTurnHandler = async (options: {
},
};
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
process.stdout.write(
`AI_GENERAL_PRE_APPLY_TRACE ${JSON.stringify({ engine: 'core', generalId: currentGeneral.id, stats: currentGeneral.stats, experience: currentGeneral.experience, dedication: currentGeneral.dedication, meta: { leadership_exp: currentGeneral.meta.leadership_exp, strength_exp: currentGeneral.meta.strength_exp, intel_exp: currentGeneral.meta.intel_exp, dex1: currentGeneral.meta.dex1, dex2: currentGeneral.meta.dex2, dex3: currentGeneral.meta.dex3, dex4: currentGeneral.meta.dex4, dex5: currentGeneral.meta.dex5 } })}\n`
);
}
const result: GeneralTurnResult = {
general: currentGeneral,
city: currentCity,