feat: enhance AI nation behavior and diplomacy handling, improve test assertions for war readiness
This commit is contained in:
@@ -28,6 +28,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 t무장 = 1;
|
const t무장 = 1;
|
||||||
const t지장 = 2;
|
const t지장 = 2;
|
||||||
@@ -122,7 +123,9 @@ export class GeneralAI {
|
|||||||
constructor(options: GeneralAIOptions) {
|
constructor(options: GeneralAIOptions) {
|
||||||
this.general = options.general;
|
this.general = options.general;
|
||||||
this.city = options.city;
|
this.city = options.city;
|
||||||
this.nation = options.nation ?? null;
|
this.nation =
|
||||||
|
options.nation ??
|
||||||
|
(options.general.nationId > 0 ? options.worldRef?.getNationById(options.general.nationId) ?? null : null);
|
||||||
this.world = options.world;
|
this.world = options.world;
|
||||||
this.worldRef = options.worldRef;
|
this.worldRef = options.worldRef;
|
||||||
this.map = options.map;
|
this.map = options.map;
|
||||||
@@ -746,6 +749,13 @@ 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) ??
|
||||||
|
readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0);
|
||||||
|
const markAttackable = () => {
|
||||||
|
lastAttackable = yearMonth;
|
||||||
|
lastAttackableByNation.set(nationId, yearMonth);
|
||||||
|
};
|
||||||
|
|
||||||
if (minWarTerm === null) {
|
if (minWarTerm === null) {
|
||||||
this.dipState = d평화;
|
this.dipState = d평화;
|
||||||
} else if (minWarTerm > 8) {
|
} else if (minWarTerm > 8) {
|
||||||
@@ -754,19 +764,29 @@ export class GeneralAI {
|
|||||||
this.dipState = d징병;
|
this.dipState = d징병;
|
||||||
} else {
|
} else {
|
||||||
this.dipState = d직전;
|
this.dipState = d직전;
|
||||||
|
markAttackable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = asRecord(this.nation.meta);
|
|
||||||
const lastAttackable = readMetaNumber(meta, 'last_attackable', 0);
|
|
||||||
if (Object.prototype.hasOwnProperty.call(warTargetNation, 0) && this.attackable) {
|
if (Object.prototype.hasOwnProperty.call(warTargetNation, 0) && this.attackable) {
|
||||||
this.dipState = d전쟁;
|
this.dipState = d전쟁;
|
||||||
|
markAttackable();
|
||||||
} else if (onWar > 0) {
|
} else if (onWar > 0) {
|
||||||
if (this.attackable) {
|
if (this.attackable) {
|
||||||
this.dipState = d전쟁;
|
this.dipState = d전쟁;
|
||||||
|
markAttackable();
|
||||||
} else if (lastAttackable >= yearMonth - 5) {
|
} else if (lastAttackable >= yearMonth - 5) {
|
||||||
this.dipState = d전쟁;
|
this.dipState = d전쟁;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((this.dipState === d평화 || this.dipState === d선포) && this.worldRef) {
|
||||||
|
const hasCrew =
|
||||||
|
this.general.crew > 0 ||
|
||||||
|
this.worldRef.listGenerals().some((general) => general.nationId === nationId && general.crew > 0);
|
||||||
|
if (hasCrew) {
|
||||||
|
this.dipState = d징병;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private calcRecentWarTurn(general: TurnGeneral): number {
|
private calcRecentWarTurn(general: TurnGeneral): number {
|
||||||
|
|||||||
@@ -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)) {
|
if ([0, 1].includes(ai.dipState) && ai.general.crew <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const buildFrontStatePatches = (options: {
|
|||||||
.listNations()
|
.listNations()
|
||||||
.filter((nation) => options.nationIds?.includes(nation.id))
|
.filter((nation) => options.nationIds?.includes(nation.id))
|
||||||
: options.worldView.listNations();
|
: options.worldView.listNations();
|
||||||
const nations = nationList.filter((nation) => nation.level > 0 && nation.capitalCityId);
|
const nations = nationList.filter((nation) => nation.level > 0);
|
||||||
const patches: Array<{ id: number; patch: Partial<City> }> = [];
|
const patches: Array<{ id: number; patch: Partial<City> }> = [];
|
||||||
for (const nation of nations) {
|
for (const nation of nations) {
|
||||||
const frontStates = resolveNationFrontStates(nation.id, connectionMap, cityIdsByNation, diplomacy);
|
const frontStates = resolveNationFrontStates(nation.id, connectionMap, cityIdsByNation, diplomacy);
|
||||||
|
|||||||
@@ -267,7 +267,17 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null {
|
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null {
|
||||||
const entry = this.diplomacy.get(buildDiplomacyKey(srcNationId, destNationId));
|
if (srcNationId === destNationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = buildDiplomacyKey(srcNationId, destNationId);
|
||||||
|
let entry = this.diplomacy.get(key);
|
||||||
|
if (!entry && this.nations.has(srcNationId) && this.nations.has(destNationId)) {
|
||||||
|
entry = buildDefaultDiplomacy(srcNationId, destNationId);
|
||||||
|
this.diplomacy.set(key, entry);
|
||||||
|
this.dirtyDiplomacyKeys.add(key);
|
||||||
|
this.createdDiplomacyKeys.add(key);
|
||||||
|
}
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -278,6 +288,7 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listDiplomacy(): TurnDiplomacy[] {
|
listDiplomacy(): TurnDiplomacy[] {
|
||||||
|
this.ensureDiplomacyMatrix();
|
||||||
return Array.from(this.diplomacy.values()).map((entry) => ({
|
return Array.from(this.diplomacy.values()).map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
meta: { ...entry.meta },
|
meta: { ...entry.meta },
|
||||||
@@ -530,6 +541,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.createdGeneralIds.add(createdGeneral.id);
|
this.createdGeneralIds.add(createdGeneral.id);
|
||||||
}
|
}
|
||||||
if (result.created.nations) {
|
if (result.created.nations) {
|
||||||
|
let addedNation = false;
|
||||||
for (const createdNation of result.created.nations) {
|
for (const createdNation of result.created.nations) {
|
||||||
if (this.nations.has(createdNation.id)) {
|
if (this.nations.has(createdNation.id)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -537,6 +549,10 @@ export class InMemoryTurnWorld {
|
|||||||
this.nations.set(createdNation.id, { ...createdNation });
|
this.nations.set(createdNation.id, { ...createdNation });
|
||||||
this.dirtyNationIds.add(createdNation.id);
|
this.dirtyNationIds.add(createdNation.id);
|
||||||
this.createdNationIds.add(createdNation.id);
|
this.createdNationIds.add(createdNation.id);
|
||||||
|
addedNation = true;
|
||||||
|
}
|
||||||
|
if (addedNation) {
|
||||||
|
this.ensureDiplomacyMatrix();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result.created.troops) {
|
if (result.created.troops) {
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ describe('NPC 대형 시뮬레이션', () => {
|
|||||||
const turnTraces: TurnTrace[] = [];
|
const turnTraces: TurnTrace[] = [];
|
||||||
const traceByGeneralId = new Map<number, TurnTrace>();
|
const traceByGeneralId = new Map<number, TurnTrace>();
|
||||||
|
|
||||||
|
const commandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet);
|
||||||
|
|
||||||
const handler = await createReservedTurnHandler({
|
const handler = await createReservedTurnHandler({
|
||||||
reservedTurns: reservedTurnStore,
|
reservedTurns: reservedTurnStore,
|
||||||
scenarioConfig: snapshot.scenarioConfig,
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
@@ -401,14 +403,20 @@ describe('NPC 대형 시뮬레이션', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertWarReadiness = (minCount: number, minTrain = 70, minAtmos = 70) => {
|
const assertWarReadiness = (minReadyCount: number, minTrain: number, minAtmos: number) => {
|
||||||
const recruited = world
|
const recruited = world
|
||||||
.listGenerals()
|
.listGenerals()
|
||||||
.filter((general) => general.nationId > 0 && general.crew > 0 && general.crewTypeId > 0);
|
.filter((general) => general.nationId > 0 && general.crew > 0 && general.crewTypeId > 0);
|
||||||
|
const maxTrain = recruited.reduce((max, general) => Math.max(max, general.train), 0);
|
||||||
|
const maxAtmos = recruited.reduce((max, general) => Math.max(max, general.atmos), 0);
|
||||||
const ready = recruited.filter((general) => general.train >= minTrain && general.atmos >= minAtmos);
|
const ready = recruited.filter((general) => general.train >= minTrain && general.atmos >= minAtmos);
|
||||||
expect(ready.length).toBeGreaterThanOrEqual(minCount);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
expect(recruited.length).toBeGreaterThan(0);
|
||||||
|
expect(maxTrain).toBeGreaterThan(0);
|
||||||
|
expect(maxAtmos).toBeGreaterThan(0);
|
||||||
|
expect(ready.length).toBeGreaterThanOrEqual(minReadyCount);
|
||||||
|
};
|
||||||
|
|
||||||
const assertDispatchRecorded = (year: number, month: number, minCount = 1) => {
|
const assertDispatchRecorded = (year: number, month: number, minCount = 1) => {
|
||||||
const matches = turnTraces.filter(
|
const matches = turnTraces.filter(
|
||||||
(trace) => trace.year === year && trace.month === month && trace.actionKey === 'che_출병'
|
(trace) => trace.year === year && trace.month === month && trace.actionKey === 'che_출병'
|
||||||
@@ -527,9 +535,8 @@ describe('NPC 대형 시뮬레이션', () => {
|
|||||||
}
|
}
|
||||||
const city = world.getCityById(general.cityId);
|
const city = world.getCityById(general.cityId);
|
||||||
const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null;
|
const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null;
|
||||||
const env = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet);
|
|
||||||
const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({
|
const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({
|
||||||
env,
|
env: commandEnv,
|
||||||
commandProfile: DEFAULT_TURN_COMMAND_PROFILE,
|
commandProfile: DEFAULT_TURN_COMMAND_PROFILE,
|
||||||
defaultActionKey: '휴식',
|
defaultActionKey: '휴식',
|
||||||
});
|
});
|
||||||
@@ -557,8 +564,8 @@ describe('NPC 대형 시뮬레이션', () => {
|
|||||||
month: state.currentMonth,
|
month: state.currentMonth,
|
||||||
startYear,
|
startYear,
|
||||||
relYear: state.currentYear - startYear,
|
relYear: state.currentYear - startYear,
|
||||||
openingPartYear: env.openingPartYear,
|
openingPartYear: commandEnv.openingPartYear,
|
||||||
minAvailableRecruitPop: env.minAvailableRecruitPop,
|
minAvailableRecruitPop: commandEnv.minAvailableRecruitPop,
|
||||||
cities: world.listCities(),
|
cities: world.listCities(),
|
||||||
nations: world.listNations(),
|
nations: world.listNations(),
|
||||||
map: LARGE_TEST_MAP,
|
map: LARGE_TEST_MAP,
|
||||||
@@ -618,7 +625,7 @@ describe('NPC 대형 시뮬레이션', () => {
|
|||||||
scenarioMeta: snapshot.scenarioMeta,
|
scenarioMeta: snapshot.scenarioMeta,
|
||||||
map: LARGE_TEST_MAP,
|
map: LARGE_TEST_MAP,
|
||||||
unitSet: snapshot.unitSet,
|
unitSet: snapshot.unitSet,
|
||||||
commandEnv: env,
|
commandEnv,
|
||||||
generalDefinitions,
|
generalDefinitions,
|
||||||
nationDefinitions,
|
nationDefinitions,
|
||||||
generalFallback,
|
generalFallback,
|
||||||
|
|||||||
Reference in New Issue
Block a user