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
+15
View File
@@ -85,6 +85,21 @@ export const parseYearMonth = (value: number): [number, number] => {
return [year, month];
};
export 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;
};
export const calcCityDevRatio = (city: City): number => {
const total = city.agriculture + city.commerce + city.security + city.defence + city.wall;
const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax;
+339 -16
View File
@@ -13,6 +13,7 @@ import type { ConstraintContext } from '@sammo-ts/logic';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
import type { TurnGeneral, TurnWorldState } from '../../types.js';
@@ -26,6 +27,7 @@ import {
readRequiredMetaNumber,
roundTo,
valueFit,
withCanonicalArgumentAliases,
} from '../aiUtils.js';
import { searchAllDistanceByNationList } from '../distance.js';
import { generalActionHandlers } from '../generalAiGeneralActions.js';
@@ -48,10 +50,36 @@ const d징병 = 2;
const d직전 = 3;
const d전쟁 = 4;
export const resolveLegacyAiStats = (
general: Pick<TurnGeneral, 'injury' | 'officerLevel' | 'stats'>,
nation: Nation | null | undefined,
maxStatLevel: number
) => {
const maxLevel = Math.max(1, maxStatLevel);
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
const injuryRatio = (100 - Math.max(0, Math.min(general.injury, 100))) / 100;
const nationLevel = nation?.level ?? 0;
const leadershipBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0;
const leadershipWithBonus = (value: number): number => clampStat(clampStat(value) + leadershipBonus);
const injuredLeadership = general.stats.leadership * injuryRatio;
const injuredStrength = general.stats.strength * injuryRatio;
const injuredIntelligence = general.stats.intelligence * injuryRatio;
return {
fullLeadership: leadershipWithBonus(general.stats.leadership),
fullStrength: clampStat(general.stats.strength + Math.round(general.stats.intelligence / 4)),
fullIntelligence: clampStat(general.stats.intelligence + Math.round(general.stats.strength / 4)),
effectiveLeadership: Math.trunc(leadershipWithBonus(injuredLeadership)),
effectiveStrength: Math.trunc(clampStat(injuredStrength + Math.round(injuredIntelligence / 4))),
effectiveIntelligence: Math.trunc(clampStat(injuredIntelligence + Math.round(injuredStrength / 4))),
};
};
export class GeneralAI {
public readonly general: TurnGeneral;
public readonly city?: City;
public readonly nation?: Nation | null;
public general: TurnGeneral;
public city?: City;
public nation?: Nation | null;
public readonly world: TurnWorldState;
public readonly worldRef: AiWorldView | null;
public readonly map?: MapDefinition;
@@ -125,11 +153,15 @@ export class GeneralAI {
private devRate: Record<string, number> | null = null;
private categorizedCities = false;
private categorizedGenerals = false;
private promotionPatches: Array<{ generalId: number; officerLevel: number; officerCity: number }> = [];
private promotionNationMeta: Record<string, unknown> | null = null;
private readonly initialGeneralMeta: Record<string, unknown>;
private readonly reservedTurnProvider: AiReservedTurnProvider;
constructor(options: GeneralAIOptions) {
this.general = { ...options.general, meta: { ...options.general.meta } };
this.initialGeneralMeta = { ...options.general.meta };
this.city = options.city;
const nation =
options.nation ??
@@ -160,7 +192,51 @@ export class GeneralAI {
this.world.currentMonth,
this.general.id
);
this.rng = new RandUtil(LiteHashDRBG.build(seed));
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_SEED_TRACE ${JSON.stringify({
generalId: this.general.id,
year: this.world.currentYear,
month: this.world.currentMonth,
seedHex: Buffer.from(seed).toString('hex'),
})}\n`
);
}
const baseRng = new RandUtil(LiteHashDRBG.build(seed));
const traceRng = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id));
let traceSequence = 0;
this.rng = traceRng
? new Proxy(baseRng, {
get: (target, property, receiver) => {
const value = Reflect.get(target, property, receiver);
if (typeof value !== 'function') return value;
return (...args: unknown[]) => {
const result = Reflect.apply(value, receiver, args);
if (
['nextFloat1', 'nextRangeInt', 'nextInt', 'nextBit', 'nextBool', 'choice', 'choiceUsingWeight', 'choiceUsingWeightPair'].includes(
String(property)
)
) {
process.stdout.write(
`AI_RNG_TRACE ${JSON.stringify({
generalId: this.general.id,
sequence: traceSequence++,
method: String(property),
caller: new Error().stack?.split('\n')[2]?.trim() ?? null,
result:
result === null || ['string', 'number', 'boolean'].includes(typeof result)
? result
: Array.isArray(result)
? `[array:${result.length}]`
: '[object]',
})}\n`
);
}
return result;
};
},
})
: baseRng;
const constValues = asRecord(this.scenarioConfig.const);
this.aiConst = {
@@ -176,7 +252,7 @@ export class GeneralAI {
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
availableNationTypes: Array.isArray(constValues.availableNationType)
? constValues.availableNationType.filter((value) => typeof value === 'string')
: [],
: NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'),
};
const generalPolicy = new AutorunGeneralPolicy(
@@ -223,6 +299,14 @@ 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) {
this.chooseNpcPromotion();
} else {
this.chooseNonLordPromotion();
}
}
if (reservedTurn.action !== ACTION_REST) {
const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
if (reservedCandidate) {
@@ -243,6 +327,16 @@ export class GeneralAI {
}
const result = handler(this);
if (result) {
// Ref refreshes the cached AI state after these selected nation
// commands, before choosing the general command with the same
// RNG. The refresh includes another mixed-general type draw.
if (
['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes(
actionName
)
) {
this.reqUpdateInstance = true;
}
return result;
}
}
@@ -250,13 +344,47 @@ export class GeneralAI {
return this.buildNationCandidate(ACTION_REST, {}, 'neutral');
}
consumePromotionPatches(): {
generals: Array<{ generalId: number; officerLevel: number; officerCity: number }>;
nationMeta: Record<string, unknown> | null;
} {
const result = {
generals: this.promotionPatches,
nationMeta: this.promotionNationMeta,
};
this.promotionPatches = [];
this.promotionNationMeta = null;
return result;
}
consumePersistentGeneralMetaPatch(): { set: Record<string, unknown>; unset: string[] } {
const transientKeys = new Set([
'fullLeadership',
'fullStrength',
'fullIntelligence',
'effectiveLeadership',
'effectiveStrength',
'effectiveIntelligence',
]);
const set: Record<string, unknown> = {};
for (const [key, value] of Object.entries(this.general.meta)) {
if (transientKeys.has(key) || Object.is(this.initialGeneralMeta[key], value)) continue;
set[key] = value;
}
const unset = Object.keys(this.initialGeneralMeta).filter(
(key) => !transientKeys.has(key) && !Object.prototype.hasOwnProperty.call(this.general.meta, key)
);
return { set, unset };
}
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance();
if (!this.worldRef) {
return null;
}
const npcMessage = asRecord(this.general.meta).npcmsg;
const generalMeta = asRecord(this.general.meta);
const npcMessage = generalMeta.npcmsg ?? generalMeta.text;
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
}
@@ -336,7 +464,13 @@ export class GeneralAI {
}
for (const actionName of this.generalPolicy.priority) {
if (!this.generalPolicy.can(actionName)) {
const allowed = this.generalPolicy.can(actionName);
if (!allowed) {
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result: null })}\n`
);
}
continue;
}
const handler = generalActionHandlers[actionName];
@@ -344,6 +478,11 @@ export class GeneralAI {
continue;
}
const result = handler(this);
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result })}\n`
);
}
if (result) {
return result;
}
@@ -354,7 +493,6 @@ export class GeneralAI {
}
getDebugState(): GeneralAiDebugState {
this.updateInstance();
this.categorizeNationCities();
const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth);
const startYearMonth = joinYearMonth(this.startYear + 2, 5);
@@ -626,10 +764,35 @@ export class GeneralAI {
}
this.reqUpdateInstance = false;
const nation = this.nation;
if (!nation) {
return;
const refreshedGeneral = this.worldRef?.getGeneralById(this.general.id);
if (refreshedGeneral) {
this.general = { ...refreshedGeneral, meta: { ...refreshedGeneral.meta } };
}
const refreshedCity = this.worldRef?.getCityById(this.general.cityId);
if (refreshedCity) {
this.city = refreshedCity;
}
const refreshedNation =
this.general.nationId > 0 ? (this.worldRef?.getNationById(this.general.nationId) ?? null) : null;
if (refreshedNation !== undefined) {
this.nation = refreshedNation ? { ...refreshedNation, meta: { ...refreshedNation.meta } } : refreshedNation;
}
const nation =
this.nation ??
({
id: 0,
name: '재야',
color: '#000000',
capitalCityId: null,
chiefGeneralId: null,
gold: 0,
rice: 0,
power: 0,
level: 0,
typeCode: 'neutral',
meta: {},
} satisfies Nation);
const baseDevelCost = this.commandEnv.develCost * 12;
const nationMeta = asRecord(nation.meta);
@@ -654,6 +817,7 @@ export class GeneralAI {
}
this.calcDiplomacyState();
this.refreshLegacyFullStats();
this.genType = this.calcGenType();
void baseDevelCost;
@@ -671,16 +835,17 @@ export class GeneralAI {
if (parsedArgs === null) {
return null;
}
const constraintArgs = withCanonicalArgumentAliases(parsedArgs as Record<string, unknown>);
const constraintEnv = this.buildConstraintEnv();
const ctx: ConstraintContext = {
actorId: this.general.id,
cityId: this.city?.id,
nationId: this.general.nationId,
args: parsedArgs as Record<string, unknown>,
args: constraintArgs,
env: constraintEnv,
mode: 'full',
};
const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record<string, unknown>, {
const view = new WorldStateView(this.worldRef, constraintEnv, constraintArgs, {
general: this.general,
city: this.city,
nation: this.nation ?? null,
@@ -688,6 +853,11 @@ export class GeneralAI {
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
const result = evaluateConstraints(constraints, ctx, view);
if (result.kind !== 'allow') {
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_CONSTRAINT_TRACE ${JSON.stringify({ generalId: this.general.id, action, args: parsedArgs, result })}\n`
);
}
return null;
}
return {
@@ -708,9 +878,10 @@ export class GeneralAI {
}
private calcGenType(): number {
const leadership = this.general.stats.leadership;
const strength = Math.max(this.general.stats.strength, 1);
const intel = Math.max(this.general.stats.intelligence, 1);
const meta = asRecord(this.general.meta);
const leadership = readMetaNumber(meta, 'fullLeadership', this.general.stats.leadership);
const strength = Math.max(readMetaNumber(meta, 'fullStrength', this.general.stats.strength), 1);
const intel = Math.max(readMetaNumber(meta, 'fullIntelligence', this.general.stats.intelligence), 1);
let genType: number;
if (strength >= intel) {
@@ -736,6 +907,153 @@ export class GeneralAI {
return genType;
}
private refreshLegacyFullStats(): void {
const stats = resolveLegacyAiStats(
this.general,
this.nation,
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
);
this.general.meta = {
...this.general.meta,
...stats,
};
}
private chooseNpcPromotion(): void {
if (!this.nation || !this.worldRef) {
return;
}
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
const generals = this.worldRef
.listGenerals()
.filter((candidate) => candidate.nationId === this.nation!.id)
.sort((left, right) => {
const leftScore = left.stats.leadership * 2 + left.stats.strength + left.stats.intelligence;
const rightScore = right.stats.leadership * 2 + right.stats.strength + right.stats.intelligence;
// Ref's nation query returns primary-key order and uasort keeps
// that order when the raw-stat score ties.
return rightScore - leftScore || left.id - right.id;
});
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
for (let chiefLevel = 11; chiefLevel >= minChiefLevel; chiefLevel -= 1) {
if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) {
continue;
}
const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel);
if (oldChief) {
const newChiefProbability = this.rng.nextBool(0.1) ? 1 : 0;
// GeneralAI.php performs a second nextBool(0) call on the
// rejection path. Preserve that consumption for the shared
// nation/general AI RNG stream.
if (newChiefProbability < 1 && !this.rng.nextBool(newChiefProbability)) {
continue;
}
}
const nextChief = generals.find((candidate) => {
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4 || candidate.npcState < 2) {
return false;
}
const killturn = readRequiredMetaNumber(
asRecord(candidate.meta),
'killturn',
`generalId=${candidate.id}`
);
if (killturn < 36) {
return false;
}
if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) {
return false;
}
if (
chiefLevel !== 11 &&
chiefLevel % 2 === 1 &&
candidate.stats.intelligence < this.aiConst.chiefStatMin
) {
return false;
}
return true;
});
if (!nextChief) {
continue;
}
if (oldChief) {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
}
this.promotionPatches.push({ generalId: nextChief.id, officerLevel: chiefLevel, officerCity: 0 });
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
process.stdout.write(
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'lord', actor: this.general.id, chiefLevel, picked: nextChief.id })}\n`
);
}
effectiveOfficerLevel.set(nextChief.id, chiefLevel);
chiefSet |= 1 << chiefLevel;
}
if (this.promotionPatches.length > 0) {
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
}
}
private chooseNonLordPromotion(): void {
if (!this.nation) {
return;
}
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
const pools = [this.npcWarGenerals, this.npcCivilGenerals, this.userWarGenerals, this.userCivilGenerals];
for (let chiefLevel = minChiefLevel; chiefLevel <= 12; chiefLevel += 1) {
if (
(chiefSet & (1 << chiefLevel)) !== 0 ||
this.chiefGenerals[chiefLevel] ||
this.general.officerLevel === chiefLevel
) {
continue;
}
let picked: TurnGeneral | null = null;
for (let trial = 0; trial < 5; trial += 1) {
const pool = pools.find((candidatePool) => Object.keys(candidatePool).length > 0);
if (!pool) {
break;
}
const candidate = this.rng.choice(Object.values(pool));
if (candidate.officerLevel !== 1) {
continue;
}
if (
chiefLevel !== 11 &&
((chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) ||
(chiefLevel % 2 === 1 && candidate.stats.intelligence < this.aiConst.chiefStatMin))
) {
continue;
}
picked = candidate;
break;
}
if (!picked) {
continue;
}
picked.officerLevel = chiefLevel;
picked.meta = { ...picked.meta, officer_city: 0 };
this.promotionPatches.push({ generalId: picked.id, officerLevel: chiefLevel, officerCity: 0 });
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
process.stdout.write(
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'non-lord', actor: this.general.id, chiefLevel, picked: picked.id })}\n`
);
}
this.chiefGenerals[chiefLevel] = picked;
chiefSet |= 1 << chiefLevel;
}
if (this.promotionPatches.length > 0) {
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
}
}
private calcDiplomacyState(): void {
if (!this.nation || !this.worldRef) {
return;
@@ -815,6 +1133,11 @@ export class GeneralAI {
}
// legacy GeneralAI.php 기준: 평화/선포 상태에서 병력 보유 여부로 d징병 전환하지 않음.
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_DIPLOMACY_TRACE ${JSON.stringify({ generalId: this.general.id, warTargets, dipState: this.dipState })}\n`
);
}
}
private calcRecentWarTurn(general: TurnGeneral): number {
@@ -27,6 +27,10 @@ export const do일반내정 = (ai: GeneralAI) => {
}
const develRate = ai.calcCityDevelRate(city);
const generalMeta = asRecord(ai.general.meta);
const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership);
const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength);
const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -35,18 +39,18 @@ export const do일반내정 = (ai: GeneralAI) => {
if (develRate.trust[0] < 0.98) {
cmdList.push([
ai.buildGeneralCandidate('che_주민선정', {}, '일반내정'),
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
(leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
]);
}
if (develRate.pop[0] < 0.8) {
cmdList.push([
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001),
leadership / valueFit(develRate.pop[0], 0.001),
]);
} else if (develRate.pop[0] < 0.99) {
cmdList.push([
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
ai.general.stats.leadership / valueFit(develRate.pop[0] / 4, 0.001),
leadership / valueFit(develRate.pop[0] / 4, 0.001),
]);
}
}
@@ -55,51 +59,69 @@ export const do일반내정 = (ai: GeneralAI) => {
if (develRate.def[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'),
ai.general.stats.strength / valueFit(develRate.def[0], 0.001),
strength / valueFit(develRate.def[0], 0.001),
]);
}
if (develRate.wall[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_성벽보수', {}, '일반내정'),
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001),
strength / valueFit(develRate.wall[0], 0.001),
]);
}
if (develRate.secu[0] < 0.9) {
cmdList.push([
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1),
strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1),
]);
} else if (develRate.secu[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
ai.general.stats.strength / 2 / valueFit(develRate.secu[0], 0.001),
strength / 2 / valueFit(develRate.secu[0], 0.001),
]);
}
}
if (ai.genType & t지장) {
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 2000)
: ai.general.stats.intelligence;
// PHP's `%` coerces the FLOAT nation tech value to an integer
// before taking the remainder. Keeping the fraction here can move
// a weighted draw across a candidate boundary.
const nextTech = (Math.trunc(tech) % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 2000) : intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), weight]);
}
if (develRate.agri[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / valueFit(develRate.agri[0], 0.001, 1),
((isSpringSummer ? 1.2 : 0.8) * intelligence) / valueFit(develRate.agri[0], 0.001, 1),
]);
}
if (develRate.comm[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_상업투자', {}, '일반내정'),
((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / valueFit(develRate.comm[0], 0.001, 1),
((isSpringSummer ? 0.8 : 1.2) * intelligence) / valueFit(develRate.comm[0], 0.001, 1),
]);
}
}
return pickWeightedCandidate(ai, cmdList);
const picked = pickWeightedCandidate(ai, cmdList);
const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? [];
if (traceGeneralIds.includes(String(ai.general.id))) {
process.stdout.write(
`AI_DEVEL_TRACE ${JSON.stringify({
generalId: ai.general.id,
year: ai.world.currentYear,
month: ai.world.currentMonth,
genType: ai.genType,
city,
candidates: cmdList.flatMap(([candidate, weight]) =>
candidate ? [{ action: candidate.action, weight }] : []
),
picked: picked?.action ?? null,
})}\n`
);
}
return picked;
};
export const do긴급내정 = (ai: GeneralAI) => {
@@ -111,12 +133,13 @@ export const do긴급내정 = (ai: GeneralAI) => {
return null;
}
const trust = resolveCityTrust(city);
if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) {
const leadership = readMetaNumber(asRecord(ai.general.meta), 'effectiveLeadership', ai.general.stats.leadership);
if (trust < 70 && ai.rng.nextBool(leadership / ai.aiConst.chiefStatMin)) {
return ai.buildGeneralCandidate('che_주민선정', {}, '긴급내정');
}
if (
city.population < ai.nationPolicy.minNpcRecruitCityPopulation &&
ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin / 2)
ai.rng.nextBool(leadership / ai.aiConst.chiefStatMin / 2)
) {
return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정');
}
@@ -139,6 +162,10 @@ export const do전쟁내정 = (ai: GeneralAI) => {
return null;
}
const develRate = ai.calcCityDevelRate(city);
const generalMeta = asRecord(ai.general.meta);
const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership);
const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength);
const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -147,13 +174,13 @@ export const do전쟁내정 = (ai: GeneralAI) => {
if (develRate.trust[0] < 0.98) {
cmdList.push([
ai.buildGeneralCandidate('che_주민선정', {}, '전쟁내정'),
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
(leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
]);
}
if (develRate.pop[0] < 0.8) {
const weight = [1, 3].includes(city.frontState)
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
? leadership / valueFit(develRate.pop[0], 0.001)
: leadership / valueFit(develRate.pop[0], 0.001) / 2;
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
}
}
@@ -162,52 +189,62 @@ export const do전쟁내정 = (ai: GeneralAI) => {
if (develRate.def[0] < 0.5) {
cmdList.push([
ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'),
ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2,
strength / valueFit(develRate.def[0], 0.001) / 2,
]);
}
if (develRate.wall[0] < 0.5) {
cmdList.push([
ai.buildGeneralCandidate('che_성벽보수', {}, '전쟁내정'),
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001) / 2,
strength / valueFit(develRate.wall[0], 0.001) / 2,
]);
}
if (develRate.secu[0] < 0.5) {
cmdList.push([
ai.buildGeneralCandidate('che_치안강화', {}, '전쟁내정'),
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4,
strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4,
]);
}
}
if (ai.genType & t지장) {
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 3000)
: ai.general.stats.intelligence;
const nextTech = (Math.trunc(tech) % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 3000) : intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), weight]);
}
if (develRate.agri[0] < 0.5) {
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.agri[0], 0.001, 1);
? ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 4 / valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 2 / valueFit(develRate.agri[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
}
if (develRate.comm[0] < 0.5) {
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.comm[0], 0.001, 1);
? ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 4 / valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 2 / valueFit(develRate.comm[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
}
}
return pickWeightedCandidate(ai, cmdList);
const picked = pickWeightedCandidate(ai, cmdList);
const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? [];
if (traceGeneralIds.includes(String(ai.general.id))) {
process.stdout.write(
`AI_DEVEL_TRACE ${JSON.stringify({
generalId: ai.general.id,
year: ai.world.currentYear,
month: ai.world.currentMonth,
mode: '전쟁내정',
genType: ai.genType,
dipState: ai.dipState,
city,
nationTech: tech,
candidates: cmdList.flatMap(([candidate, weight]) =>
candidate ? [{ action: candidate.action, weight }] : []
),
picked: picked?.action ?? null,
})}\n`
);
}
return picked;
};
@@ -1,11 +1,22 @@
import { GeneralActionPipeline } from '@sammo-ts/logic';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
export const do금쌀구매 = (ai: GeneralAI) => {
const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
const trace = (stage: string, values: Record<string, unknown> = {}) => {
if (!traceEnabled) {
return;
}
process.stderr.write(
`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
);
};
const city = ai.city;
if (!city) {
trace('no-city');
return null;
}
@@ -14,8 +25,9 @@ export const do금쌀구매 = (ai: GeneralAI) => {
return null;
}
const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000;
const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000;
const generalMeta = asRecord(ai.general.meta);
const kill = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)) + 50000;
const death = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)) + 50000;
const deathRate = death / kill;
const absGold = ai.general.gold;
@@ -24,16 +36,64 @@ export const do금쌀구매 = (ai: GeneralAI) => {
const relRice = absRice * deathRate;
const baseDevelCost = ai.commandEnv.develCost * 12;
trace('resources', {
absGold,
absRice,
relGold,
relRice,
deathRate,
baseDevelCost,
canIgnoreTrader: ai.generalPolicy.can('상인무시'),
trade,
});
if (absGold + absRice < baseDevelCost * 2) {
trace('insufficient-base-resource');
return null;
}
const crewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0);
const fullLeadership = ai.general.stats.leadership;
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
const crewAmount = fullLeadership * 100;
const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
const riceCost = crewAmount / 100;
const rawGoldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const goldCost = Math.round(
actionPipeline.onCalcDomestic(
{
general: ai.general,
nation: ai.nation ?? undefined,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
},
'징병',
'cost',
rawGoldCost,
{ armType: crewType?.armType ?? 0 }
) * (ai.generalPolicy.can('모병') ? 2 : 1)
);
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
trace('recruit-cost', {
crewTypeId: crewType?.id ?? null,
crewCost: crewType?.cost ?? null,
crewRice: crewType?.rice ?? null,
tech,
crewAmount,
goldCost,
riceCost,
});
if ((relGold + relRice) * 1.5 <= goldCost + riceCost) {
return null;
@@ -55,9 +115,15 @@ export const do금쌀구매 = (ai: GeneralAI) => {
}
if (tryBuying) {
const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
const amount = valueFit(
Math.floor((relGold - relRice) / (1 + deathRate)),
100,
ai.aiConst.maxResourceActionAmount
);
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
const result = ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
trace('buy', { amount, minimumResourceActionAmount: ai.nationPolicy.minimumResourceActionAmount, result });
return result;
}
}
@@ -73,7 +139,11 @@ export const do금쌀구매 = (ai: GeneralAI) => {
}
if (trySelling) {
const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
const amount = valueFit(
Math.floor((relRice - relGold) / (1 + deathRate)),
100,
ai.aiConst.maxResourceActionAmount
);
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
}
@@ -1,4 +1,4 @@
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
import { searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
@@ -23,7 +23,8 @@ export const do국가선택 = (ai: GeneralAI) => {
return null;
}
if (ai.world.currentYear < ai.startYear + 3) {
const nations = ai.worldRef.listNations();
// Ref queries the nation table, which has no synthetic neutral row.
const nations = ai.worldRef.listNations().filter((nation) => nation.id > 0);
const nationCount = nations.length;
const notFullNationCount = nations.filter((nation) => {
const count = ai.worldRef!.listGenerals().filter((general) => general.nationId === nation.id).length;
@@ -104,18 +105,16 @@ export const do거병 = (ai: GeneralAI) => {
.map((c) => c.id)
);
for (const general of ai.worldRef.listGenerals()) {
if (general.officerLevel === 12 && general.nationId === 0) {
// Ref joins through city and checks city.nation=0. A ruler of a newly
// raised wandering nation therefore still occupies its neutral city.
if (general.officerLevel === 12 && ai.worldRef.getCityById(general.cityId)?.nationId === 0) {
occupied.add(general.cityId);
}
}
let availableNearCity = false;
const nearby = searchDistance(ai.map, ai.general.cityId, 3);
for (const [targetCityId, dist] of Object.entries(nearby)) {
const cityId = Number(targetCityId);
if (!Number.isFinite(cityId)) {
continue;
}
const nearby = searchDistanceEntries(ai.map, ai.general.cityId, 3);
for (const [cityId, dist] of nearby) {
if (occupied.has(cityId)) {
continue;
}
@@ -159,7 +158,10 @@ export const do건국 = (ai: GeneralAI) => {
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
: 'che_도적';
const colorType = ai.rng.nextRangeInt(0, 32);
const nationName = `${Array.from(ai.general.name).slice(1).join('')}`;
// Ref stores the NPC display prefix in general.name and removes it here.
// Core's installed scenario rows keep the prefix in npcState instead.
const characters = Array.from(ai.general.name);
const nationName = `${characters[0] === 'ⓝ' ? characters.slice(1).join('') : ai.general.name}`;
const result = ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
if (result) {
@@ -198,13 +200,13 @@ export const do방랑군이동 = (ai: GeneralAI) => {
if (!city || !ai.map || !ai.worldRef) {
return null;
}
const lordCities = ai.worldRef
.listGenerals()
.filter((general) => general.officerLevel === 12 && general.nationId === 0)
.map((general) => general.cityId);
if (lordCities.filter((cityId) => cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
const rulers = ai.worldRef.listGenerals().filter((general) => general.officerLevel === 12);
if (rulers.filter((general) => general.cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
return null;
}
const lordCities = rulers
.filter((general) => ai.worldRef!.getCityById(general.cityId)?.nationId === 0)
.map((general) => general.cityId);
const occupied = new Set(
ai.worldRef
@@ -222,11 +224,10 @@ export const do방랑군이동 = (ai: GeneralAI) => {
}
if (movingTargetCityId === null) {
const nearby = searchDistance(ai.map, city.id, 4);
const nearby = searchDistanceEntries(ai.map, city.id, 4);
const candidates: Array<[number, number]> = [];
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
const cityId = Number(cityIdRaw);
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
for (const [cityId, dist] of nearby) {
if (occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
@@ -6,6 +6,7 @@ import {
} from '@sammo-ts/logic/world/unitSet.js';
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
@@ -40,26 +41,46 @@ const getRequiredTech = (crewType: CrewTypeDefinition): number | null => {
};
export const do징병 = (ai: GeneralAI) => {
const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
const trace = (stage: string, values: Record<string, unknown> = {}) => {
if (traceEnabled) {
process.stdout.write(
`AI_RECRUIT_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
);
}
};
const city = ai.city;
const nation = ai.nation;
if (!city || !nation || !ai.unitSet || !ai.map) {
trace('missing-context');
return null;
}
if ([0, 1].includes(ai.dipState)) {
trace('diplomacy', { dipState: ai.dipState });
return null;
}
if (!(ai.genType & t통솔장)) {
trace('general-type', { genType: ai.genType });
return null;
}
if (ai.general.crew >= ai.nationPolicy.minWarCrew) {
trace('existing-crew', { crew: ai.general.crew, minWarCrew: ai.nationPolicy.minWarCrew });
return null;
}
const generalMeta = asRecord(ai.general.meta);
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
trace('population-policy', {
population: city.population,
populationMax: city.populationMax,
safeRatio: ai.nationPolicy.safeRecruitCityPopulationRatio,
minPopulation: ai.nationPolicy.minNpcRecruitCityPopulation,
canLimitRecruit: ai.generalPolicy.can('한계징병'),
});
if (!ai.generalPolicy.can('한계징병')) {
const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100;
if (remainPop <= 0) {
trace('population-floor', { remainPop, fullLeadership });
return null;
}
const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation;
@@ -67,6 +88,7 @@ export const do징병 = (ai: GeneralAI) => {
city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio &&
ai.rng.nextBool(remainPop / Math.max(1, maxPop))
) {
trace('population-random', { remainPop, maxPop, fullLeadership });
return null;
}
}
@@ -82,10 +104,22 @@ export const do징병 = (ai: GeneralAI) => {
) {
forcedArmType = 0;
}
const armType =
forcedArmType > 0
? forcedArmType
: ai.rng.choiceUsingWeightPair(buildRecruitArmTypeWeights(ai.general, warConfig.armTypes));
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
let armTypeDraw: number | null = null;
const armType = forcedArmType > 0
? forcedArmType
: traceEnabled
? (() => {
armTypeDraw = ai.rng.nextFloat1();
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
for (const [candidate, weight] of armTypeWeights) {
if (cursor <= weight) return candidate;
cursor -= Math.max(0, weight);
}
return armTypeWeights.at(-1)![0];
})()
: ai.rng.choiceUsingWeightPair(armTypeWeights);
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
const candidates = (ai.unitSet?.crewTypes ?? [])
.filter((crew) => crew.armType === armType)
@@ -100,11 +134,17 @@ export const do징병 = (ai: GeneralAI) => {
})
);
if (candidates.length === 0) {
trace('no-crew-type', { armType });
return null;
}
let picked = ai.rng.choiceUsingWeightPair(
candidates.map((crew) => [crew, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)])
);
trace('crew-type', {
armType,
candidates: candidates.map((crew) => [crew.id, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]),
picked: picked.id,
});
if (ai.generalPolicy.can('고급병종')) {
const currentCrewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId);
if (
@@ -130,7 +170,39 @@ export const do징병 = (ai: GeneralAI) => {
const crewTypeId = picked.id;
let crewAmount = crewAmountBase;
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
// Ref asks the concrete che_징병 command for getCost() before deciding
// whether to halve the requested crew. That path includes personality,
// traits, items, and the final integer rounding; using the raw unit price
// makes che_출세 (+20% cost) recruit a full stack incorrectly.
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const goldCost = Math.round(
actionPipeline.onCalcDomestic(
{
general: ai.general,
nation,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
},
'징병',
'cost',
rawGoldCost,
{ armType: picked.armType }
)
);
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
@@ -139,6 +211,7 @@ export const do징병 = (ai: GeneralAI) => {
const remainingGold = ai.general.gold - fullLeadership * 3;
const remainingRice = ai.general.rice - fullLeadership * 4;
if (remainingGold <= 0 || remainingRice <= 0) {
trace('reserve-floor', { remainingGold, remainingRice, fullLeadership });
return null;
}
@@ -156,8 +229,18 @@ export const do징병 = (ai: GeneralAI) => {
}
if (!ai.generalPolicy.can('한계징병') && remainingRice * 1.1 <= riceCost) {
trace('rice-cost', { remainingGold, remainingRice, goldCost, riceCost, crewAmount, crewTypeId });
return null;
}
return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
const result = ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
trace(result ? 'selected' : 'constraint', {
remainingGold,
remainingRice,
goldCost,
riceCost,
crewAmount,
crewTypeId,
});
return result;
};
@@ -1,5 +1,5 @@
import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js';
export const do후방워프 = (ai: GeneralAI) => {
@@ -20,11 +20,13 @@ export const do후방워프 = (ai: GeneralAI) => {
return null;
}
let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
// Ref uses getLeadership(false): item/officer bonuses included, injury ignored.
const fullLeadership = readMetaNumber(asRecord(ai.general.meta), 'fullLeadership', ai.general.stats.leadership);
let minRecruitPop = fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop;
if (!ai.generalPolicy.can('한계징병')) {
minRecruitPop = Math.max(
minRecruitPop,
ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
);
}
@@ -82,6 +84,12 @@ export const do후방워프 = (ai: GeneralAI) => {
return null;
}
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
process.stdout.write(
`AI_WARP_TRACE ${JSON.stringify({ generalId: ai.general.id, kind: 'rear', fullLeadership, minRecruitPop, recruitable })}\n`
);
}
return ai.buildGeneralCandidate(
'che_NPC능동',
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) },
@@ -158,7 +166,6 @@ export const do내정워프 = (ai: GeneralAI) => {
}
ai.categorizeNationCities();
ai.categorizeNationGeneral();
const candidateCities: Record<number, number> = {};
for (const candidate of Object.values(ai.supplyCities)) {
if (candidate.id === city.id) {
@@ -1,5 +1,6 @@
import type { GeneralAI } from '../../core.js';
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio, selectRecruitableCity } from '../helpers.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js';
export const doNPC후방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
@@ -12,6 +13,26 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
return null;
}
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const actionContext = (general: GeneralAI['general']) => ({
general,
nation: ai.nation,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
});
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
if (general.id === ai.general.id) {
return false;
@@ -29,16 +50,56 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
if (actionPipeline.onCalcDomestic(actionContext(general), '징집인구', 'score', 100) <= 1) {
return false;
}
return true;
});
if (candidates.length === 0) {
return null;
}
if (Object.keys(ai.supplyCities).length === 1) {
return null;
}
const picked = ai.rng.choice(candidates);
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
const destCityCandidates = selectRecruitableCity(ai, minPop);
const fullLeadership = actionPipeline.onCalcStat(
actionContext(picked),
'leadership',
picked.stats.leadership
) as number;
const minPop = Math.max(
fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop,
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
);
const destCityCandidates: Record<number, number> = {};
for (const city of Object.values(ai.backupCities)) {
const ratio = resolveCityPopRatio(city);
if (
city.id !== ai.city?.id &&
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
city.population >= minPop &&
ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio
) {
destCityCandidates[city.id] = ratio;
}
}
if (Object.keys(destCityCandidates).length === 0) {
for (const city of Object.values(ai.supplyCities)) {
const ratio = resolveCityPopRatio(city);
if (
city.id !== ai.city?.id &&
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
city.population > minPop &&
ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio
) {
// Ref contains a non-assigning `pop_ratio / 2` expression for
// front cities, so the persisted behavior keeps this weight.
destCityCandidates[city.id] = ratio;
}
}
}
if (Object.keys(destCityCandidates).length === 0) {
return null;
}
@@ -102,12 +163,14 @@ export const doNPC전방발령 = (ai: GeneralAI) => {
return null;
}
// Ref consumes the general draw before the weighted front-city draw.
// Reversing these two calls keeps the seed but assigns a different person.
const destGeneral = ai.rng.choice(candidates);
const cityCandidates = pickFrontCityWeight(ai);
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
const destGeneral = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC전방발령');
};
@@ -2,9 +2,40 @@ import type { GeneralAI } from '../core.js';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral } from '../../../types.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate } from './helpers.js';
import { buildAwardCandidate, buildSeizureCandidate } from './helpers.js';
type ResourceName = 'gold' | 'rice';
type ResourceCandidate = [{ destGeneralId: number; amount: number; isGold: boolean }, number];
const pickResourceCandidate = (
ai: GeneralAI,
action: 'award' | 'seizure',
candidates: ResourceCandidate[],
reason: string
) => {
if (candidates.length === 0) {
return null;
}
// Ref chooses the raw argument tuple first and only then checks the
// command's full constraints. The draw is therefore observable even
// when the selected command is rejected. Building/filtering every
// command before the draw shifts the shared nation/general AI stream.
const selected = ai.rng.choiceUsingWeightPair(candidates);
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
process.stdout.write(
`AI_REWARD_SELECTION_TRACE ${JSON.stringify({
engine: 'core',
actor: ai.general.id,
reason,
candidates,
selected,
})}\n`
);
}
return action === 'award'
? buildAwardCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason)
: buildSeizureCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason);
};
const clampLegacy = (value: number, min: number | null, max: number | null): number => {
if (min !== null && max !== null && max < min) {
@@ -13,13 +44,30 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num
return Math.max(min ?? -Infinity, Math.min(max ?? Infinity, value));
};
const getFullLeadership = (general: TurnGeneral): number =>
readMetaNumber(asRecord(general.meta), 'fullLeadership', general.stats.leadership);
const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
const nationLevel = ai.nation?.level ?? 0;
const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0;
const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max;
return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat));
};
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, multiplier: number): number => {
const getCrewGoldCost = (
ai: GeneralAI,
general: TurnGeneral,
baseMultiplier: number,
finalMultiplier = 1
): number => {
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
return (crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(general) * multiplier;
// Ref evaluates costWithTech() first, including its `/ 100`, and then
// applies `* 100 * baseMultiplier * finalMultiplier` from GeneralAI.
// Keeping that operation order is observable at exact resource boundaries
// (for example 3036 versus 3036.0000000000005).
return (
((((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * 100 *
baseMultiplier) *
finalMultiplier
);
};
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
@@ -35,7 +83,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number]> = [
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
@@ -50,7 +98,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
if (!canUseGeneral(general)) {
continue;
}
let required = getCrewGoldCost(ai, general, 3 * 1.1);
let required = getCrewGoldCost(ai, general, 3, 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, minimum);
}
@@ -64,14 +112,11 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'),
generals.length - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, '유저장긴급포상');
};
export const do유저장포상 = (ai: GeneralAI) => {
@@ -79,7 +124,7 @@ export const do유저장포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number, number]> = [
[
'gold',
@@ -109,7 +154,7 @@ export const do유저장포상 = (ai: GeneralAI) => {
}
let enough: number;
if (ai.userWarGenerals[general.id]) {
let required = getCrewGoldCost(ai, general, 6 * 1.1);
let required = getCrewGoldCost(ai, general, 6, 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, warMinimum);
}
@@ -126,14 +171,11 @@ export const do유저장포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'),
generals.length - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, '유저장포상');
};
export const doNPC긴급포상 = (ai: GeneralAI) => {
@@ -141,7 +183,7 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
@@ -173,14 +215,11 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'),
generals.length - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, 'NPC긴급포상');
};
export const doNPC포상 = (ai: GeneralAI) => {
@@ -188,7 +227,7 @@ export const doNPC포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
@@ -208,7 +247,7 @@ export const doNPC포상 = (ai: GeneralAI) => {
if (!canUseGeneral(general)) {
continue;
}
let required = getCrewGoldCost(ai, general, 3 * 1.1);
let required = getCrewGoldCost(ai, general, 3, 1.1);
if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, warMinimum);
}
@@ -222,10 +261,23 @@ export const doNPC포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
process.stdout.write(
`AI_REWARD_TRACE ${JSON.stringify({
engine: 'core',
actor: ai.general.id,
target: general.id,
resource: resKey,
nationResource: nation[resKey],
targetResource: general[resKey],
required,
enough,
maxResourceActionAmount: ai.maxResourceActionAmount,
amount,
})}\n`
);
}
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]);
}
for (const [index, general] of civilGenerals.entries()) {
if (general[resKey] >= civilMinimum) {
@@ -239,14 +291,11 @@ export const doNPC포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, 'NPC포상');
};
export const doNPC몰수 = (ai: GeneralAI) => {
@@ -254,7 +303,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
@@ -269,7 +318,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
break;
}
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]);
}
const nationDelta = nationMinimum * 1.5 - nation[resKey];
@@ -294,9 +343,9 @@ export const doNPC몰수 = (ai: GeneralAI) => {
break;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'seizure', candidates, 'NPC몰수');
};