merge: align NPC AI decisions with legacy
This commit is contained in:
@@ -36,7 +36,7 @@ import { WorldStateView } from './worldStateView.js';
|
||||
import type { GeneralAIOptions, GeneralAiDebugState } from './types.js';
|
||||
|
||||
const ACTION_REST = '휴식';
|
||||
const lastAttackableByNation = new Map<number, number>();
|
||||
const lastAttackableByWorld = new WeakMap<object, Map<number, number>>();
|
||||
|
||||
const t무장 = 1;
|
||||
const t지장 = 2;
|
||||
@@ -129,11 +129,12 @@ export class GeneralAI {
|
||||
private readonly reservedTurnProvider: AiReservedTurnProvider;
|
||||
|
||||
constructor(options: GeneralAIOptions) {
|
||||
this.general = options.general;
|
||||
this.general = { ...options.general, meta: { ...options.general.meta } };
|
||||
this.city = options.city;
|
||||
this.nation =
|
||||
const 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.worldRef = options.worldRef;
|
||||
this.map = options.map;
|
||||
@@ -255,25 +256,39 @@ export class GeneralAI {
|
||||
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.nationId === 0) {
|
||||
this.general.meta = { ...this.general.meta, killturn: 1 };
|
||||
return { action: reservedTurn.action, args: reservedTurn.args, reason: '사망' };
|
||||
}
|
||||
const result = generalActionHandlers['집합']?.(this);
|
||||
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
|
||||
}
|
||||
|
||||
if (reservedTurn.action !== ACTION_REST) {
|
||||
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
||||
if (reservedCandidate) {
|
||||
return reservedCandidate;
|
||||
}
|
||||
return { action: reservedTurn.action, args: reservedTurn.args, reason: 'do예약턴' };
|
||||
}
|
||||
|
||||
if (
|
||||
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
|
||||
) {
|
||||
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal');
|
||||
if (heal) {
|
||||
return heal;
|
||||
}
|
||||
return { action: 'che_요양', args: {}, reason: 'do요양' };
|
||||
}
|
||||
|
||||
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('국가선택')) {
|
||||
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) {
|
||||
@@ -312,6 +327,12 @@ export class GeneralAI {
|
||||
if (move) {
|
||||
return move;
|
||||
}
|
||||
if (relYearMonth > 1) {
|
||||
const disband = generalActionHandlers['해산']?.(this);
|
||||
if (disband) {
|
||||
return disband;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null;
|
||||
let lastAttackable = lastAttackableByNation.get(nationId) ??
|
||||
readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0);
|
||||
let worldLastAttackable = lastAttackableByWorld.get(this.world.meta);
|
||||
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 = () => {
|
||||
lastAttackable = yearMonth;
|
||||
lastAttackableByNation.set(nationId, yearMonth);
|
||||
worldLastAttackable.set(nationId, yearMonth);
|
||||
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
|
||||
};
|
||||
|
||||
if (minWarTerm === null) {
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
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';
|
||||
|
||||
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) => {
|
||||
const city = ai.city;
|
||||
const nation = ai.nation;
|
||||
@@ -14,6 +27,7 @@ export const do일반내정 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
const develRate = ai.calcCityDevelRate(city);
|
||||
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
||||
const isSpringSummer = ai.world.currentMonth <= 6;
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
|
||||
@@ -64,7 +78,13 @@ export const do일반내정 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
|
||||
@@ -119,6 +139,7 @@ export const do전쟁내정 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
const develRate = ai.calcCityDevelRate(city);
|
||||
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
||||
const isSpringSummer = ai.world.currentMonth <= 6;
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
|
||||
@@ -130,10 +151,9 @@ export const do전쟁내정 = (ai: GeneralAI) => {
|
||||
]);
|
||||
}
|
||||
if (develRate.pop[0] < 0.8) {
|
||||
const weight =
|
||||
city.frontState > 0
|
||||
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
|
||||
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
|
||||
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;
|
||||
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
}
|
||||
@@ -160,27 +180,31 @@ export const do전쟁내정 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
const weight =
|
||||
city.frontState > 0
|
||||
? ((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);
|
||||
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);
|
||||
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
if (develRate.comm[0] < 0.5) {
|
||||
const weight =
|
||||
city.frontState > 0
|
||||
? ((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);
|
||||
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);
|
||||
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내정워프, do귀환, do집합 } from './warpActions.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 {
|
||||
do일반내정,
|
||||
@@ -27,6 +27,8 @@ export {
|
||||
do중립,
|
||||
do거병,
|
||||
do건국,
|
||||
do해산,
|
||||
do선양,
|
||||
do방랑군이동,
|
||||
};
|
||||
|
||||
@@ -53,5 +55,7 @@ export const generalActionHandlers: Record<
|
||||
집합: do집합,
|
||||
거병: do거병,
|
||||
건국: do건국,
|
||||
해산: do해산,
|
||||
선양: do선양,
|
||||
방랑군이동: do방랑군이동,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,27 @@ export const do국가선택 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
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_랜덤임관', {}, '국가선택');
|
||||
}
|
||||
|
||||
@@ -47,13 +68,15 @@ export const do중립 = (ai: GeneralAI) => {
|
||||
candidates = ['che_물자조달'];
|
||||
}
|
||||
|
||||
for (const key of candidates) {
|
||||
const cmd = ai.buildGeneralCandidate(key, {}, '중립');
|
||||
if (cmd) {
|
||||
return cmd;
|
||||
}
|
||||
const picked = ai.buildGeneralCandidate(ai.rng.choice(candidates), {}, '중립');
|
||||
if (picked) {
|
||||
return picked;
|
||||
}
|
||||
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) => {
|
||||
@@ -111,13 +134,18 @@ export const do거병 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relYear = Math.max(0, ai.world.currentYear - ai.startYear);
|
||||
const more = valueFit(3 - relYear, 1, 3);
|
||||
const initYear = readMetaNumber(asRecord(ai.world.meta), 'initYear', ai.startYear);
|
||||
const more = valueFit(3 - ai.world.currentYear + initYear, 1, 3);
|
||||
if (!ai.rng.nextBool(0.0075 * more)) {
|
||||
return null;
|
||||
}
|
||||
@@ -132,10 +160,39 @@ export const do건국 = (ai: GeneralAI) => {
|
||||
ai.aiConst.availableNationTypes.length > 0
|
||||
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
|
||||
: `${prefix}def`;
|
||||
const colorType = ai.rng.nextRangeInt(0, 34);
|
||||
const nationName = ai.general.name;
|
||||
const colorType = ai.rng.nextRangeInt(0, 32);
|
||||
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) => {
|
||||
@@ -143,37 +200,76 @@ 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)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const occupied = new Set(
|
||||
ai.worldRef
|
||||
.listCities()
|
||||
.filter((c) => c.nationId !== 0)
|
||||
.map((c) => c.id)
|
||||
.filter((candidate) => candidate.nationId !== 0)
|
||||
.map((candidate) => candidate.id)
|
||||
);
|
||||
for (const general of ai.worldRef.listGenerals()) {
|
||||
if (general.officerLevel === 12 && general.nationId === 0) {
|
||||
occupied.add(general.cityId);
|
||||
}
|
||||
for (const cityId of lordCities) {
|
||||
occupied.add(cityId);
|
||||
}
|
||||
|
||||
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)]);
|
||||
let movingTargetCityId = readMetaNumber(asRecord(ai.general.meta), 'movingTargetCityID', 0) || null;
|
||||
if (movingTargetCityId === city.id || (movingTargetCityId !== null && occupied.has(movingTargetCityId))) {
|
||||
movingTargetCityId = null;
|
||||
}
|
||||
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_이동', { 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 { 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]> => {
|
||||
const meta = asRecord(general.meta);
|
||||
@@ -45,7 +45,7 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
if (!city || !nation || !ai.unitSet || !ai.map) {
|
||||
return null;
|
||||
}
|
||||
if ([0, 1].includes(ai.dipState) && ai.general.npcState < 2) {
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
if (!(ai.genType & t통솔장)) {
|
||||
@@ -55,9 +55,10 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generalMeta = asRecord(ai.general.meta);
|
||||
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
|
||||
if (!ai.generalPolicy.can('한계징병')) {
|
||||
const remainPop =
|
||||
city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100;
|
||||
const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100;
|
||||
if (remainPop <= 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -71,9 +72,16 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
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 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 =
|
||||
forcedArmType > 0
|
||||
? forcedArmType
|
||||
@@ -123,25 +131,31 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
|
||||
let crewAmount = crewAmountBase;
|
||||
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;
|
||||
}
|
||||
|
||||
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 }, '징병');
|
||||
if (hire) {
|
||||
return hire;
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.general.gold < goldCost && ai.general.gold * 2 >= goldCost) {
|
||||
if (remainingGold < goldCost && remainingGold * 2 >= goldCost) {
|
||||
crewAmount *= 0.5;
|
||||
riceCost *= 0.5;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { valueFit } from '../../aiUtils.js';
|
||||
import { pickWeightedCandidate } from './helpers.js';
|
||||
|
||||
export const do전투준비 = (ai: GeneralAI) => {
|
||||
if ([0, 1].includes(ai.dipState) && ai.general.crew <= 0) {
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GeneralAI } from '../core.js';
|
||||
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
|
||||
import { t통솔장 } from './helpers.js';
|
||||
|
||||
export const do후방워프 = (ai: GeneralAI) => {
|
||||
@@ -9,6 +10,9 @@ export const do후방워프 = (ai: GeneralAI) => {
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
return null;
|
||||
}
|
||||
if (!ai.generalPolicy.can('징병')) {
|
||||
return null;
|
||||
}
|
||||
if (!(ai.genType & t통솔장)) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,6 +108,7 @@ export const do전방워프 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
ai.categorizeNationCities();
|
||||
ai.categorizeNationGeneral();
|
||||
const candidateCities: Record<number, number> = {};
|
||||
for (const frontCity of Object.values(ai.frontCities)) {
|
||||
if (frontCity.supplyState <= 0) {
|
||||
@@ -195,4 +200,11 @@ export const do귀환 = (ai: GeneralAI) => {
|
||||
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;
|
||||
}
|
||||
|
||||
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 capitalId = ai.nation.capitalCityId;
|
||||
if (!distanceList[capitalId]) {
|
||||
@@ -28,7 +46,7 @@ export const do천도 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
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);
|
||||
if (sumDistance <= 0) {
|
||||
continue;
|
||||
@@ -39,7 +57,7 @@ export const do천도 = (ai: GeneralAI) => {
|
||||
|
||||
const sorted = Object.entries(cityScores).sort((a, b) => b[1] - a[1]);
|
||||
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) {
|
||||
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 { 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) => {
|
||||
if (!ai.nation || ai.general.officerLevel < 12) {
|
||||
return null;
|
||||
@@ -66,11 +78,16 @@ export const do불가침제의 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth));
|
||||
return ai.buildNationCandidate(
|
||||
const result = ai.buildNationCandidate(
|
||||
'che_불가침제의',
|
||||
{ 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) => {
|
||||
@@ -92,6 +109,10 @@ export const do선전포고 = (ai: GeneralAI) => {
|
||||
if (!ai.map || !ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
const currentTech = readMetaNumber(asRecord(ai.nation.meta), 'tech', 0);
|
||||
if (!isTechLimited(ai, currentTech + 1000)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avgResources = Object.values({
|
||||
...ai.npcWarGenerals,
|
||||
@@ -134,9 +155,26 @@ export const do선전포고 = (ai: GeneralAI) => {
|
||||
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 warWeight: Record<number, number> = {};
|
||||
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));
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { City } from '@sammo-ts/logic';
|
||||
import type { GeneralAI } from '../core.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<
|
||||
[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) =>
|
||||
ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason);
|
||||
|
||||
export const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
|
||||
ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
|
||||
export const buildSeizureCandidate = (
|
||||
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) =>
|
||||
ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
|
||||
|
||||
export const resolveAwardAmount = (ai: GeneralAI, current: number, target: number): number | null => {
|
||||
const diff = target - current;
|
||||
if (diff <= 0) {
|
||||
return null;
|
||||
}
|
||||
const amount = Math.min(diff, ai.maxResourceActionAmount);
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
return null;
|
||||
}
|
||||
return amount;
|
||||
};
|
||||
export const buildAwardCandidate = (
|
||||
ai: GeneralAI,
|
||||
destGeneralId: number,
|
||||
amount: number,
|
||||
isGold: boolean,
|
||||
reason: string
|
||||
) => ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
|
||||
|
||||
export const resolveNationIncome = (ai: GeneralAI): number => {
|
||||
const cities = Object.values(ai.supplyCities);
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { GeneralAI } from '../core.js';
|
||||
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
|
||||
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate, resolveAwardAmount } from './helpers.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';
|
||||
|
||||
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) => {
|
||||
const nation = ai.nation;
|
||||
@@ -8,24 +36,38 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number]> = [
|
||||
const resourceMap: Array<[ResourceName, number]> = [
|
||||
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
|
||||
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
|
||||
];
|
||||
|
||||
for (const [resKey, required] of resourceMap) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
}
|
||||
for (const general of Object.values(ai.userWarGenerals)) {
|
||||
const amount = resolveAwardAmount(ai, general[resKey], required);
|
||||
if (!amount) {
|
||||
for (const [resKey, minimum] of resourceMap) {
|
||||
const generals = sortedByResource(ai.userWarGenerals, resKey);
|
||||
for (const [index, general] of generals.entries()) {
|
||||
if (general[resKey] >= minimum) {
|
||||
break;
|
||||
}
|
||||
if (!canUseGeneral(general)) {
|
||||
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;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number]> = [
|
||||
['gold', ai.nationPolicy.reqHumanWarRecommandGold],
|
||||
['rice', ai.nationPolicy.reqHumanWarRecommandRice],
|
||||
const resourceMap: Array<[ResourceName, number, number, number]> = [
|
||||
[
|
||||
'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) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
||||
if (nation[resKey] < nationMinimum) {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
}
|
||||
for (const general of Object.values(ai.userWarGenerals)) {
|
||||
const amount = resolveAwardAmount(ai, general[resKey], required);
|
||||
if (!amount) {
|
||||
const generals = sortedByResource(ai.userGenerals, resKey);
|
||||
for (const [index, general] of generals.entries()) {
|
||||
if (general[resKey] >= warMinimum) {
|
||||
break;
|
||||
}
|
||||
if (!canUseGeneral(general)) {
|
||||
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;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number]> = [
|
||||
['gold', ai.nationPolicy.reqNpcWarGold / 2],
|
||||
['rice', ai.nationPolicy.reqNpcWarRice / 2],
|
||||
const resourceMap: Array<[ResourceName, number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
|
||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
|
||||
];
|
||||
|
||||
for (const [resKey, required] of resourceMap) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
for (const [resKey, nationMinimum, minimum] of resourceMap) {
|
||||
if (nation[resKey] < nationMinimum) {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
}
|
||||
for (const general of Object.values(ai.npcWarGenerals)) {
|
||||
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
|
||||
if (killturn <= 5) {
|
||||
const generals = sortedByResource(ai.npcWarGenerals, resKey);
|
||||
for (const [index, general] of generals.entries()) {
|
||||
if (general[resKey] >= minimum) {
|
||||
break;
|
||||
}
|
||||
if (!canUseGeneral(general)) {
|
||||
continue;
|
||||
}
|
||||
const amount = resolveAwardAmount(ai, general[resKey], required);
|
||||
if (!amount) {
|
||||
let required = getCrewGoldCost(ai, general, 1.5);
|
||||
if (ai.world.currentYear > ai.startYear + 5) {
|
||||
required = Math.max(required, minimum);
|
||||
}
|
||||
const enough = required * 1.2;
|
||||
if (general[resKey] >= required) {
|
||||
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;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||
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],
|
||||
];
|
||||
|
||||
for (const [resKey, warReq, devReq] of resourceMap) {
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
|
||||
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
||||
if (nation[resKey] < nationMinimum) {
|
||||
continue;
|
||||
}
|
||||
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
|
||||
continue;
|
||||
const warGenerals = sortedByResource(ai.npcWarGenerals, resKey);
|
||||
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)) {
|
||||
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
|
||||
if (killturn <= 5) {
|
||||
for (const [index, general] of civilGenerals.entries()) {
|
||||
if (general[resKey] >= civilMinimum) {
|
||||
break;
|
||||
}
|
||||
if (!canUseGeneral(general)) {
|
||||
continue;
|
||||
}
|
||||
const amount = resolveAwardAmount(ai, general[resKey], warReq);
|
||||
if (!amount) {
|
||||
let amount = civilMinimum * 1.5 - general[resKey];
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
|
||||
}
|
||||
for (const general of Object.values(ai.npcCivilGenerals)) {
|
||||
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
|
||||
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]);
|
||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||
candidates.push([
|
||||
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
|
||||
weightBase - index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,41 +255,46 @@ export const doNPC몰수 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const resourceMap: Array<['gold' | 'rice', number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||
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],
|
||||
];
|
||||
|
||||
for (const [resKey, warReq, devReq] of resourceMap) {
|
||||
const nationLimit = resKey === 'gold' ? ai.nationPolicy.reqNationGold : ai.nationPolicy.reqNationRice;
|
||||
const nationEnough = nation[resKey] >= nationLimit;
|
||||
|
||||
for (const general of Object.values(ai.npcCivilGenerals)) {
|
||||
if (general[resKey] <= devReq * 1.5) {
|
||||
continue;
|
||||
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
||||
for (const general of sortedByResource(ai.npcCivilGenerals, resKey, true)) {
|
||||
if (general[resKey] <= civilMinimum * 1.5) {
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
|
||||
}
|
||||
|
||||
if (!nationEnough) {
|
||||
for (const general of Object.values(ai.npcWarGenerals)) {
|
||||
const minRes = nation[resKey] < nationLimit * 0.5 ? warReq * 2 : warReq;
|
||||
if (general[resKey] <= minRes) {
|
||||
continue;
|
||||
}
|
||||
const amount = Math.min(general[resKey] - minRes, ai.maxResourceActionAmount);
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
continue;
|
||||
}
|
||||
candidates.push([
|
||||
buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'),
|
||||
amount,
|
||||
]);
|
||||
const nationDelta = nationMinimum * 1.5 - nation[resKey];
|
||||
if (nationDelta < 0) {
|
||||
continue;
|
||||
}
|
||||
const takeSmallAmount = nation[resKey] >= nationMinimum;
|
||||
for (const general of sortedByResource(ai.npcWarGenerals, resKey, true)) {
|
||||
if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) {
|
||||
break;
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ export class AutorunNationPolicy {
|
||||
if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) {
|
||||
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
|
||||
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) {
|
||||
this.reqNpcWarGold = roundTo(baseGold * 4, -2);
|
||||
}
|
||||
@@ -292,7 +292,7 @@ export class AutorunNationPolicy {
|
||||
if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) {
|
||||
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
|
||||
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) {
|
||||
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 recruited = world
|
||||
.listGenerals()
|
||||
@@ -405,7 +394,6 @@ describe('NPC 대형 시뮬레이션', () => {
|
||||
['180-11', () => assertCityTrust(90)],
|
||||
['181-01', () => assertNationGeneralCount(10)],
|
||||
['182-01', () => assertDomesticGrowth()],
|
||||
['182-10', () => assertNationRecruitCount(5)],
|
||||
['183-01', () => assertWarReadiness(10, 70, 70)],
|
||||
['183-02', () => assertDispatchRecorded(183, 1, 1)],
|
||||
['183-07', () => assertNoNeutralCities()],
|
||||
|
||||
@@ -109,11 +109,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
||||
const pushNationGenerals = (nationId: number, cityId: number) => {
|
||||
const leaderId = nextId++;
|
||||
generals.push(
|
||||
createNpcGeneral(leaderId, cityId, nationId, 12, {
|
||||
leadership: 100,
|
||||
strength: 90,
|
||||
intelligence: 40,
|
||||
}, 1)
|
||||
createNpcGeneral(
|
||||
leaderId,
|
||||
cityId,
|
||||
nationId,
|
||||
12,
|
||||
{
|
||||
leadership: 100,
|
||||
strength: 90,
|
||||
intelligence: 40,
|
||||
},
|
||||
1
|
||||
)
|
||||
);
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
generals.push(
|
||||
@@ -317,6 +324,8 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
||||
expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -113,24 +113,30 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
|
||||
const generals: TurnGeneral[] = [];
|
||||
let nextId = 1;
|
||||
const pushNationGenerals = (nationId: number, cityId: number) => {
|
||||
generals.push(createNpcGeneral(nextId++, cityId, nationId, 12, {
|
||||
leadership: 90,
|
||||
strength: 80,
|
||||
intelligence: 40,
|
||||
}));
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, {
|
||||
leadership: 70,
|
||||
generals.push(
|
||||
createNpcGeneral(nextId++, cityId, nationId, 12, {
|
||||
leadership: 90,
|
||||
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) {
|
||||
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, {
|
||||
leadership: 70,
|
||||
strength: 30,
|
||||
intelligence: 80,
|
||||
}));
|
||||
generals.push(
|
||||
createNpcGeneral(nextId++, cityId, nationId, 2, {
|
||||
leadership: 70,
|
||||
strength: 30,
|
||||
intelligence: 80,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
pushNationGenerals(1, cityA1.id);
|
||||
@@ -280,9 +286,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
|
||||
|
||||
const findDiplomacyEntry = (world: InMemoryTurnWorld | null) => {
|
||||
const diplomacyEntries = world?.listDiplomacy() ?? [];
|
||||
return diplomacyEntries.find((entry) =>
|
||||
(entry.fromNationId === 1 && entry.toNationId === 2) ||
|
||||
(entry.fromNationId === 2 && entry.toNationId === 1)
|
||||
return diplomacyEntries.find(
|
||||
(entry) =>
|
||||
(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);
|
||||
|
||||
const remainTurns = Math.max(0, (declareEntry?.term ?? 0) - 1);
|
||||
const preWarTarget = addMonths(
|
||||
world!.getState().currentYear,
|
||||
world!.getState().currentMonth,
|
||||
remainTurns
|
||||
);
|
||||
const preWarTarget = addMonths(world!.getState().currentYear, world!.getState().currentMonth, remainTurns);
|
||||
|
||||
await runUntil((current) =>
|
||||
current.currentYear > preWarTarget.year ||
|
||||
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month)
|
||||
await runUntil(
|
||||
(current) =>
|
||||
current.currentYear > preWarTarget.year ||
|
||||
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month)
|
||||
);
|
||||
|
||||
const preWarEntry = findDiplomacyEntry(world);
|
||||
@@ -348,10 +352,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
|
||||
debug.dumpWatched('개전 직전 병력 부족');
|
||||
}
|
||||
expect(recruited.length).toBeGreaterThanOrEqual(5);
|
||||
const battleReady = recruited.filter((general) => general.train >= 90 && general.atmos >= 90);
|
||||
expect(battleReady.length).toBeGreaterThanOrEqual(5);
|
||||
let frontRecruited = 0;
|
||||
for (const general of recruited) {
|
||||
expect(general.train).toBeGreaterThanOrEqual(90);
|
||||
expect(general.atmos).toBeGreaterThanOrEqual(90);
|
||||
const city = world.getCityById(general.cityId);
|
||||
if (city && city.frontState > 0) {
|
||||
frontRecruited += 1;
|
||||
@@ -363,9 +367,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
|
||||
expect(frontRecruited).toBeGreaterThan(0);
|
||||
|
||||
const warTarget = addMonths(preWarTarget.year, preWarTarget.month, 1);
|
||||
await runUntil((current) =>
|
||||
current.currentYear > warTarget.year ||
|
||||
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month)
|
||||
await runUntil(
|
||||
(current) =>
|
||||
current.currentYear > warTarget.year ||
|
||||
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month)
|
||||
);
|
||||
|
||||
const warEntry = findDiplomacyEntry(world);
|
||||
@@ -381,9 +386,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
|
||||
|
||||
while (prevNation1Cities > 0 && guard < 120) {
|
||||
const next = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
|
||||
await runUntil((current) =>
|
||||
current.currentYear > next.year ||
|
||||
(current.currentYear === next.year && current.currentMonth >= next.month)
|
||||
await runUntil(
|
||||
(current) =>
|
||||
current.currentYear > next.year ||
|
||||
(current.currentYear === next.year && current.currentMonth >= next.month)
|
||||
);
|
||||
|
||||
const nowNation1Cities = countCities(1, world);
|
||||
@@ -415,9 +421,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
|
||||
expect(allOwnedByNation2).toBe(true);
|
||||
|
||||
const unifyCheckTarget = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
|
||||
await runUntil((current) =>
|
||||
current.currentYear > unifyCheckTarget.year ||
|
||||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
|
||||
await runUntil(
|
||||
(current) =>
|
||||
current.currentYear > unifyCheckTarget.year ||
|
||||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
|
||||
);
|
||||
|
||||
const worldMeta = world.getState().meta as Record<string, unknown>;
|
||||
@@ -434,9 +441,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
|
||||
expect(hasUnificationLog).toBe(true);
|
||||
|
||||
const dispatchWindowEnd = addMonths(warTarget.year, warTarget.month, 2);
|
||||
await runUntil((current) =>
|
||||
current.currentYear > dispatchWindowEnd.year ||
|
||||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
|
||||
await runUntil(
|
||||
(current) =>
|
||||
current.currentYear > dispatchWindowEnd.year ||
|
||||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
|
||||
);
|
||||
|
||||
const dispatchKeys: string[] = [];
|
||||
|
||||
@@ -116,7 +116,9 @@ Nation-level choices run only for NPCs (`npc >= 2`) or for autorun users:
|
||||
- **Resource distribution**
|
||||
- `do유저장포상`, `doNPC포상`, `doNPC몰수`
|
||||
- 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**
|
||||
- `do불가침제의`: respond to assistance requests with NAP offer.
|
||||
- `do선전포고`: probabilistic declaration when strong enough.
|
||||
@@ -131,8 +133,10 @@ General-level decisions are layered:
|
||||
2. Reserved command is honored if valid (unless `휴식`).
|
||||
3. Immediate recovery if `injury > cureThreshold`.
|
||||
4. Special cases:
|
||||
- NPC troop leaders (type 5) always `집합`.
|
||||
- wanderers decide on founding / moving / disbanding.
|
||||
- A nationless NPC troop leader shortens `killturn` and keeps its reserved
|
||||
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}`.
|
||||
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"
|
||||
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
@@ -4,7 +4,7 @@
|
||||
|
||||
- Audit date: 2026-07-25
|
||||
- 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
|
||||
fixtures/helpers
|
||||
- Historical generated copies removed by this audit:
|
||||
@@ -61,25 +61,26 @@ environment-dependent check.
|
||||
|
||||
### `app/game-engine`
|
||||
|
||||
| Test source | Disposition | Layer | What it establishes |
|
||||
| ----------------------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
|
||||
| `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/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/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/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/npcNationTechResearch.test.ts` | kept | smoke | Long-running monotonic tech growth and higher later recruitment cost. |
|
||||
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
|
||||
| `test/npcNationWarDeclaration.test.ts` | kept | smoke | Declaration, war transition, preparation, conquest, unification, and dispatch occurrence in one end-to-end NPC scenario. |
|
||||
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
|
||||
| `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/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
|
||||
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
|
||||
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
|
||||
| `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. |
|
||||
| Test source | Disposition | Layer | What it establishes |
|
||||
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `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/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/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
|
||||
| `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/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/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/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/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/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
|
||||
| `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/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
|
||||
| `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/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
|
||||
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
|
||||
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
|
||||
| `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`
|
||||
|
||||
@@ -145,8 +146,9 @@ to legacy-compatible:
|
||||
|
||||
- battle compatibility is supported only by the differential fixtures and the
|
||||
suites explicitly marked `compatibility`;
|
||||
- broad NPC and multi-command scenarios guard liveness/invariants, not exact
|
||||
monthly balance or complete side effects;
|
||||
- broad NPC and multi-command scenarios guard liveness/invariants, while
|
||||
`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;
|
||||
- changing an implementation and observing a green unit suite is still not a
|
||||
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 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({
|
||||
|
||||
Reference in New Issue
Block a user