merge: align NPC AI decisions with legacy

This commit is contained in:
2026-07-25 11:24:14 +00:00
19 changed files with 1233 additions and 287 deletions
+43 -16
View File
@@ -36,7 +36,7 @@ import { WorldStateView } from './worldStateView.js';
import type { GeneralAIOptions, GeneralAiDebugState } from './types.js'; import type { GeneralAIOptions, GeneralAiDebugState } from './types.js';
const ACTION_REST = '휴식'; const ACTION_REST = '휴식';
const lastAttackableByNation = new Map<number, number>(); const lastAttackableByWorld = new WeakMap<object, Map<number, number>>();
const t무장 = 1; const t무장 = 1;
const t지장 = 2; const t지장 = 2;
@@ -129,11 +129,12 @@ export class GeneralAI {
private readonly reservedTurnProvider: AiReservedTurnProvider; private readonly reservedTurnProvider: AiReservedTurnProvider;
constructor(options: GeneralAIOptions) { constructor(options: GeneralAIOptions) {
this.general = options.general; this.general = { ...options.general, meta: { ...options.general.meta } };
this.city = options.city; this.city = options.city;
this.nation = const nation =
options.nation ?? options.nation ??
(options.general.nationId > 0 ? options.worldRef?.getNationById(options.general.nationId) ?? null : null); (options.general.nationId > 0 ? (options.worldRef?.getNationById(options.general.nationId) ?? null) : null);
this.nation = nation ? { ...nation, meta: { ...nation.meta } } : nation;
this.world = options.world; this.world = options.world;
this.worldRef = options.worldRef; this.worldRef = options.worldRef;
this.map = options.map; this.map = options.map;
@@ -255,25 +256,39 @@ export class GeneralAI {
return null; return null;
} }
const npcMessage = asRecord(this.general.meta).npcmsg;
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
}
if (this.general.npcState >= 2) {
this.general.meta = { ...this.general.meta, defence_train: 80 };
}
if (this.general.officerLevel === 12 && this.generalPolicy.can('선양')) {
const abdication = generalActionHandlers['선양']?.(this);
if (abdication) {
return abdication;
}
}
if (this.general.npcState === 5) { if (this.general.npcState === 5) {
if (this.general.nationId === 0) {
this.general.meta = { ...this.general.meta, killturn: 1 };
return { action: reservedTurn.action, args: reservedTurn.args, reason: '사망' };
}
const result = generalActionHandlers['집합']?.(this); const result = generalActionHandlers['집합']?.(this);
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop'); return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
} }
if (reservedTurn.action !== ACTION_REST) { if (reservedTurn.action !== ACTION_REST) {
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved'); return { action: reservedTurn.action, args: reservedTurn.args, reason: 'do예약턴' };
if (reservedCandidate) {
return reservedCandidate;
}
} }
if ( if (
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
) { ) {
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal'); return { action: 'che_요양', args: {}, reason: 'do요양' };
if (heal) {
return heal;
}
} }
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) { if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
@@ -293,7 +308,7 @@ export class GeneralAI {
} }
if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) { if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) {
return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user'); return { action: reservedTurn.action, args: reservedTurn.args, reason: '재야유저' };
} }
if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) { if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) {
@@ -312,6 +327,12 @@ export class GeneralAI {
if (move) { if (move) {
return move; return move;
} }
if (relYearMonth > 1) {
const disband = generalActionHandlers['해산']?.(this);
if (disband) {
return disband;
}
}
} }
for (const actionName of this.generalPolicy.priority) { for (const actionName of this.generalPolicy.priority) {
@@ -757,11 +778,17 @@ export class GeneralAI {
const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term); const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term);
const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null; const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null;
let lastAttackable = lastAttackableByNation.get(nationId) ?? let worldLastAttackable = lastAttackableByWorld.get(this.world.meta);
readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0); if (!worldLastAttackable) {
worldLastAttackable = new Map();
lastAttackableByWorld.set(this.world.meta, worldLastAttackable);
}
let lastAttackable =
worldLastAttackable.get(nationId) ?? readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0);
const markAttackable = () => { const markAttackable = () => {
lastAttackable = yearMonth; lastAttackable = yearMonth;
lastAttackableByNation.set(nationId, yearMonth); worldLastAttackable.set(nationId, yearMonth);
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
}; };
if (minWarTerm === null) { if (minWarTerm === null) {
@@ -1,7 +1,20 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { valueFit } from '../../aiUtils.js'; import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
import { pickWeightedCandidate, resolveCityTrust, t무장, t지장, t통솔장 } from './helpers.js'; import { pickWeightedCandidate, resolveCityTrust, t무장, t지장, t통솔장 } from './helpers.js';
const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5;
const initialAllowedLevel = ai.commandEnv.initialAllowedTechLevel ?? 1;
const relativeMaxLevel = valueFit(
Math.floor(relativeYear / levelIncreaseYears) + initialAllowedLevel,
1,
ai.commandEnv.maxTechLevel
);
const currentLevel = valueFit(Math.floor(tech / 1000), 0, ai.commandEnv.maxTechLevel);
return currentLevel >= relativeMaxLevel;
};
export const do일반내정 = (ai: GeneralAI) => { export const do일반내정 = (ai: GeneralAI) => {
const city = ai.city; const city = ai.city;
const nation = ai.nation; const nation = ai.nation;
@@ -14,6 +27,7 @@ export const do일반내정 = (ai: GeneralAI) => {
} }
const develRate = ai.calcCityDevelRate(city); const develRate = ai.calcCityDevelRate(city);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6; const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = []; const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -64,7 +78,13 @@ export const do일반내정 = (ai: GeneralAI) => {
} }
if (ai.genType & t지장) { if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), ai.general.stats.intelligence]); 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;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), weight]);
}
if (develRate.agri[0] < 1) { if (develRate.agri[0] < 1) {
cmdList.push([ cmdList.push([
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'), ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
@@ -119,6 +139,7 @@ export const do전쟁내정 = (ai: GeneralAI) => {
return null; return null;
} }
const develRate = ai.calcCityDevelRate(city); const develRate = ai.calcCityDevelRate(city);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6; const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = []; const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -130,10 +151,9 @@ export const do전쟁내정 = (ai: GeneralAI) => {
]); ]);
} }
if (develRate.pop[0] < 0.8) { if (develRate.pop[0] < 0.8) {
const weight = const weight = [1, 3].includes(city.frontState)
city.frontState > 0 ? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) : ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]); cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
} }
} }
@@ -160,27 +180,31 @@ export const do전쟁내정 = (ai: GeneralAI) => {
} }
if (ai.genType & t지장) { if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), ai.general.stats.intelligence]); 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;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), weight]);
}
if (develRate.agri[0] < 0.5) { if (develRate.agri[0] < 0.5) {
const weight = const weight = [1, 3].includes(city.frontState)
city.frontState > 0 ? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / 4 /
4 / valueFit(develRate.agri[0], 0.001, 1)
valueFit(develRate.agri[0], 0.001, 1) : ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / 2 /
2 / valueFit(develRate.agri[0], 0.001, 1);
valueFit(develRate.agri[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]); cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
} }
if (develRate.comm[0] < 0.5) { if (develRate.comm[0] < 0.5) {
const weight = const weight = [1, 3].includes(city.frontState)
city.frontState > 0 ? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / 4 /
4 / valueFit(develRate.comm[0], 0.001, 1)
valueFit(develRate.comm[0], 0.001, 1) : ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / 2 /
2 / valueFit(develRate.comm[0], 0.001, 1);
valueFit(develRate.comm[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]); cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
} }
} }
@@ -5,7 +5,7 @@ import { do징병 } from './recruitActions.js';
import { do전투준비, do소집해제, do출병 } from './warActions.js'; import { do전투준비, do소집해제, do출병 } from './warActions.js';
import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } from './warpActions.js'; import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } from './warpActions.js';
import { doNPC헌납, doNPC사망대비 } from './npcActions.js'; import { doNPC헌납, doNPC사망대비 } from './npcActions.js';
import { do국가선택, do중립, do거병, do건국, do방랑군이동 } from './politicsActions.js'; import { do국가선택, do중립, do거병, do건국, do해산, do선양, do방랑군이동 } from './politicsActions.js';
export { export {
do일반내정, do일반내정,
@@ -27,6 +27,8 @@ export {
do중립, do중립,
do거병, do거병,
do건국, do건국,
do해산,
do선양,
do방랑군이동, do방랑군이동,
}; };
@@ -53,5 +55,7 @@ export const generalActionHandlers: Record<
집합: do집합, 집합: do집합,
거병: do거병, 거병: do거병,
건국: do건국, 건국: do건국,
해산: do해산,
선양: do선양,
방랑군이동: do방랑군이동, 방랑군이동: do방랑군이동,
}; };
@@ -18,6 +18,27 @@ export const do국가선택 = (ai: GeneralAI) => {
} }
if (ai.rng.nextBool(0.3)) { if (ai.rng.nextBool(0.3)) {
const affinity = ai.general.affinity ?? readMetaNumber(asRecord(ai.general.meta), 'affinity', 0);
if (affinity === 999) {
return null;
}
if (ai.world.currentYear < ai.startYear + 3) {
const nations = ai.worldRef.listNations();
const nationCount = nations.length;
const notFullNationCount = nations.filter((nation) => {
const count = ai.worldRef!.listGenerals().filter((general) => general.nationId === nation.id).length;
return count < ai.commandEnv.initialNationGenLimit;
}).length;
if (nationCount === 0 || notFullNationCount === 0) {
return null;
}
const rejectProbability = Math.pow(1 / (nationCount + 1) / Math.pow(notFullNationCount, 3), 1 / 4);
if (ai.rng.nextBool(rejectProbability)) {
return null;
}
} else if (ai.rng.nextBool()) {
return null;
}
return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택'); return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택');
} }
@@ -47,13 +68,15 @@ export const do중립 = (ai: GeneralAI) => {
candidates = ['che_물자조달']; candidates = ['che_물자조달'];
} }
for (const key of candidates) { const picked = ai.buildGeneralCandidate(ai.rng.choice(candidates), {}, '중립');
const cmd = ai.buildGeneralCandidate(key, {}, '중립'); if (picked) {
if (cmd) { return picked;
return cmd;
}
} }
return ai.buildGeneralCandidate(ACTION_REST, {}, '중립'); const supply = ai.buildGeneralCandidate('che_물자조달', {}, '중립');
if (supply) {
return supply;
}
return ai.buildGeneralCandidate('che_견문', {}, '중립') ?? ai.buildGeneralCandidate(ACTION_REST, {}, '중립');
}; };
export const do거병 = (ai: GeneralAI) => { export const do거병 = (ai: GeneralAI) => {
@@ -111,13 +134,18 @@ export const do거병 = (ai: GeneralAI) => {
} }
const prop = (ai.rng.nextFloat1() * (ai.aiConst.defaultStatNpcMax + ai.aiConst.chiefStatMin)) / 2; const prop = (ai.rng.nextFloat1() * (ai.aiConst.defaultStatNpcMax + ai.aiConst.chiefStatMin)) / 2;
const ratio = (ai.general.stats.leadership + ai.general.stats.strength + ai.general.stats.intelligence) / 3; const generalMeta = asRecord(ai.general.meta);
const ratio =
(readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership) +
readMetaNumber(generalMeta, 'fullStrength', ai.general.stats.strength) +
readMetaNumber(generalMeta, 'fullIntelligence', ai.general.stats.intelligence)) /
3;
if (prop >= ratio) { if (prop >= ratio) {
return null; return null;
} }
const relYear = Math.max(0, ai.world.currentYear - ai.startYear); const initYear = readMetaNumber(asRecord(ai.world.meta), 'initYear', ai.startYear);
const more = valueFit(3 - relYear, 1, 3); const more = valueFit(3 - ai.world.currentYear + initYear, 1, 3);
if (!ai.rng.nextBool(0.0075 * more)) { if (!ai.rng.nextBool(0.0075 * more)) {
return null; return null;
} }
@@ -132,10 +160,39 @@ export const do건국 = (ai: GeneralAI) => {
ai.aiConst.availableNationTypes.length > 0 ai.aiConst.availableNationTypes.length > 0
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string) ? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
: `${prefix}def`; : `${prefix}def`;
const colorType = ai.rng.nextRangeInt(0, 34); const colorType = ai.rng.nextRangeInt(0, 32);
const nationName = ai.general.name; const nationName = `${Array.from(ai.general.name).slice(1).join('')}`;
return ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국'); const result = ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
if (result) {
const nextMeta = { ...ai.general.meta };
delete nextMeta.movingTargetCityID;
ai.general.meta = nextMeta;
}
return result;
};
export const do해산 = (ai: GeneralAI) => {
const result = ai.buildGeneralCandidate('che_해산', {}, '해산');
if (result) {
const nextMeta = { ...ai.general.meta };
delete nextMeta.movingTargetCityID;
ai.general.meta = nextMeta;
}
return result;
};
export const do선양 = (ai: GeneralAI) => {
if (!ai.worldRef) {
return null;
}
const candidates = ai.worldRef
.listGenerals()
.filter((general) => general.nationId === ai.general.nationId && general.npcState !== 5);
if (candidates.length === 0) {
return null;
}
return ai.buildGeneralCandidate('che_선양', { destGeneralID: ai.rng.choice(candidates).id }, '선양');
}; };
export const do방랑군이동 = (ai: GeneralAI) => { export const do방랑군이동 = (ai: GeneralAI) => {
@@ -143,37 +200,76 @@ export const do방랑군이동 = (ai: GeneralAI) => {
if (!city || !ai.map || !ai.worldRef) { if (!city || !ai.map || !ai.worldRef) {
return null; 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)) {
return null;
}
const occupied = new Set( const occupied = new Set(
ai.worldRef ai.worldRef
.listCities() .listCities()
.filter((c) => c.nationId !== 0) .filter((candidate) => candidate.nationId !== 0)
.map((c) => c.id) .map((candidate) => candidate.id)
); );
for (const general of ai.worldRef.listGenerals()) { for (const cityId of lordCities) {
if (general.officerLevel === 12 && general.nationId === 0) { occupied.add(cityId);
occupied.add(general.cityId);
}
} }
const nearby = searchDistance(ai.map, city.id, 4); let movingTargetCityId = readMetaNumber(asRecord(ai.general.meta), 'movingTargetCityID', 0) || null;
const candidates: Array<[number, number]> = []; if (movingTargetCityId === city.id || (movingTargetCityId !== null && occupied.has(movingTargetCityId))) {
for (const [cityIdRaw, dist] of Object.entries(nearby)) { movingTargetCityId = null;
const cityId = Number(cityIdRaw);
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
candidates.push([cityId, 1 / Math.pow(2, dist)]);
} }
if (candidates.length === 0) {
return null; if (movingTargetCityId === null) {
const nearby = searchDistance(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)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
candidates.push([cityId, 1 / Math.pow(2, dist)]);
}
if (candidates.length === 0) {
return null;
}
movingTargetCityId = ai.rng.choiceUsingWeightPair(candidates);
ai.general.meta = { ...ai.general.meta, movingTargetCityID: movingTargetCityId };
} }
const destCityId = ai.rng.choiceUsingWeightPair(candidates);
if (destCityId === city.id) { if (movingTargetCityId === city.id) {
return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동'); return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동');
} }
return ai.buildGeneralCandidate('che_이동', { destCityId }, '방랑군이동');
const distanceMap = searchDistance(ai.map, movingTargetCityId, 99);
const targetDistance = distanceMap[city.id];
if (targetDistance === undefined) {
return null;
}
const neighbors = ai.map.cities.find((candidate) => candidate.id === city.id)?.connections ?? [];
const nextCandidates: Array<[number, number]> = [];
for (const nextCityId of neighbors) {
const nextCity = ai.worldRef.getCityById(nextCityId);
if (nextCity && [5, 6].includes(nextCity.level) && !occupied.has(nextCityId)) {
nextCandidates.push([nextCityId, 10]);
}
if (distanceMap[nextCityId] !== undefined && distanceMap[nextCityId] + 1 === targetDistance) {
nextCandidates.push([nextCityId, 1]);
}
}
if (nextCandidates.length === 0) {
return null;
}
return ai.buildGeneralCandidate(
'che_이동',
{ destCityId: ai.rng.choiceUsingWeightPair(nextCandidates) },
'방랑군이동'
);
}; };
@@ -9,7 +9,7 @@ import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js'; import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js'; import { t무장, t지장, t통솔장 } from './helpers.js';
export const buildRecruitArmTypeWeights = (general: General, armTypes: WarArmTypes): Array<[number, number]> => { export const buildRecruitArmTypeWeights = (general: General, armTypes: WarArmTypes): Array<[number, number]> => {
const meta = asRecord(general.meta); const meta = asRecord(general.meta);
@@ -45,7 +45,7 @@ export const do징병 = (ai: GeneralAI) => {
if (!city || !nation || !ai.unitSet || !ai.map) { if (!city || !nation || !ai.unitSet || !ai.map) {
return null; return null;
} }
if ([0, 1].includes(ai.dipState) && ai.general.npcState < 2) { if ([0, 1].includes(ai.dipState)) {
return null; return null;
} }
if (!(ai.genType & t통솔장)) { if (!(ai.genType & t통솔장)) {
@@ -55,9 +55,10 @@ export const do징병 = (ai: GeneralAI) => {
return null; return null;
} }
const generalMeta = asRecord(ai.general.meta);
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
if (!ai.generalPolicy.can('한계징병')) { if (!ai.generalPolicy.can('한계징병')) {
const remainPop = const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100;
city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100;
if (remainPop <= 0) { if (remainPop <= 0) {
return null; return null;
} }
@@ -71,9 +72,16 @@ export const do징병 = (ai: GeneralAI) => {
} }
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0); const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const crewAmountBase = ai.general.stats.leadership * 100; const crewAmountBase = fullLeadership * 100;
const warConfig = buildWarConfig(ai.scenarioConfig, ai.unitSet); const warConfig = buildWarConfig(ai.scenarioConfig, ai.unitSet);
const forcedArmType = readMetaNumber(asRecord(ai.general.meta), 'armType', 0); let forcedArmType = readMetaNumber(asRecord(ai.general.meta), 'armType', 0);
if (
(forcedArmType === warConfig.armTypes.wizard && !(ai.genType & t지장)) ||
([warConfig.armTypes.footman, warConfig.armTypes.archer, warConfig.armTypes.cavalry].includes(forcedArmType) &&
!(ai.genType & t무장))
) {
forcedArmType = 0;
}
const armType = const armType =
forcedArmType > 0 forcedArmType > 0
? forcedArmType ? forcedArmType
@@ -123,25 +131,31 @@ export const do징병 = (ai: GeneralAI) => {
let crewAmount = crewAmountBase; let crewAmount = crewAmountBase;
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100; const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
const riceCost = crewAmount / 100; 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));
let riceCost = (picked.rice * getTechCost(tech) * expectedCrewLoss) / 100;
if (ai.general.gold <= 0 || ai.general.rice <= 0) { const remainingGold = ai.general.gold - fullLeadership * 3;
const remainingRice = ai.general.rice - fullLeadership * 4;
if (remainingGold <= 0 || remainingRice <= 0) {
return null; return null;
} }
if (ai.generalPolicy.can('모병') && ai.general.gold >= goldCost * 6) { if (ai.generalPolicy.can('모병') && remainingGold >= goldCost * 6) {
const hire = ai.buildGeneralCandidate('che_모병', { crewType: crewTypeId, amount: crewAmount }, '징병'); const hire = ai.buildGeneralCandidate('che_모병', { crewType: crewTypeId, amount: crewAmount }, '징병');
if (hire) { if (hire) {
return hire; return hire;
} }
} }
if (ai.general.gold < goldCost && ai.general.gold * 2 >= goldCost) { if (remainingGold < goldCost && remainingGold * 2 >= goldCost) {
crewAmount *= 0.5; crewAmount *= 0.5;
riceCost *= 0.5;
crewAmount = roundTo(crewAmount - 49, -2); crewAmount = roundTo(crewAmount - 49, -2);
} }
if (!ai.generalPolicy.can('한계징병') && ai.general.rice * 1.1 <= riceCost) { if (!ai.generalPolicy.can('한계징병') && remainingRice * 1.1 <= riceCost) {
return null; return null;
} }
@@ -3,7 +3,7 @@ import { valueFit } from '../../aiUtils.js';
import { pickWeightedCandidate } from './helpers.js'; import { pickWeightedCandidate } from './helpers.js';
export const do전투준비 = (ai: GeneralAI) => { export const do전투준비 = (ai: GeneralAI) => {
if ([0, 1].includes(ai.dipState) && ai.general.crew <= 0) { if ([0, 1].includes(ai.dipState)) {
return null; return null;
} }
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = []; const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -1,4 +1,5 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js'; import { t통솔장 } from './helpers.js';
export const do후방워프 = (ai: GeneralAI) => { export const do후방워프 = (ai: GeneralAI) => {
@@ -9,6 +10,9 @@ export const do후방워프 = (ai: GeneralAI) => {
if ([0, 1].includes(ai.dipState)) { if ([0, 1].includes(ai.dipState)) {
return null; return null;
} }
if (!ai.generalPolicy.can('징병')) {
return null;
}
if (!(ai.genType & t통솔장)) { if (!(ai.genType & t통솔장)) {
return null; return null;
} }
@@ -104,6 +108,7 @@ export const do전방워프 = (ai: GeneralAI) => {
} }
ai.categorizeNationCities(); ai.categorizeNationCities();
ai.categorizeNationGeneral();
const candidateCities: Record<number, number> = {}; const candidateCities: Record<number, number> = {};
for (const frontCity of Object.values(ai.frontCities)) { for (const frontCity of Object.values(ai.frontCities)) {
if (frontCity.supplyState <= 0) { if (frontCity.supplyState <= 0) {
@@ -195,4 +200,11 @@ export const do귀환 = (ai: GeneralAI) => {
return ai.buildGeneralCandidate('che_귀환', {}, '귀환'); return ai.buildGeneralCandidate('che_귀환', {}, '귀환');
}; };
export const do집합 = (ai: GeneralAI) => ai.buildGeneralCandidate('che_집합', {}, '집합'); export const do집합 = (ai: GeneralAI) => {
if (ai.general.npcState === 5) {
const killturn = readRequiredMetaNumber(asRecord(ai.general.meta), 'killturn', `generalId=${ai.general.id}`);
const nextKillturn = ((killturn + ai.rng.nextRangeInt(2, 4)) % 5) + 70;
ai.general.meta = { ...ai.general.meta, killturn: nextKillturn };
}
return ai.buildGeneralCandidate('che_집합', {}, '집합');
};
@@ -14,7 +14,25 @@ export const do천도 = (ai: GeneralAI) => {
return null; return null;
} }
const cityIds = nationCities.map((city) => city.id); const nationCityIds = new Set(nationCities.map((city) => city.id));
const connectedCityIds = new Set<number>([ai.nation.capitalCityId]);
const queue = [ai.nation.capitalCityId];
while (queue.length > 0) {
const cityId = queue.shift()!;
const connections = ai.map.cities.find((city) => city.id === cityId)?.connections ?? [];
for (const nextCityId of connections) {
if (!nationCityIds.has(nextCityId) || connectedCityIds.has(nextCityId)) {
continue;
}
connectedCityIds.add(nextCityId);
queue.push(nextCityId);
}
}
if (connectedCityIds.size <= 1) {
return null;
}
const cityIds = Array.from(connectedCityIds);
const distanceList = searchAllDistanceByCityList(ai.map, cityIds); const distanceList = searchAllDistanceByCityList(ai.map, cityIds);
const capitalId = ai.nation.capitalCityId; const capitalId = ai.nation.capitalCityId;
if (!distanceList[capitalId]) { if (!distanceList[capitalId]) {
@@ -28,7 +46,7 @@ export const do천도 = (ai: GeneralAI) => {
} }
const cityScores: Record<number, number> = {}; const cityScores: Record<number, number> = {};
for (const city of nationCities) { for (const city of nationCities.filter((candidate) => connectedCityIds.has(candidate.id))) {
const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0); const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0);
if (sumDistance <= 0) { if (sumDistance <= 0) {
continue; continue;
@@ -39,7 +57,7 @@ export const do천도 = (ai: GeneralAI) => {
const sorted = Object.entries(cityScores).sort((a, b) => b[1] - a[1]); const sorted = Object.entries(cityScores).sort((a, b) => b[1] - a[1]);
const topLimit = Math.ceil(sorted.length * 0.25); const topLimit = Math.ceil(sorted.length * 0.25);
for (let idx = 0; idx < Math.min(topLimit, sorted.length); idx += 1) { for (let idx = 0; idx <= Math.min(topLimit, sorted.length - 1); idx += 1) {
if (Number(sorted[idx][0]) === capitalId) { if (Number(sorted[idx][0]) === capitalId) {
return null; return null;
} }
@@ -62,5 +80,5 @@ export const do천도 = (ai: GeneralAI) => {
} }
} }
return ai.buildNationCandidate('che_천도', { destCityId: targetCityId }, '천도'); return ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
}; };
@@ -3,6 +3,18 @@ import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../a
import { isNeighbor } from '../../distance.js'; import { isNeighbor } from '../../distance.js';
import { resolveNationIncome } from './helpers.js'; import { resolveNationIncome } from './helpers.js';
const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5;
const initialAllowedLevel = ai.commandEnv.initialAllowedTechLevel ?? 1;
const relativeMaxLevel = Math.max(
1,
Math.min(Math.floor(relativeYear / levelIncreaseYears) + initialAllowedLevel, ai.commandEnv.maxTechLevel)
);
const techLevel = Math.max(0, Math.min(Math.floor(tech / 1000), ai.commandEnv.maxTechLevel));
return techLevel >= relativeMaxLevel;
};
export const do불가침제의 = (ai: GeneralAI) => { export const do불가침제의 = (ai: GeneralAI) => {
if (!ai.nation || ai.general.officerLevel < 12) { if (!ai.nation || ai.general.officerLevel < 12) {
return null; return null;
@@ -66,11 +78,16 @@ export const do불가침제의 = (ai: GeneralAI) => {
} }
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth)); const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth));
return ai.buildNationCandidate( const result = ai.buildNationCandidate(
'che_불가침제의', 'che_불가침제의',
{ destNationId, year: targetYear, month: targetMonth }, { destNationId, year: targetYear, month: targetMonth },
'불가침제의' '불가침제의'
); );
if (result) {
const nextTry = { ...respAssistTry, [`n${destNationId}`]: [destNationId, yearMonth] };
asRecord(ai.nation.meta).resp_assist_try = nextTry;
}
return result;
}; };
export const do선전포고 = (ai: GeneralAI) => { export const do선전포고 = (ai: GeneralAI) => {
@@ -92,6 +109,10 @@ export const do선전포고 = (ai: GeneralAI) => {
if (!ai.map || !ai.worldRef) { if (!ai.map || !ai.worldRef) {
return null; return null;
} }
const currentTech = readMetaNumber(asRecord(ai.nation.meta), 'tech', 0);
if (!isTechLimited(ai, currentTech + 1000)) {
return null;
}
const avgResources = Object.values({ const avgResources = Object.values({
...ai.npcWarGenerals, ...ai.npcWarGenerals,
@@ -134,9 +155,26 @@ export const do선전포고 = (ai: GeneralAI) => {
return null; return null;
} }
const lowTargetNations = new Set(
ai.worldRef
.listDiplomacy()
.filter((entry) => entry.fromNationId !== currentNationId && (entry.state === 0 || entry.state === 1))
.map((entry) => entry.fromNationId)
);
const weight: Record<number, number> = {}; const weight: Record<number, number> = {};
const warWeight: Record<number, number> = {};
for (const nation of neighbors) { for (const nation of neighbors) {
weight[nation.id] = 1 / Math.sqrt(nation.power + 1); const target = lowTargetNations.has(nation.id) ? warWeight : weight;
target[nation.id] = 1 / Math.sqrt(nation.power + 1);
}
if (Object.keys(weight).length === 0) {
if (Object.keys(warWeight).length === 0 || lowTargetNations.size === 0) {
return null;
}
if (ai.rng.nextBool(1 / lowTargetNations.size)) {
return null;
}
Object.assign(weight, warWeight);
} }
const destNationId = Number(ai.rng.choiceUsingWeight(weight)); const destNationId = Number(ai.rng.choiceUsingWeight(weight));
@@ -3,7 +3,10 @@ import type { City } from '@sammo-ts/logic';
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber } from '../../aiUtils.js'; import { asRecord, readMetaNumber } from '../../aiUtils.js';
export const pickWeightedCandidate = (ai: GeneralAI, list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>) => { export const pickWeightedCandidate = (
ai: GeneralAI,
list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>
) => {
const items = list.filter(([item]) => Boolean(item)) as Array< const items = list.filter(([item]) => Boolean(item)) as Array<
[ReturnType<GeneralAI['buildNationCandidate']>, number] [ReturnType<GeneralAI['buildNationCandidate']>, number]
>; >;
@@ -59,23 +62,21 @@ export const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record<num
export const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) => export const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) =>
ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason); ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason);
export const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) => export const buildSeizureCandidate = (
ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason); ai: GeneralAI,
destGeneralId: number,
amount: number,
isGold: boolean,
reason: string
) => ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
export const buildAwardCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) => export const buildAwardCandidate = (
ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason); ai: GeneralAI,
destGeneralId: number,
export const resolveAwardAmount = (ai: GeneralAI, current: number, target: number): number | null => { amount: number,
const diff = target - current; isGold: boolean,
if (diff <= 0) { reason: string
return null; ) => ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
}
const amount = Math.min(diff, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
return null;
}
return amount;
};
export const resolveNationIncome = (ai: GeneralAI): number => { export const resolveNationIncome = (ai: GeneralAI): number => {
const cities = Object.values(ai.supplyCities); const cities = Object.values(ai.supplyCities);
@@ -1,6 +1,34 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate, resolveAwardAmount } from './helpers.js'; import type { TurnGeneral } from '../../../types.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate } from './helpers.js';
type ResourceName = 'gold' | 'rice';
const clampLegacy = (value: number, min: number | null, max: number | null): number => {
if (min !== null && max !== null && max < min) {
return min;
}
return Math.max(min ?? -Infinity, Math.min(max ?? Infinity, value));
};
const getFullLeadership = (general: TurnGeneral): number =>
readMetaNumber(asRecord(general.meta), 'fullLeadership', general.stats.leadership);
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, multiplier: number): 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;
};
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
Object.values(generals).sort((lhs, rhs) =>
descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource]
);
const canUseGeneral = (general: TurnGeneral): boolean =>
readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`) > 5;
export const do유저장긴급포상 = (ai: GeneralAI) => { export const do유저장긴급포상 = (ai: GeneralAI) => {
const nation = ai.nation; const nation = ai.nation;
@@ -8,24 +36,38 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
return null; return null;
} }
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = []; const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [ const resourceMap: Array<[ResourceName, number]> = [
['gold', ai.nationPolicy.reqHumanWarUrgentGold], ['gold', ai.nationPolicy.reqHumanWarUrgentGold],
['rice', ai.nationPolicy.reqHumanWarUrgentRice], ['rice', ai.nationPolicy.reqHumanWarUrgentRice],
]; ];
for (const [resKey, required] of resourceMap) { for (const [resKey, minimum] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') { const generals = sortedByResource(ai.userWarGenerals, resKey);
continue; for (const [index, general] of generals.entries()) {
} if (general[resKey] >= minimum) {
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') { break;
continue; }
} if (!canUseGeneral(general)) {
for (const general of Object.values(ai.userWarGenerals)) {
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
continue; continue;
} }
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), amount]); let required = getCrewGoldCost(ai, general, 3 * 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, minimum);
}
const enough = required * 1.1;
if (general[resKey] >= required) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, null, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'),
generals.length - index,
]);
} }
} }
@@ -38,24 +80,56 @@ export const do유저장포상 = (ai: GeneralAI) => {
return null; return null;
} }
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = []; const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [ const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqHumanWarRecommandGold], [
['rice', ai.nationPolicy.reqHumanWarRecommandRice], 'gold',
ai.nationPolicy.reqNationGold,
ai.nationPolicy.reqHumanWarRecommandGold,
ai.nationPolicy.reqHumanDevelGold,
],
[
'rice',
ai.nationPolicy.reqNationRice,
ai.nationPolicy.reqHumanWarRecommandRice,
ai.nationPolicy.reqHumanDevelRice,
],
]; ];
for (const [resKey, required] of resourceMap) { for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') { if (nation[resKey] < nationMinimum) {
continue; continue;
} }
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') { const generals = sortedByResource(ai.userGenerals, resKey);
continue; for (const [index, general] of generals.entries()) {
} if (general[resKey] >= warMinimum) {
for (const general of Object.values(ai.userWarGenerals)) { break;
const amount = resolveAwardAmount(ai, general[resKey], required); }
if (!amount) { if (!canUseGeneral(general)) {
continue; continue;
} }
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), amount]); let enough: number;
if (ai.userWarGenerals[general.id]) {
let required = getCrewGoldCost(ai, general, 6 * 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, warMinimum);
}
enough = required * 1.2;
} else {
enough = civilMinimum * 1.2;
}
if (general[resKey] >= enough) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'),
generals.length - index,
]);
} }
} }
@@ -68,28 +142,41 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
return null; return null;
} }
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = []; const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [ const resourceMap: Array<[ResourceName, number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold / 2], ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNpcWarRice / 2], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
]; ];
for (const [resKey, required] of resourceMap) { for (const [resKey, nationMinimum, minimum] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') { if (nation[resKey] < nationMinimum) {
continue; continue;
} }
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') { const generals = sortedByResource(ai.npcWarGenerals, resKey);
continue; for (const [index, general] of generals.entries()) {
} if (general[resKey] >= minimum) {
for (const general of Object.values(ai.npcWarGenerals)) { break;
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`); }
if (killturn <= 5) { if (!canUseGeneral(general)) {
continue; continue;
} }
const amount = resolveAwardAmount(ai, general[resKey], required); let required = getCrewGoldCost(ai, general, 1.5);
if (!amount) { if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, minimum);
}
const enough = required * 1.2;
if (general[resKey] >= required) {
continue; continue;
} }
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), amount]); let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum * 0.9, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'),
generals.length - index,
]);
} }
} }
@@ -102,39 +189,60 @@ export const doNPC포상 = (ai: GeneralAI) => {
return null; return null;
} }
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = []; const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [ const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
]; ];
for (const [resKey, warReq, devReq] of resourceMap) { for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') { if (nation[resKey] < nationMinimum) {
continue; continue;
} }
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') { const warGenerals = sortedByResource(ai.npcWarGenerals, resKey);
continue; const civilGenerals = sortedByResource(ai.npcCivilGenerals, resKey);
const weightBase = Math.max(warGenerals.length, civilGenerals.length);
for (const [index, general] of warGenerals.entries()) {
if (general[resKey] >= warMinimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
let required = getCrewGoldCost(ai, general, 3 * 1.1);
if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, warMinimum);
}
const enough = required * 1.5;
if (general[resKey] >= required) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum, enough - general[resKey]);
if (nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
} }
for (const general of Object.values(ai.npcWarGenerals)) { for (const [index, general] of civilGenerals.entries()) {
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`); if (general[resKey] >= civilMinimum) {
if (killturn <= 5) { break;
}
if (!canUseGeneral(general)) {
continue; continue;
} }
const amount = resolveAwardAmount(ai, general[resKey], warReq); let amount = civilMinimum * 1.5 - general[resKey];
if (!amount) { if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue; continue;
} }
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]); amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
} candidates.push([
for (const general of Object.values(ai.npcCivilGenerals)) { buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`); weightBase - index,
if (killturn <= 5) { ]);
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], devReq);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
} }
} }
@@ -147,41 +255,46 @@ export const doNPC몰수 = (ai: GeneralAI) => {
return null; return null;
} }
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = []; const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [ const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
]; ];
for (const [resKey, warReq, devReq] of resourceMap) { for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
const nationLimit = resKey === 'gold' ? ai.nationPolicy.reqNationGold : ai.nationPolicy.reqNationRice; for (const general of sortedByResource(ai.npcCivilGenerals, resKey, true)) {
const nationEnough = nation[resKey] >= nationLimit; if (general[resKey] <= civilMinimum * 1.5) {
break;
for (const general of Object.values(ai.npcCivilGenerals)) {
if (general[resKey] <= devReq * 1.5) {
continue;
} }
const amount = Math.min(general[resKey] - devReq * 1.2, ai.maxResourceActionAmount); const amount = clampLegacy(general[resKey] - civilMinimum * 1.2, 100, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) { if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue; break;
} }
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]); candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
} }
if (!nationEnough) { const nationDelta = nationMinimum * 1.5 - nation[resKey];
for (const general of Object.values(ai.npcWarGenerals)) { if (nationDelta < 0) {
const minRes = nation[resKey] < nationLimit * 0.5 ? warReq * 2 : warReq; continue;
if (general[resKey] <= minRes) { }
continue; const takeSmallAmount = nation[resKey] >= nationMinimum;
} for (const general of sortedByResource(ai.npcWarGenerals, resKey, true)) {
const amount = Math.min(general[resKey] - minRes, ai.maxResourceActionAmount); if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) {
if (amount < ai.nationPolicy.minimumResourceActionAmount) { break;
continue;
}
candidates.push([
buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'),
amount,
]);
} }
let amount: number;
if (takeSmallAmount) {
const maxAmount = general[resKey] - warMinimum;
const minAmount = general[resKey] - warMinimum * 2;
amount = clampLegacy(Math.sqrt(minAmount * nationDelta), 0, maxAmount);
} else {
const maxAmount = general[resKey] - warMinimum;
amount = clampLegacy(Math.sqrt(maxAmount * nationDelta), 0, maxAmount);
}
if (amount < 100 || amount < ai.nationPolicy.minimumResourceActionAmount) {
break;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
} }
} }
+2 -2
View File
@@ -280,7 +280,7 @@ export class AutorunNationPolicy {
if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) { if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId); const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.npcMax : 0; const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.npcMax : 0;
const baseRice = stat.npcMax; const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.npcMax : 0;
if (this.reqNpcWarGold === 0) { if (this.reqNpcWarGold === 0) {
this.reqNpcWarGold = roundTo(baseGold * 4, -2); this.reqNpcWarGold = roundTo(baseGold * 4, -2);
} }
@@ -292,7 +292,7 @@ export class AutorunNationPolicy {
if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) { if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId); const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.max : 0; const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.max : 0;
const baseRice = stat.max; const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.max : 0;
if (this.reqHumanWarUrgentGold === 0) { if (this.reqHumanWarUrgentGold === 0) {
this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2); this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
} }
@@ -0,0 +1,570 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '@sammo-ts/logic';
import type { GeneralAI } from '../src/turn/ai/generalAi.js';
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
import { do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
import { do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
import { doNPC몰수, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
type Candidate = {
action: string;
args: Record<string, unknown>;
reason: string;
};
type ScriptedRng = {
bools: boolean[];
choices: unknown[];
weightedPairs: Array<Array<[unknown, number]>>;
nextBool: (probability?: number) => boolean;
nextFloat1: () => number;
nextRangeInt: (min: number, max: number) => number;
choice: <T>(items: T[] | Record<string, T>) => T;
choiceUsingWeight: <T extends string | number>(items: Record<T, number>) => T;
choiceUsingWeightPair: <T>(items: Array<[T, number]>) => T;
};
const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => {
const scriptedChoices = [...choices];
return {
bools: [...bools],
choices: scriptedChoices,
weightedPairs: [],
nextBool() {
return this.bools.shift() ?? false;
},
nextFloat1() {
return 0;
},
nextRangeInt(min) {
const picked = scriptedChoices.shift();
return typeof picked === 'number' ? picked : min;
},
choice<T>(items: T[] | Record<string, T>): T {
const values = Array.isArray(items) ? items : Object.values(items);
const picked = scriptedChoices.shift();
if (typeof picked === 'number' && Number.isInteger(picked) && picked >= 0 && picked < values.length) {
return values[picked]!;
}
if (picked !== undefined && values.includes(picked as T)) {
return picked as T;
}
return values[0]!;
},
choiceUsingWeight<T extends string | number>(items: Record<T, number>): T {
return this.choice(
Object.keys(items).map((key) => {
const numeric = Number(key);
return (Number.isNaN(numeric) ? key : numeric) as T;
})
);
},
choiceUsingWeightPair<T>(items: Array<[T, number]>): T {
this.weightedPairs.push(items);
const picked = scriptedChoices.shift();
if (typeof picked === 'number' && Number.isInteger(picked) && picked >= 0 && picked < items.length) {
return items[picked]![0];
}
return items[0]![0];
},
};
};
const baseGeneral = (): General => ({
id: 1,
name: '가상장수',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
injury: 0,
gold: 10_000,
rice: 10_000,
crew: 0,
crewTypeId: 1,
train: 0,
atmos: 0,
age: 30,
npcState: 2,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 100, fullLeadership: 70 },
});
const baseCity = (): City => ({
id: 1,
name: '가상도시',
nationId: 1,
level: 5,
state: 0,
population: 100_000,
populationMax: 100_000,
agriculture: 10_000,
agricultureMax: 10_000,
commerce: 10_000,
commerceMax: 10_000,
security: 10_000,
securityMax: 10_000,
supplyState: 1,
frontState: 0,
defence: 10_000,
defenceMax: 10_000,
wall: 10_000,
wallMax: 10_000,
meta: { trust: 100, trade: 100 },
});
const baseNation = (): Nation => ({
id: 1,
name: '가상국',
color: '#ffffff',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 100_000,
rice: 100_000,
power: 100,
level: 1,
typeCode: 'che_중립',
meta: { tech: 0 },
});
const makeAi = (
overrides: {
general?: Partial<General>;
city?: Partial<City>;
nation?: Partial<Nation>;
dipState?: number;
attackable?: boolean;
genType?: number;
year?: number;
startYear?: number;
rng?: ScriptedRng;
blockedActions?: string[];
nations?: Nation[];
generals?: General[];
disabledPolicyActions?: string[];
} = {}
): GeneralAI => {
const general = {
...baseGeneral(),
...overrides.general,
meta: { ...baseGeneral().meta, ...overrides.general?.meta },
};
const city = { ...baseCity(), ...overrides.city, meta: { ...baseCity().meta, ...overrides.city?.meta } };
const nation = {
...baseNation(),
...overrides.nation,
meta: { ...baseNation().meta, ...overrides.nation?.meta },
};
const rng = overrides.rng ?? makeRng();
const blocked = new Set(overrides.blockedActions ?? []);
const disabledPolicyActions = new Set(overrides.disabledPolicyActions ?? []);
const nations = overrides.nations ?? [nation];
const generals = overrides.generals ?? [general];
const candidates: Candidate[] = [];
return {
general,
city,
nation,
world: {
id: 1,
currentYear: overrides.year ?? 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0190-01-01T00:00:00Z'),
meta: { seed: 1 },
},
worldRef: {
listNations: () => nations,
listGenerals: () => generals,
listCities: () => [city],
listTroops: () => [],
listDiplomacy: () => [],
getNationById: (id: number) => nations.find((item) => item.id === id) ?? null,
getGeneralById: (id: number) => generals.find((item) => item.id === id) ?? null,
getCityById: (id: number) => (city.id === id ? city : null),
getTroopById: () => null,
getDiplomacyEntry: () => null,
},
map: {
id: 'test',
name: 'test',
cities: [
{
id: 1,
name: '가상도시',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [2],
max: {
population: 100_000,
agriculture: 10_000,
commerce: 10_000,
security: 10_000,
defence: 10_000,
wall: 10_000,
},
initial: {
population: 100_000,
agriculture: 10_000,
commerce: 10_000,
security: 10_000,
defence: 10_000,
wall: 10_000,
},
},
],
defaults: { trust: 100, trade: 100, supplyState: 1, frontState: 0 },
},
unitSet: {
id: 'test',
name: 'test',
defaultCrewTypeId: 1,
crewTypes: [
{
id: 1,
armType: 1,
name: '보병',
attack: 10,
defence: 10,
speed: 10,
avoid: 0,
magicCoef: 0,
cost: 10,
rice: 1,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
},
scenarioConfig: {
stat: { total: 300, min: 1, max: 100, npcTotal: 150, npcMax: 50, npcMin: 1, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'test' },
},
startYear: overrides.startYear ?? 180,
commandEnv: {
baseGold: 1000,
baseRice: 1000,
develCost: 10,
maxResourceActionAmount: 10_000,
minAvailableRecruitPop: 30_000,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
defaultCrewTypeId: 1,
openingPartYear: 3,
initialNationGenLimit: 10,
maxTechLevel: 10,
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
},
aiConst: {
baseGold: 1000,
baseRice: 1000,
minAvailableRecruitPop: 30_000,
maxResourceActionAmount: 10_000,
minNationalGold: 1000,
minNationalRice: 1000,
defaultStatMax: 100,
defaultStatNpcMax: 50,
chiefStatMin: 70,
npcMessageFreqByDay: 0,
availableNationTypes: [],
},
dipState: overrides.dipState ?? 0,
attackable: overrides.attackable ?? false,
genType: overrides.genType ?? 7,
rng,
maxResourceActionAmount: 10_000,
generalPolicy: {
can: (action: string) =>
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
},
nationPolicy: {
minWarCrew: 1500,
minNpcRecruitCityPopulation: 30_000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
minimumResourceActionAmount: 1000,
reqNationGold: 10_000,
reqNationRice: 12_000,
reqHumanWarRecommandGold: 20_000,
reqHumanWarRecommandRice: 20_000,
reqHumanDevelGold: 10_000,
reqHumanDevelRice: 10_000,
reqNpcWarGold: 10_000,
reqNpcWarRice: 10_000,
reqNpcDevelGold: 5_000,
reqNpcDevelRice: 5_000,
},
calcCityDevelRate: (target: City) => ({
trust: [Number(target.meta.trust ?? 0) / 100, 4],
pop: [target.population / target.populationMax, 4],
agri: [target.agriculture / target.agricultureMax, 2],
comm: [target.commerce / target.commerceMax, 2],
secu: [target.security / target.securityMax, 1],
def: [target.defence / target.defenceMax, 1],
wall: [target.wall / target.wallMax, 1],
}),
buildGeneralCandidate: (action: string, args: Record<string, unknown>, reason: string) => {
if (blocked.has(action)) {
return null;
}
const candidate = { action, args, reason };
candidates.push(candidate);
return candidate;
},
buildNationCandidate: (action: string, args: Record<string, unknown>, reason: string) => {
if (blocked.has(action)) {
return null;
}
const candidate = { action, args, reason };
candidates.push(candidate);
return candidate;
},
} as unknown as GeneralAI;
};
/**
* Expected branches are extracted from ref/sam hwe/sammo/GeneralAI.php
* at ng_compare@fe9ae978. These tests intentionally assert final command
* selection and RNG-sensitive gates, not TypeScript implementation details.
*/
describe('legacy NPC AI final-decision parity', () => {
it.each([
[0, 0],
[0, 2],
[1, 0],
[1, 2],
])('does not recruit during peace/declaration (dip=%i, npc=%i)', (dipState, npcState) => {
const ai = makeAi({ dipState, general: { npcState } });
expect(do징병(ai)).toBeNull();
});
it.each([
[1000, 1000, null],
[1000, 2000, 'che_징병'],
])(
'uses legacy casualty ranks for recruitment rice reserve (kill=%i, death=%i)',
(killCrew, deathCrew, expected) => {
const ai = makeAi({
dipState: 2,
general: {
gold: 10_000,
rice: 350,
meta: {
killturn: 100,
fullLeadership: 70,
rank_killcrew: killCrew,
rank_deathcrew: deathCrew,
},
},
rng: makeRng([], [0, 0]),
});
expect(do징병(ai)?.action ?? null).toBe(expected);
}
);
it.each([
[0, 0],
[0, 2000],
[1, 0],
[1, 2000],
])('does not train during peace/declaration (dip=%i, crew=%i)', (dipState, crew) => {
const ai = makeAi({ dipState, general: { crew, train: 0, atmos: 0 } });
expect(do전투준비(ai)).toBeNull();
});
it.each([
[180, 0, 'che_기술연구'],
[180, 1000, null],
[185, 1000, 'che_기술연구'],
[185, 2000, null],
])('respects the legacy year-based technology ceiling (year=%i, tech=%i)', (year, tech, expected) => {
const ai = makeAi({
year,
genType: 2,
nation: { rice: 100_000, meta: { tech } },
rng: makeRng([], [0]),
});
expect(do일반내정(ai)?.action ?? null).toBe(expected);
});
it('uses the legacy weighted front-state rule for wartime domestic choices', () => {
const rng = makeRng([false], [0]);
const ai = makeAi({
dipState: 4,
genType: 2,
city: {
frontState: 2,
agriculture: 1000,
agricultureMax: 10_000,
commerce: 10_000,
commerceMax: 10_000,
},
nation: { meta: { tech: 1000 } },
year: 185,
rng,
});
expect(do전쟁내정(ai)?.action).toBe('che_기술연구');
const weights = rng.weightedPairs.at(-1)!;
const agriculture = weights.find(([candidate]) => (candidate as Candidate).action === 'che_농지개간')!;
expect(agriculture[1]).toBe(420);
});
it.each([
[1500, 400, null],
[10_000, 1000, 'che_군량매매'],
[1000, 10_000, 'che_군량매매'],
[10_000, 10_000, null],
])('matches legacy gold/rice trade decisions (gold=%i, rice=%i)', (gold, rice, expected) => {
const ai = makeAi({ general: { gold, rice } });
expect(do금쌀구매(ai)?.action ?? null).toBe(expected);
});
it('randomly chooses between supply and search when national resources are sufficient', () => {
const ai = makeAi({ rng: makeRng([], [1]) });
expect(do중립(ai)?.action).toBe('che_인재탐색');
});
it('falls back supply -> inspect when the randomly selected neutral command is invalid', () => {
const ai = makeAi({
rng: makeRng([], [1]),
blockedActions: ['che_인재탐색', 'che_물자조달'],
});
expect(do중립(ai)?.action).toBe('che_견문');
});
it.each([
['affinity sentinel', { affinity: 999 }, 190, [true], null],
['late rejection', {}, 190, [true, true], null],
['late acceptance', {}, 190, [true, false], 'che_랜덤임관'],
['movement', {}, 190, [false, true], 'che_이동'],
['no action', {}, 190, [false, false], null],
])('matches legacy free-general choice: %s', (_name, general, year, bools, expected) => {
const ai = makeAi({
general: { nationId: 0, ...general },
year,
rng: makeRng(bools, [0]),
});
expect(do국가선택(ai)?.action ?? null).toBe(expected);
});
it('rejects early random enlistment when no nation exists', () => {
const ai = makeAi({
general: { nationId: 0 },
year: 181,
nations: [],
rng: makeRng([true]),
});
expect(do국가선택(ai)).toBeNull();
});
it.each([
[false, 4, 100, 100, 2000, null],
[true, 3, 100, 100, 2000, null],
[true, 4, 89, 100, 2000, null],
[true, 4, 100, 89, 2000, null],
[true, 4, 100, 100, 1000, null],
])(
'rejects deployment outside legacy war readiness (attackable=%s dip=%i train=%i atmos=%i crew=%i)',
(attackable, dipState, train, atmos, crew, expected) => {
const ai = makeAi({
attackable,
dipState,
general: { train, atmos, crew },
city: { frontState: 3 },
});
expect(do출병(ai)?.action ?? null).toBe(expected);
}
);
it('updates NPC troop-leader lifespan before selecting assembly', () => {
const ai = makeAi({
general: { npcState: 5, meta: { killturn: 69 } },
rng: makeRng([], [3]),
});
expect(do집합(ai)?.action).toBe('che_집합');
expect(ai.general.meta.killturn).toBe(72);
});
it('does not warp to the rear when recruitment is disabled', () => {
const ai = makeAi({
dipState: 4,
general: { crew: 0 },
city: { population: 10_000 },
disabledPolicyActions: ['징병'],
});
expect(do후방워프(ai)).toBeNull();
});
it('categorizes generals before weighting a front-line warp destination', () => {
const ai = makeAi({
dipState: 4,
attackable: true,
general: { crew: 2000 },
});
let categorizedGenerals = false;
ai.categorizeNationCities = () => {
ai.frontCities = { 1: { ...baseCity(), frontState: 3, important: 1, dev: 1 } };
};
ai.categorizeNationGeneral = () => {
categorizedGenerals = true;
ai.frontCities[1]!.important = 2;
};
expect(do전방워프(ai)?.action).toBe('che_NPC능동');
expect(categorizedGenerals).toBe(true);
});
it('awards a resource-poor civil user general like the legacy nation AI', () => {
const ai = makeAi();
const civilGeneral = {
...baseGeneral(),
id: 2,
npcState: 0,
gold: 0,
rice: 20_000,
meta: { killturn: 100, fullLeadership: 70 },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
ai.userGenerals = { 2: civilGeneral };
ai.userWarGenerals = {};
expect(do유저장포상(ai)?.action).toBe('che_포상');
});
it('seizes a small war-NPC surplus while the treasury is below 1.5x reserve', () => {
const ai = makeAi({ nation: { gold: 12_000, rice: 100_000 } });
const warGeneral = {
...baseGeneral(),
id: 2,
gold: 25_000,
rice: 10_000,
meta: { killturn: 100, fullLeadership: 70 },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
ai.npcCivilGenerals = {};
ai.npcWarGenerals = { 2: warGeneral };
expect(doNPC몰수(ai)?.action).toBe('che_몰수');
});
});
@@ -315,17 +315,6 @@ describe('NPC 대형 시뮬레이션', () => {
} }
}; };
const assertNationRecruitCount = (minRecruit: number) => {
const nations = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId);
const generals = world.listGenerals();
for (const nation of nations) {
const recruited = generals.filter(
(general) => general.nationId === nation.id && general.crew > 0 && general.crewTypeId > 0
);
expect(recruited.length).toBeGreaterThanOrEqual(minRecruit);
}
};
const assertWarReadiness = (minReadyCount: number, minTrain: number, minAtmos: number) => { const assertWarReadiness = (minReadyCount: number, minTrain: number, minAtmos: number) => {
const recruited = world const recruited = world
.listGenerals() .listGenerals()
@@ -405,7 +394,6 @@ describe('NPC 대형 시뮬레이션', () => {
['180-11', () => assertCityTrust(90)], ['180-11', () => assertCityTrust(90)],
['181-01', () => assertNationGeneralCount(10)], ['181-01', () => assertNationGeneralCount(10)],
['182-01', () => assertDomesticGrowth()], ['182-01', () => assertDomesticGrowth()],
['182-10', () => assertNationRecruitCount(5)],
['183-01', () => assertWarReadiness(10, 70, 70)], ['183-01', () => assertWarReadiness(10, 70, 70)],
['183-02', () => assertDispatchRecorded(183, 1, 1)], ['183-02', () => assertDispatchRecorded(183, 1, 1)],
['183-07', () => assertNoNeutralCities()], ['183-07', () => assertNoNeutralCities()],
@@ -109,11 +109,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
const pushNationGenerals = (nationId: number, cityId: number) => { const pushNationGenerals = (nationId: number, cityId: number) => {
const leaderId = nextId++; const leaderId = nextId++;
generals.push( generals.push(
createNpcGeneral(leaderId, cityId, nationId, 12, { createNpcGeneral(
leadership: 100, leaderId,
strength: 90, cityId,
intelligence: 40, nationId,
}, 1) 12,
{
leadership: 100,
strength: 90,
intelligence: 40,
},
1
)
); );
for (let i = 0; i < 9; i += 1) { for (let i = 0; i < 9; i += 1) {
generals.push( generals.push(
@@ -317,6 +324,8 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel); expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel);
expect(secondRecruitCost).not.toBeNull(); expect(secondRecruitCost).not.toBeNull();
expect(secondRecruitCost ?? 0).toBeGreaterThan(firstRecruitCost); // Nation awards can occur in the same tick and make the general's net
// gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke.
}, 60000); }, 60000);
}); });
@@ -113,24 +113,30 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const generals: TurnGeneral[] = []; const generals: TurnGeneral[] = [];
let nextId = 1; let nextId = 1;
const pushNationGenerals = (nationId: number, cityId: number) => { const pushNationGenerals = (nationId: number, cityId: number) => {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 12, { generals.push(
leadership: 90, createNpcGeneral(nextId++, cityId, nationId, 12, {
strength: 80, leadership: 90,
intelligence: 40,
}));
for (let i = 0; i < 9; i += 1) {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 80, strength: 80,
intelligence: 30, intelligence: 40,
})); })
);
for (let i = 0; i < 9; i += 1) {
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 80,
intelligence: 30,
})
);
} }
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, { generals.push(
leadership: 70, createNpcGeneral(nextId++, cityId, nationId, 2, {
strength: 30, leadership: 70,
intelligence: 80, strength: 30,
})); intelligence: 80,
})
);
} }
}; };
pushNationGenerals(1, cityA1.id); pushNationGenerals(1, cityA1.id);
@@ -280,9 +286,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const findDiplomacyEntry = (world: InMemoryTurnWorld | null) => { const findDiplomacyEntry = (world: InMemoryTurnWorld | null) => {
const diplomacyEntries = world?.listDiplomacy() ?? []; const diplomacyEntries = world?.listDiplomacy() ?? [];
return diplomacyEntries.find((entry) => return diplomacyEntries.find(
(entry.fromNationId === 1 && entry.toNationId === 2) || (entry) =>
(entry.fromNationId === 2 && entry.toNationId === 1) (entry.fromNationId === 1 && entry.toNationId === 2) ||
(entry.fromNationId === 2 && entry.toNationId === 1)
); );
}; };
@@ -321,15 +328,12 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(declareEntry?.state).toBe(DIPLOMACY_STATE.DECLARATION); expect(declareEntry?.state).toBe(DIPLOMACY_STATE.DECLARATION);
const remainTurns = Math.max(0, (declareEntry?.term ?? 0) - 1); const remainTurns = Math.max(0, (declareEntry?.term ?? 0) - 1);
const preWarTarget = addMonths( const preWarTarget = addMonths(world!.getState().currentYear, world!.getState().currentMonth, remainTurns);
world!.getState().currentYear,
world!.getState().currentMonth,
remainTurns
);
await runUntil((current) => await runUntil(
current.currentYear > preWarTarget.year || (current) =>
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month) current.currentYear > preWarTarget.year ||
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month)
); );
const preWarEntry = findDiplomacyEntry(world); const preWarEntry = findDiplomacyEntry(world);
@@ -348,10 +352,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
debug.dumpWatched('개전 직전 병력 부족'); debug.dumpWatched('개전 직전 병력 부족');
} }
expect(recruited.length).toBeGreaterThanOrEqual(5); expect(recruited.length).toBeGreaterThanOrEqual(5);
const battleReady = recruited.filter((general) => general.train >= 90 && general.atmos >= 90);
expect(battleReady.length).toBeGreaterThanOrEqual(5);
let frontRecruited = 0; let frontRecruited = 0;
for (const general of recruited) { for (const general of recruited) {
expect(general.train).toBeGreaterThanOrEqual(90);
expect(general.atmos).toBeGreaterThanOrEqual(90);
const city = world.getCityById(general.cityId); const city = world.getCityById(general.cityId);
if (city && city.frontState > 0) { if (city && city.frontState > 0) {
frontRecruited += 1; frontRecruited += 1;
@@ -363,9 +367,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(frontRecruited).toBeGreaterThan(0); expect(frontRecruited).toBeGreaterThan(0);
const warTarget = addMonths(preWarTarget.year, preWarTarget.month, 1); const warTarget = addMonths(preWarTarget.year, preWarTarget.month, 1);
await runUntil((current) => await runUntil(
current.currentYear > warTarget.year || (current) =>
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month) current.currentYear > warTarget.year ||
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month)
); );
const warEntry = findDiplomacyEntry(world); const warEntry = findDiplomacyEntry(world);
@@ -381,9 +386,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
while (prevNation1Cities > 0 && guard < 120) { while (prevNation1Cities > 0 && guard < 120) {
const next = addMonths(world.getState().currentYear, world.getState().currentMonth, 1); const next = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) => await runUntil(
current.currentYear > next.year || (current) =>
(current.currentYear === next.year && current.currentMonth >= next.month) current.currentYear > next.year ||
(current.currentYear === next.year && current.currentMonth >= next.month)
); );
const nowNation1Cities = countCities(1, world); const nowNation1Cities = countCities(1, world);
@@ -415,9 +421,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(allOwnedByNation2).toBe(true); expect(allOwnedByNation2).toBe(true);
const unifyCheckTarget = addMonths(world.getState().currentYear, world.getState().currentMonth, 1); const unifyCheckTarget = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) => await runUntil(
current.currentYear > unifyCheckTarget.year || (current) =>
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month) current.currentYear > unifyCheckTarget.year ||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
); );
const worldMeta = world.getState().meta as Record<string, unknown>; const worldMeta = world.getState().meta as Record<string, unknown>;
@@ -434,9 +441,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(hasUnificationLog).toBe(true); expect(hasUnificationLog).toBe(true);
const dispatchWindowEnd = addMonths(warTarget.year, warTarget.month, 2); const dispatchWindowEnd = addMonths(warTarget.year, warTarget.month, 2);
await runUntil((current) => await runUntil(
current.currentYear > dispatchWindowEnd.year || (current) =>
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month) current.currentYear > dispatchWindowEnd.year ||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
); );
const dispatchKeys: string[] = []; const dispatchKeys: string[] = [];
+21 -3
View File
@@ -116,7 +116,9 @@ Nation-level choices run only for NPCs (`npc >= 2`) or for autorun users:
- **Resource distribution** - **Resource distribution**
- `do유저장포상`, `doNPC포상`, `doNPC몰수` - `do유저장포상`, `doNPC포상`, `doNPC몰수`
- uses resource floors (`reqNation*`, `reqNPC*`, `reqHuman*`) - uses resource floors (`reqNation*`, `reqNPC*`, `reqHuman*`)
- weighted by target general's deficit and recent activity. - sorts by each general's gold/rice, excludes inactive (`killturn <= 5`)
targets, and preserves the legacy geometric-mean amounts and candidate
weights.
- **Diplomacy** - **Diplomacy**
- `do불가침제의`: respond to assistance requests with NAP offer. - `do불가침제의`: respond to assistance requests with NAP offer.
- `do선전포고`: probabilistic declaration when strong enough. - `do선전포고`: probabilistic declaration when strong enough.
@@ -131,8 +133,10 @@ General-level decisions are layered:
2. Reserved command is honored if valid (unless `휴식`). 2. Reserved command is honored if valid (unless `휴식`).
3. Immediate recovery if `injury > cureThreshold`. 3. Immediate recovery if `injury > cureThreshold`.
4. Special cases: 4. Special cases:
- NPC troop leaders (type 5) always `집합`. - A nationless NPC troop leader shortens `killturn` and keeps its reserved
- wanderers decide on founding / moving / disbanding. command; an affiliated type-5 leader refreshes `killturn` and uses `집합`.
- wandering lords decide on founding, one-edge movement toward a cached
target, or disbanding.
5. Iterate policy `priority`, invoking `do{Action}`. 5. Iterate policy `priority`, invoking `do{Action}`.
6. Fallback to `do중립`. 6. Fallback to `do중립`.
@@ -189,3 +193,17 @@ To port the AI to an in-memory state model without behavior drift:
These guidelines mirror the current "derive once, then select via priority" These guidelines mirror the current "derive once, then select via priority"
pattern and minimize resimulation deltas in the rewrite. pattern and minimize resimulation deltas in the rewrite.
## Migrated decision-parity regression
`app/game-engine/test/generalAiLegacyDecisionParity.test.ts` records focused
final-command expectations extracted from `ref/sam` `ng_compare@fe9ae978`.
Its matrix varies diplomacy/war state, city development and population,
technology/year ceilings, general gold/rice and casualty ranks, stats and
affinity, reserved/special NPC state, nation treasury reserves, and command
availability. It also asserts RNG-sensitive candidate weights where consuming
the same random branch is part of the final decision.
This is compatibility evidence for the represented decision branches. The
long-running NPC scenario suites remain smoke tests and are not a substitute
for this branch-level matrix.
+24 -22
View File
@@ -4,7 +4,7 @@
- Audit date: 2026-07-25 - Audit date: 2026-07-25
- Code baseline: `main@46ae79dbe7a0fb64aff9bdcc76eadafd75e10c9e` - Code baseline: `main@46ae79dbe7a0fb64aff9bdcc76eadafd75e10c9e`
- Executed test sources: 66 TypeScript `*.test.ts` files - Executed test sources: 67 TypeScript `*.test.ts` files
- Excluded from the source count: ignored `dist/` outputs and non-executable - Excluded from the source count: ignored `dist/` outputs and non-executable
fixtures/helpers fixtures/helpers
- Historical generated copies removed by this audit: - Historical generated copies removed by this audit:
@@ -61,25 +61,26 @@ environment-dependent check.
### `app/game-engine` ### `app/game-engine`
| Test source | Disposition | Layer | What it establishes | | Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. | | `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. | | `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. | | `test/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. |
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. | | `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. | | `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. | | `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. Broad thresholds remain intentional smoke bounds; unconditional diagnostic output was removed. | | `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationTechResearch.test.ts` | kept | smoke | Long-running monotonic tech growth and higher later recruitment cost. | | `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. The implementation-specific early recruitment bound was removed because legacy AI forbids peace/declaration recruitment; remaining thresholds are smoke bounds. |
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. | | `test/npcNationTechResearch.test.ts` | corrected | smoke | Long-running monotonic tech growth. Net per-tick general gold is not treated as recruitment price because nation awards can occur in the same tick. |
| `test/npcNationWarDeclaration.test.ts` | kept | smoke | Declaration, war transition, preparation, conquest, unification, and dispatch occurrence in one end-to-end NPC scenario. | | `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. | | `test/npcNationWarDeclaration.test.ts` | corrected | smoke | Declaration, war transition, a battle-ready cohort, front deployment, conquest, unification, and dispatch occurrence without assuming every recruit is already fully trained. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. | | `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. | | `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. | | `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. | | `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. | | `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. | | `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
### `packages/logic` ### `packages/logic`
@@ -145,8 +146,9 @@ to legacy-compatible:
- battle compatibility is supported only by the differential fixtures and the - battle compatibility is supported only by the differential fixtures and the
suites explicitly marked `compatibility`; suites explicitly marked `compatibility`;
- broad NPC and multi-command scenarios guard liveness/invariants, not exact - broad NPC and multi-command scenarios guard liveness/invariants, while
monthly balance or complete side effects; `generalAiLegacyDecisionParity.test.ts` establishes exact final choices only
for its explicitly represented input branches;
- DB/Redis/PM2 claims require their integration suites to run rather than skip; - DB/Redis/PM2 claims require their integration suites to run rather than skip;
- changing an implementation and observing a green unit suite is still not a - changing an implementation and observing a green unit suite is still not a
substitute for a new PHP trace when compatibility-sensitive behavior changes. substitute for a new PHP trace when compatibility-sensitive behavior changes.
@@ -403,7 +403,11 @@ export class ActionDefinition<
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null; const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
const defenderGenerals = generals.filter( const defenderGenerals = generals.filter(
(general) => general.cityId === defenderCity.id && general.nationId === defenderCity.nationId (general) =>
general.cityId === defenderCity.id &&
general.nationId === defenderCity.nationId &&
general.crew > 0 &&
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
); );
const battle = resolveWarBattle({ const battle = resolveWarBattle({