fix: match scenario 2601 monthly seed progression
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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몰수');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user