From 8735d350fab2fbd3eb0ba4a49db2c4fdef60242b Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 13:38:54 +0000 Subject: [PATCH] Refactor AI nation actions: consolidate functions into a single export module for improved organization and maintainability. --- app/game-engine/src/turn/ai/generalAi.ts | 926 +-------------- .../src/turn/ai/generalAi/constraint.ts | 25 + app/game-engine/src/turn/ai/generalAi/core.ts | 802 +++++++++++++ .../turn/ai/generalAi/general/devActions.ts | 189 +++ .../ai/generalAi/general/economyActions.ts | 83 ++ .../src/turn/ai/generalAi/general/helpers.ts | 26 + .../src/turn/ai/generalAi/general/index.ts | 57 + .../turn/ai/generalAi/general/npcActions.ts | 96 ++ .../ai/generalAi/general/politicsActions.ts | 179 +++ .../ai/generalAi/general/recruitActions.ts | 87 ++ .../turn/ai/generalAi/general/warActions.ts | 81 ++ .../turn/ai/generalAi/general/warpActions.ts | 198 ++++ .../nation/assignments/npcAssignments.ts | 162 +++ .../nation/assignments/troopAssignments.ts | 171 +++ .../nation/assignments/userAssignments.ts | 229 ++++ .../src/turn/ai/generalAi/nation/capital.ts | 66 ++ .../src/turn/ai/generalAi/nation/diplomacy.ts | 149 +++ .../src/turn/ai/generalAi/nation/helpers.ts | 94 ++ .../src/turn/ai/generalAi/nation/index.ts | 62 + .../src/turn/ai/generalAi/nation/rewards.ts | 189 +++ app/game-engine/src/turn/ai/generalAi/seed.ts | 8 + .../src/turn/ai/generalAi/types.ts | 56 + .../src/turn/ai/generalAi/worldStateView.ts | 66 ++ .../src/turn/ai/generalAiGeneralActions.ts | 943 +-------------- .../src/turn/ai/generalAiNationActions.ts | 1056 +---------------- 25 files changed, 3079 insertions(+), 2921 deletions(-) create mode 100644 app/game-engine/src/turn/ai/generalAi/constraint.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/core.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/devActions.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/economyActions.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/helpers.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/index.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/npcActions.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/warActions.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/general/warpActions.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/capital.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/helpers.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/index.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/nation/rewards.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/seed.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/types.ts create mode 100644 app/game-engine/src/turn/ai/generalAi/worldStateView.ts diff --git a/app/game-engine/src/turn/ai/generalAi.ts b/app/game-engine/src/turn/ai/generalAi.ts index 2212db0..8172826 100644 --- a/app/game-engine/src/turn/ai/generalAi.ts +++ b/app/game-engine/src/turn/ai/generalAi.ts @@ -1,924 +1,2 @@ -import type { - City, - GeneralActionDefinition, - MapDefinition, - Nation, - ScenarioConfig, - ScenarioMeta, - TurnCommandEnv, - UnitSetDefinition, -} from '@sammo-ts/logic'; -import { evaluateConstraints } from '@sammo-ts/logic'; -import type { ConstraintContext, StateView } from '@sammo-ts/logic'; -import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; -import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; - -import type { ReservedTurnEntry } from '../reservedTurnStore.js'; -import type { TurnGeneral, TurnWorldState } from '../types.js'; -import type { AiCommandCandidate, AiReservedTurnProvider, AiWorldView } from './types.js'; -import { AutorunGeneralPolicy, AutorunNationPolicy, AVAILABLE_INSTANT_TURN } from './policies.js'; -import { asRecord, joinYearMonth, readMetaNumber, readNumber, roundTo, valueFit } from './aiUtils.js'; -import { searchAllDistanceByNationList } from './distance.js'; -import { generalActionHandlers } from './generalAiGeneralActions.js'; -import { nationActionHandlers } from './generalAiNationActions.js'; - -const ACTION_REST = '휴식'; - -const t무장 = 1; -const t지장 = 2; -const t통솔장 = 4; - -const d평화 = 0; -const d선포 = 1; -const d징병 = 2; -const d직전 = 3; -const d전쟁 = 4; - -type ConstraintEnv = Record; - -const resolveConstraintEnv = ( - world: TurnWorldState, - scenarioMeta: ScenarioMeta | undefined, - env: TurnCommandEnv -): ConstraintEnv => { - const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; - const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; - - return { - currentYear: world.currentYear, - currentMonth: world.currentMonth, - year: world.currentYear, - month: world.currentMonth, - startYear, - relYear, - openingPartYear: env.openingPartYear, - minAvailableRecruitPop: env.minAvailableRecruitPop, - }; -}; - -const buildSeedBase = (world: TurnWorldState): string => { - const meta = asRecord(world.meta); - const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id; - return String(rawSeed); -}; - -class WorldStateView implements StateView { - constructor( - private readonly world: AiWorldView | null, - private readonly env: ConstraintEnv, - private readonly args: Record, - private readonly overrides?: { - general?: TurnGeneral; - city?: City; - nation?: Nation | null; - } - ) {} - - has(req: Parameters[0]): boolean { - return this.get(req) !== null; - } - - get(req: Parameters[0]): unknown | null { - if (!this.world) { - return null; - } - switch (req.kind) { - case 'general': - if (this.overrides?.general && this.overrides.general.id === req.id) { - return this.overrides.general; - } - return this.world.getGeneralById(req.id); - case 'generalList': - return this.world.listGenerals(); - case 'destGeneral': - return this.world.getGeneralById(req.id); - case 'city': - if (this.overrides?.city && this.overrides.city.id === req.id) { - return this.overrides.city; - } - return this.world.getCityById(req.id); - case 'destCity': - return this.world.getCityById(req.id); - case 'nation': - if (this.overrides?.nation && this.overrides.nation.id === req.id) { - return this.overrides.nation; - } - return this.world.getNationById(req.id); - case 'nationList': - return this.world.listNations(); - case 'destNation': - return this.world.getNationById(req.id); - case 'diplomacy': - return this.world.getDiplomacyEntry(req.srcNationId, req.destNationId); - case 'diplomacyList': - return this.world.listDiplomacy(); - case 'arg': - return this.args[req.key] ?? null; - case 'env': - return this.env[req.key] ?? null; - default: - return null; - } - } -} - -export interface GeneralAIOptions { - general: TurnGeneral; - city?: City; - nation?: Nation | null; - world: TurnWorldState; - worldRef: AiWorldView | null; - reservedTurnProvider: AiReservedTurnProvider; - scenarioConfig: ScenarioConfig; - scenarioMeta?: ScenarioMeta; - map?: MapDefinition; - unitSet?: UnitSetDefinition; - commandEnv: TurnCommandEnv; - generalDefinitions: Map; - nationDefinitions: Map; - generalFallback: GeneralActionDefinition; - nationFallback: GeneralActionDefinition; -} - -export type GeneralAiDebugState = { - generalId: number; - nationId: number | null; - cityId: number | null; - yearMonth: number; - startYear: number; - startYearMonth: number; - dipState: number; - attackable: boolean; - warTargetNation: Record; - genType: number; - lastAttackable: number; - frontCities: Array<{ id: number; frontState: number; supplyState: number }>; - supplyCities: Array<{ id: number; frontState: number; supplyState: number }>; - policy: { - minAvailableRecruitPop: number; - minNpcWarLeadership: number; - minWarCrew: number; - cureThreshold: number; - }; -}; - -export class GeneralAI { - public readonly general: TurnGeneral; - public readonly city?: City; - public readonly nation?: Nation | null; - public readonly world: TurnWorldState; - public readonly worldRef: AiWorldView | null; - public readonly map?: MapDefinition; - public readonly unitSet?: UnitSetDefinition; - public readonly commandEnv: TurnCommandEnv; - public readonly scenarioConfig: ScenarioConfig; - public readonly scenarioMeta?: ScenarioMeta; - - public readonly generalDefinitions: Map; - public readonly nationDefinitions: Map; - public readonly generalFallback: GeneralActionDefinition; - public readonly nationFallback: GeneralActionDefinition; - - public readonly rng: RandUtil; - public readonly env: ConstraintEnv; - public readonly startYear: number; - public readonly turnTermMinutes: number; - - public readonly aiConst: { - baseGold: number; - baseRice: number; - minAvailableRecruitPop: number; - maxResourceActionAmount: number; - minNationalGold: number; - minNationalRice: number; - defaultStatMax: number; - defaultStatNpcMax: number; - chiefStatMin: number; - npcMessageFreqByDay: number; - availableNationTypes: string[]; - }; - - public generalPolicy: AutorunGeneralPolicy; - public nationPolicy: AutorunNationPolicy; - - public genType = 0; - public dipState = d평화; - public warTargetNation: Record = {}; - public attackable = false; - public maxResourceActionAmount = 0; - - public nationCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - public frontCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - public supplyCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - public backupCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - public warRoute: Record> | null = null; - - public nationGenerals: TurnGeneral[] = []; - public npcCivilGenerals: Record = {}; - public npcWarGenerals: Record = {}; - public userGenerals: Record = {}; - public userWarGenerals: Record = {}; - public userCivilGenerals: Record = {}; - public chiefGenerals: Record = {}; - public lostGenerals: Record = {}; - public troopLeaders: Record = {}; - - private reqUpdateInstance = true; - private devRate: Record | null = null; - private categorizedCities = false; - private categorizedGenerals = false; - - private readonly reservedTurnProvider: AiReservedTurnProvider; - - constructor(options: GeneralAIOptions) { - this.general = options.general; - this.city = options.city; - this.nation = options.nation ?? null; - this.world = options.world; - this.worldRef = options.worldRef; - this.map = options.map; - this.unitSet = options.unitSet; - this.commandEnv = options.commandEnv; - this.scenarioConfig = options.scenarioConfig; - this.scenarioMeta = options.scenarioMeta; - this.reservedTurnProvider = options.reservedTurnProvider; - - this.generalDefinitions = options.generalDefinitions; - this.nationDefinitions = options.nationDefinitions; - this.generalFallback = options.generalFallback; - this.nationFallback = options.nationFallback; - - this.startYear = resolveStartYear(this.world, this.scenarioMeta); - this.turnTermMinutes = resolveTurnTermMinutes(this.world); - this.env = resolveConstraintEnv(this.world, this.scenarioMeta, this.commandEnv); - - const seed = simpleSerialize( - buildSeedBase(this.world), - 'GeneralAI', - this.world.currentYear, - this.world.currentMonth, - this.general.id - ); - this.rng = new RandUtil(LiteHashDRBG.build(seed)); - - const constValues = asRecord(this.scenarioConfig.const); - this.aiConst = { - baseGold: this.commandEnv.baseGold, - baseRice: this.commandEnv.baseRice, - minAvailableRecruitPop: readNumber(constValues.minAvailableRecruitPop, 30000), - maxResourceActionAmount: this.commandEnv.maxResourceActionAmount || 10000, - minNationalGold: readNumber(constValues.minNationalGold, this.commandEnv.baseGold), - minNationalRice: readNumber(constValues.minNationalRice, this.commandEnv.baseRice), - defaultStatMax: this.scenarioConfig.stat.max, - defaultStatNpcMax: this.scenarioConfig.stat.npcMax, - chiefStatMin: this.scenarioConfig.stat.chiefMin, - npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0), - availableNationTypes: Array.isArray(constValues.availableNationType) - ? constValues.availableNationType.filter((value) => typeof value === 'string') - : [], - }; - - const generalPolicy = new AutorunGeneralPolicy( - this.general, - asRecord((this.world.meta as Record)?.autorun_user)?.options as Record, - asRecord(this.nation?.meta)?.npc_general_policy as Record | null, - asRecord(this.world.meta)?.npc_general_policy as Record | null - ); - const nationPolicy = new AutorunNationPolicy({ - general: this.general, - aiOptions: asRecord((this.world.meta as Record)?.autorun_user)?.options as Record< - string, - boolean - > | null, - nationPolicy: asRecord(this.nation?.meta)?.npc_nation_policy as Record | null, - serverPolicy: asRecord(this.world.meta)?.npc_nation_policy as Record | null, - nation: this.nation ?? { - id: 0, - name: '재야', - color: '#000000', - capitalCityId: null, - chiefGeneralId: null, - gold: 0, - rice: 0, - power: 0, - level: 0, - typeCode: 'neutral', - meta: {}, - }, - env: this.commandEnv, - scenarioConfig: this.scenarioConfig, - unitSet: this.unitSet, - }); - - this.generalPolicy = generalPolicy; - this.nationPolicy = nationPolicy; - } - - chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null { - this.updateInstance(); - if (!this.nation || !this.worldRef) { - return null; - } - this.categorizeNationCities(); - this.categorizeNationGeneral(); - - if (reservedTurn.action !== ACTION_REST) { - const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved'); - if (reservedCandidate) { - return reservedCandidate; - } - } - - for (const actionName of this.nationPolicy.priority) { - if (!this.nationPolicy.can(actionName)) { - continue; - } - if (this.general.npcState < 2 && !AVAILABLE_INSTANT_TURN[actionName]) { - continue; - } - const handler = nationActionHandlers[actionName]; - if (!handler) { - continue; - } - const result = handler(this); - if (result) { - return result; - } - } - - return this.buildNationCandidate(ACTION_REST, {}, 'neutral'); - } - - chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null { - this.updateInstance(); - if (!this.worldRef) { - return null; - } - - if (this.general.npcState === 5) { - 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; - } - } - - if ( - readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold - ) { - const heal = this.buildGeneralCandidate('che_요양', {}, 'heal'); - if (heal) { - return heal; - } - } - - if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) { - const rebellion = generalActionHandlers['거병']?.(this); - if (rebellion) { - return rebellion; - } - } - - if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) { - const pickNation = generalActionHandlers['국가선택']?.(this); - if (pickNation) { - return pickNation; - } - const neutral = generalActionHandlers['중립']?.(this); - return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral'); - } - - if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) { - return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user'); - } - - if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) { - const worldMeta = asRecord(this.world.meta); - const initYear = readNumber(worldMeta.initYear, this.scenarioMeta?.startYear ?? this.startYear); - const initMonth = readNumber(worldMeta.initMonth, 1); - const relYearMonth = - joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth); - if (relYearMonth > 1) { - const establish = generalActionHandlers['건국']?.(this); - if (establish) { - return establish; - } - } - const move = generalActionHandlers['방랑군이동']?.(this); - if (move) { - return move; - } - } - - for (const actionName of this.generalPolicy.priority) { - if (!this.generalPolicy.can(actionName)) { - continue; - } - const handler = generalActionHandlers[actionName]; - if (!handler) { - continue; - } - const result = handler(this); - if (result) { - return result; - } - } - - const neutral = generalActionHandlers['중립']?.(this); - return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral'); - } - - getDebugState(): GeneralAiDebugState { - this.updateInstance(); - this.categorizeNationCities(); - const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth); - const startYearMonth = joinYearMonth(this.startYear + 2, 5); - const meta = asRecord(this.nation?.meta ?? {}); - const lastAttackable = readMetaNumber(meta, 'last_attackable', 0); - - return { - generalId: this.general.id, - nationId: this.nation?.id ?? null, - cityId: this.city?.id ?? null, - yearMonth, - startYear: this.startYear, - startYearMonth, - dipState: this.dipState, - attackable: this.attackable, - warTargetNation: { ...this.warTargetNation }, - genType: this.genType, - lastAttackable, - frontCities: Object.values(this.frontCities).map((city) => ({ - id: city.id, - frontState: city.frontState, - supplyState: city.supplyState, - })), - supplyCities: Object.values(this.supplyCities).map((city) => ({ - id: city.id, - frontState: city.frontState, - supplyState: city.supplyState, - })), - policy: { - minAvailableRecruitPop: this.aiConst.minAvailableRecruitPop, - minNpcWarLeadership: this.nationPolicy.minNpcWarLeadership, - minWarCrew: this.nationPolicy.minWarCrew, - cureThreshold: this.nationPolicy.cureThreshold, - }, - }; - } - - buildGeneralCandidate(action: string, args: Record, reason: string): AiCommandCandidate | null { - return this.buildCandidate(this.generalDefinitions, this.generalFallback, action, args, reason); - } - - buildNationCandidate(action: string, args: Record, reason: string): AiCommandCandidate | null { - return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason); - } - - getReservedTurn(generalId: number): ReservedTurnEntry { - return this.reservedTurnProvider.getGeneralTurn(generalId, 0); - } - - calcNationDevelopedRate(): Record { - if (this.devRate) { - return this.devRate; - } - this.categorizeNationCities(); - const devRate: Record = { all: 0 }; - const cities = Object.values(this.supplyCities); - if (cities.length === 0) { - this.devRate = devRate; - return devRate; - } - - for (const city of cities) { - const entries = this.calcCityDevelRate(city); - for (const [key, [score]] of Object.entries(entries)) { - if (key === 'trust') { - continue; - } - devRate[key] = (devRate[key] ?? 0) + score; - devRate.all += score; - } - } - for (const key of Object.keys(devRate)) { - devRate[key] /= cities.length; - } - devRate.all /= Math.max(1, Object.keys(devRate).length - 1); - this.devRate = devRate; - return devRate; - } - - calcCityDevelRate(city: City): Record { - const trust = readMetaNumber(asRecord(city.meta), 'trust', 0) / 100; - return { - trust: [trust, t통솔장], - pop: [city.populationMax > 0 ? city.population / city.populationMax : 0, t통솔장], - agri: [city.agricultureMax > 0 ? city.agriculture / city.agricultureMax : 0, t지장], - comm: [city.commerceMax > 0 ? city.commerce / city.commerceMax : 0, t지장], - secu: [city.securityMax > 0 ? city.security / city.securityMax : 0, t무장], - def: [city.defenceMax > 0 ? city.defence / city.defenceMax : 0, t무장], - wall: [city.wallMax > 0 ? city.wall / city.wallMax : 0, t무장], - }; - } - - calcWarRoute(): void { - if (this.warRoute || !this.map || !this.worldRef) { - return; - } - const target = Object.keys(this.warTargetNation).map((key) => Number(key)); - if (this.nation) { - target.push(this.nation.id); - } - this.warRoute = searchAllDistanceByNationList(this.map, this.worldRef.listCities(), target, false); - } - - categorizeNationCities(): void { - if (this.categorizedCities) { - return; - } - this.categorizedCities = true; - - if (!this.nation || !this.worldRef) { - return; - } - - const nationId = this.nation.id; - const nationCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - const frontCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - const supplyCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - const backupCities: Record< - number, - City & { dev: number; important: number; generals?: Record } - > = {}; - - for (const city of this.worldRef.listCities()) { - if (city.nationId !== nationId) { - continue; - } - const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax; - const dev = - max > 0 ? (city.agriculture + city.commerce + city.security + city.defence + city.wall) / max : 0; - const entry = { ...city, dev, important: 1, generals: {} as Record }; - nationCities[city.id] = entry; - if (city.supplyState > 0) { - supplyCities[city.id] = entry; - if (city.frontState <= 0) { - backupCities[city.id] = entry; - } - } - if (city.frontState > 0) { - frontCities[city.id] = entry; - } - } - - this.nationCities = nationCities; - this.frontCities = frontCities; - this.supplyCities = supplyCities; - this.backupCities = backupCities; - } - - categorizeNationGeneral(): void { - if (this.categorizedGenerals) { - return; - } - this.categorizedGenerals = true; - - if (!this.nation || !this.worldRef) { - return; - } - - this.categorizeNationCities(); - - const nationId = this.nation.id; - const nationGenerals = this.worldRef - .listGenerals() - .filter((general) => general.nationId === nationId && general.id !== this.general.id); - - const userGenerals: Record = {}; - const userCivilGenerals: Record = {}; - const userWarGenerals: Record = {}; - const lostGenerals: Record = {}; - const npcCivilGenerals: Record = {}; - const npcWarGenerals: Record = {}; - const troopLeaders: Record = {}; - const chiefGenerals: Record = {}; - - let lastWar = Number.MAX_SAFE_INTEGER; - for (const candidate of nationGenerals) { - const belong = readMetaNumber(asRecord(candidate.meta), 'belong', 0); - const recentWarTurn = this.calcRecentWarTurn(candidate); - if (belong > 0 && recentWarTurn >= (belong - 1) * 12) { - continue; - } - lastWar = Math.min(lastWar, recentWarTurn); - } - - for (const candidate of nationGenerals) { - const officerLevel = candidate.officerLevel; - const npcType = candidate.npcState; - const officerCity = readMetaNumber( - asRecord(candidate.meta), - 'officer_city', - readMetaNumber(asRecord(candidate.meta), 'officerCity', 0) - ); - - if (officerLevel > 4) { - chiefGenerals[officerLevel] = candidate; - } else if (officerLevel >= 2 && officerCity > 0 && this.nationCities[officerCity]) { - this.nationCities[officerCity].important += 1; - } - - const cityId = candidate.cityId; - const city = this.nationCities[cityId]; - if (city) { - city.generals ??= {}; - city.generals[candidate.id] = candidate; - if (city.supplyState <= 0) { - lostGenerals[candidate.id] = candidate; - } - } else { - lostGenerals[candidate.id] = candidate; - } - - const isTroopLeader = - npcType === 5 || - (candidate.troopId === candidate.id && this.getReservedTurn(candidate.id).action === 'che_집합'); - if (isTroopLeader) { - troopLeaders[candidate.id] = candidate; - continue; - } - - const killturn = readMetaNumber(asRecord(candidate.meta), 'killturn', 999); - if (killturn <= 5) { - npcCivilGenerals[candidate.id] = candidate; - continue; - } - - if (npcType < 2) { - userGenerals[candidate.id] = candidate; - const recentWarTurn = this.calcRecentWarTurn(candidate); - if (recentWarTurn <= lastWar + 12) { - userWarGenerals[candidate.id] = candidate; - } else if (this.dipState !== d평화 && candidate.crew >= this.nationPolicy.minWarCrew) { - userWarGenerals[candidate.id] = candidate; - } else { - userCivilGenerals[candidate.id] = candidate; - } - continue; - } - - if (candidate.stats.leadership >= this.nationPolicy.minNpcWarLeadership) { - npcWarGenerals[candidate.id] = candidate; - } else { - npcCivilGenerals[candidate.id] = candidate; - } - } - - this.nationGenerals = nationGenerals; - this.userGenerals = userGenerals; - this.userCivilGenerals = userCivilGenerals; - this.userWarGenerals = userWarGenerals; - this.lostGenerals = lostGenerals; - this.npcCivilGenerals = npcCivilGenerals; - this.npcWarGenerals = npcWarGenerals; - this.troopLeaders = troopLeaders; - this.chiefGenerals = chiefGenerals; - } - - private updateInstance(): void { - if (!this.reqUpdateInstance) { - return; - } - this.reqUpdateInstance = false; - - const nation = this.nation; - if (!nation) { - return; - } - - const baseDevelCost = this.commandEnv.develCost * 12; - const nationMeta = asRecord(nation.meta); - const prevIncomeGold = readMetaNumber(nationMeta, 'prev_income_gold', 1000); - const prevIncomeRice = readMetaNumber(nationMeta, 'prev_income_rice', 1000); - const elapsedYears = this.world.currentYear - this.startYear - 3; - const maxCandidate = Math.max( - this.nationPolicy.minimumResourceActionAmount, - prevIncomeGold / 10, - prevIncomeRice / 10, - nation.gold / 5, - nation.rice / 5, - elapsedYears * 1000 - ); - this.maxResourceActionAmount = valueFit( - roundTo(maxCandidate, -2), - null, - this.nationPolicy.maximumResourceActionAmount - ); - if (this.maxResourceActionAmount > this.aiConst.maxResourceActionAmount) { - this.maxResourceActionAmount = this.aiConst.maxResourceActionAmount; - } - - this.calcDiplomacyState(); - this.genType = this.calcGenType(); - - void baseDevelCost; - } - - private buildCandidate( - definitions: Map, - fallback: GeneralActionDefinition, - action: string, - args: Record, - reason: string - ): AiCommandCandidate | null { - const definition = definitions.get(action) ?? fallback; - const parsedArgs = definition.parseArgs(args); - if (parsedArgs === null) { - return null; - } - const constraintEnv = this.buildConstraintEnv(); - const ctx: ConstraintContext = { - actorId: this.general.id, - cityId: this.city?.id, - nationId: this.general.nationId, - args: parsedArgs as Record, - env: constraintEnv, - mode: 'full', - }; - const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record, { - general: this.general, - city: this.city, - nation: this.nation ?? null, - }); - const constraints = definition.buildConstraints(ctx, parsedArgs as never); - const result = evaluateConstraints(constraints, ctx, view); - if (result.kind !== 'allow') { - return null; - } - return { - action: definition.key, - args: parsedArgs as Record, - reason, - }; - } - - private buildConstraintEnv(): ConstraintEnv { - return { - ...this.env, - cities: this.worldRef?.listCities() ?? [], - nations: this.worldRef?.listNations() ?? [], - map: this.map, - unitSet: this.unitSet, - }; - } - - private calcGenType(): number { - const leadership = this.general.stats.leadership; - const strength = Math.max(this.general.stats.strength, 1); - const intel = Math.max(this.general.stats.intelligence, 1); - let genType = 0; - - if (strength >= intel) { - genType = t무장; - if (intel >= strength * 0.8) { - if (this.rng.nextBool(intel / strength / 2)) { - genType |= t지장; - } - } - } else { - genType = t지장; - if (strength >= intel * 0.8) { - if (this.rng.nextBool(strength / intel / 2)) { - genType |= t무장; - } - } - } - - if (leadership >= this.nationPolicy.minNpcWarLeadership) { - genType |= t통솔장; - } - - return genType; - } - - private calcDiplomacyState(): void { - if (!this.nation || !this.worldRef) { - return; - } - const nationId = this.nation.id; - const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth); - const startYearMonth = joinYearMonth(this.startYear + 2, 5); - - const warTargets = this.worldRef - .listDiplomacy() - .filter((entry) => entry.fromNationId === nationId && (entry.state === 0 || entry.state === 1)); - - if (yearMonth <= startYearMonth) { - this.dipState = warTargets.length === 0 ? d평화 : d선포; - this.attackable = false; - return; - } - - const frontStatus = this.worldRef - .listCities() - .some((city) => city.nationId === nationId && city.supplyState > 0 && city.frontState > 0); - this.attackable = frontStatus; - - let onWar = 0; - let onWarReady = 0; - const warTargetNation: Record = {}; - for (const entry of warTargets) { - if (entry.state === 0) { - onWar += 1; - warTargetNation[entry.toNationId] = 2; - } else if (entry.state === 1 && entry.term < 5) { - onWarReady += 1; - warTargetNation[entry.toNationId] = 1; - } - } - if (onWar === 0 && onWarReady === 0) { - warTargetNation[0] = 1; - } - this.warTargetNation = warTargetNation; - - const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term); - const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null; - if (minWarTerm === null) { - this.dipState = d평화; - } else if (minWarTerm > 8) { - this.dipState = d선포; - } else if (minWarTerm > 5) { - this.dipState = d징병; - } else { - this.dipState = d직전; - } - - const meta = asRecord(this.nation.meta); - const lastAttackable = readMetaNumber(meta, 'last_attackable', 0); - if (Object.prototype.hasOwnProperty.call(warTargetNation, 0) && this.attackable) { - this.dipState = d전쟁; - } else if (onWar > 0) { - if (this.attackable) { - this.dipState = d전쟁; - } else if (lastAttackable >= yearMonth - 5) { - this.dipState = d전쟁; - } - } - } - - private calcRecentWarTurn(general: TurnGeneral): number { - const recent = general.recentWarTime; - if (!recent) { - return 12000; - } - const diffMs = general.turnTime.getTime() - recent.getTime(); - if (diffMs <= 0) { - return 0; - } - const turnMs = this.turnTermMinutes * 60 * 1000; - if (turnMs <= 0) { - return 12000; - } - return Math.floor(diffMs / turnMs); - } -} - -export const shouldUseAi = (general: TurnGeneral, world: TurnWorldState): boolean => { - if (general.npcState >= 2) { - return true; - } - const meta = asRecord(general.meta); - const limit = readMetaNumber(meta, 'autorun_limit', 0); - if (limit <= 0) { - return false; - } - const current = joinYearMonth(world.currentYear, world.currentMonth); - return current < limit; -}; +export * from './generalAi/core.js'; +export type { GeneralAIOptions, GeneralAiDebugState } from './generalAi/types.js'; diff --git a/app/game-engine/src/turn/ai/generalAi/constraint.ts b/app/game-engine/src/turn/ai/generalAi/constraint.ts new file mode 100644 index 0000000..5835e98 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/constraint.ts @@ -0,0 +1,25 @@ +import type { ScenarioMeta, TurnCommandEnv } from '@sammo-ts/logic'; + +import type { TurnWorldState } from '../../types.js'; + +export type ConstraintEnv = Record; + +export const resolveConstraintEnv = ( + world: TurnWorldState, + scenarioMeta: ScenarioMeta | undefined, + env: TurnCommandEnv +): ConstraintEnv => { + const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; + const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; + + return { + currentYear: world.currentYear, + currentMonth: world.currentMonth, + year: world.currentYear, + month: world.currentMonth, + startYear, + relYear, + openingPartYear: env.openingPartYear, + minAvailableRecruitPop: env.minAvailableRecruitPop, + }; +}; diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts new file mode 100644 index 0000000..c04c949 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -0,0 +1,802 @@ +import type { + City, + GeneralActionDefinition, + MapDefinition, + Nation, + ScenarioConfig, + ScenarioMeta, + TurnCommandEnv, + UnitSetDefinition, +} from '@sammo-ts/logic'; +import { evaluateConstraints } from '@sammo-ts/logic'; +import type { ConstraintContext } from '@sammo-ts/logic'; +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; +import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; + +import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; +import type { TurnGeneral, TurnWorldState } from '../../types.js'; +import type { AiCommandCandidate, AiReservedTurnProvider, AiWorldView } from '../types.js'; +import { AutorunGeneralPolicy, AutorunNationPolicy, AVAILABLE_INSTANT_TURN } from '../policies.js'; +import { asRecord, joinYearMonth, readMetaNumber, readNumber, roundTo, valueFit } from '../aiUtils.js'; +import { searchAllDistanceByNationList } from '../distance.js'; +import { generalActionHandlers } from '../generalAiGeneralActions.js'; +import { nationActionHandlers } from '../generalAiNationActions.js'; +import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js'; +import { buildSeedBase } from './seed.js'; +import { WorldStateView } from './worldStateView.js'; +import type { GeneralAIOptions, GeneralAiDebugState } from './types.js'; + +const ACTION_REST = '휴식'; + +const t무장 = 1; +const t지장 = 2; +const t통솔장 = 4; + +const d평화 = 0; +const d선포 = 1; +const d징병 = 2; +const d직전 = 3; +const d전쟁 = 4; + +export class GeneralAI { + public readonly general: TurnGeneral; + public readonly city?: City; + public readonly nation?: Nation | null; + public readonly world: TurnWorldState; + public readonly worldRef: AiWorldView | null; + public readonly map?: MapDefinition; + public readonly unitSet?: UnitSetDefinition; + public readonly commandEnv: TurnCommandEnv; + public readonly scenarioConfig: ScenarioConfig; + public readonly scenarioMeta?: ScenarioMeta; + + public readonly generalDefinitions: Map; + public readonly nationDefinitions: Map; + public readonly generalFallback: GeneralActionDefinition; + public readonly nationFallback: GeneralActionDefinition; + + public readonly rng: RandUtil; + public readonly env: ConstraintEnv; + public readonly startYear: number; + public readonly turnTermMinutes: number; + + public readonly aiConst: { + baseGold: number; + baseRice: number; + minAvailableRecruitPop: number; + maxResourceActionAmount: number; + minNationalGold: number; + minNationalRice: number; + defaultStatMax: number; + defaultStatNpcMax: number; + chiefStatMin: number; + npcMessageFreqByDay: number; + availableNationTypes: string[]; + }; + + public generalPolicy: AutorunGeneralPolicy; + public nationPolicy: AutorunNationPolicy; + + public genType = 0; + public dipState = d평화; + public warTargetNation: Record = {}; + public attackable = false; + public maxResourceActionAmount = 0; + + public nationCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public frontCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public supplyCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public backupCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + public warRoute: Record> | null = null; + + public nationGenerals: TurnGeneral[] = []; + public npcCivilGenerals: Record = {}; + public npcWarGenerals: Record = {}; + public userGenerals: Record = {}; + public userWarGenerals: Record = {}; + public userCivilGenerals: Record = {}; + public chiefGenerals: Record = {}; + public lostGenerals: Record = {}; + public troopLeaders: Record = {}; + + private reqUpdateInstance = true; + private devRate: Record | null = null; + private categorizedCities = false; + private categorizedGenerals = false; + + private readonly reservedTurnProvider: AiReservedTurnProvider; + + constructor(options: GeneralAIOptions) { + this.general = options.general; + this.city = options.city; + this.nation = options.nation ?? null; + this.world = options.world; + this.worldRef = options.worldRef; + this.map = options.map; + this.unitSet = options.unitSet; + this.commandEnv = options.commandEnv; + this.scenarioConfig = options.scenarioConfig; + this.scenarioMeta = options.scenarioMeta; + this.reservedTurnProvider = options.reservedTurnProvider; + + this.generalDefinitions = options.generalDefinitions; + this.nationDefinitions = options.nationDefinitions; + this.generalFallback = options.generalFallback; + this.nationFallback = options.nationFallback; + + this.startYear = resolveStartYear(this.world, this.scenarioMeta); + this.turnTermMinutes = resolveTurnTermMinutes(this.world); + this.env = resolveConstraintEnv(this.world, this.scenarioMeta, this.commandEnv); + + const seed = simpleSerialize( + buildSeedBase(this.world), + 'GeneralAI', + this.world.currentYear, + this.world.currentMonth, + this.general.id + ); + this.rng = new RandUtil(LiteHashDRBG.build(seed)); + + const constValues = asRecord(this.scenarioConfig.const); + this.aiConst = { + baseGold: this.commandEnv.baseGold, + baseRice: this.commandEnv.baseRice, + minAvailableRecruitPop: readNumber(constValues.minAvailableRecruitPop, 30000), + maxResourceActionAmount: this.commandEnv.maxResourceActionAmount || 10000, + minNationalGold: readNumber(constValues.minNationalGold, this.commandEnv.baseGold), + minNationalRice: readNumber(constValues.minNationalRice, this.commandEnv.baseRice), + defaultStatMax: this.scenarioConfig.stat.max, + defaultStatNpcMax: this.scenarioConfig.stat.npcMax, + chiefStatMin: this.scenarioConfig.stat.chiefMin, + npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0), + availableNationTypes: Array.isArray(constValues.availableNationType) + ? constValues.availableNationType.filter((value) => typeof value === 'string') + : [], + }; + + const generalPolicy = new AutorunGeneralPolicy( + this.general, + asRecord((this.world.meta as Record)?.autorun_user)?.options as Record, + asRecord(this.nation?.meta)?.npc_general_policy as Record | null, + asRecord(this.world.meta)?.npc_general_policy as Record | null + ); + const nationPolicy = new AutorunNationPolicy({ + general: this.general, + aiOptions: asRecord((this.world.meta as Record)?.autorun_user)?.options as Record< + string, + boolean + > | null, + nationPolicy: asRecord(this.nation?.meta)?.npc_nation_policy as Record | null, + serverPolicy: asRecord(this.world.meta)?.npc_nation_policy as Record | null, + nation: this.nation ?? { + id: 0, + name: '재야', + color: '#000000', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 0, + typeCode: 'neutral', + meta: {}, + }, + env: this.commandEnv, + scenarioConfig: this.scenarioConfig, + unitSet: this.unitSet, + }); + + this.generalPolicy = generalPolicy; + this.nationPolicy = nationPolicy; + } + + chooseNationTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null { + this.updateInstance(); + if (!this.nation || !this.worldRef) { + return null; + } + this.categorizeNationCities(); + this.categorizeNationGeneral(); + + if (reservedTurn.action !== ACTION_REST) { + const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved'); + if (reservedCandidate) { + return reservedCandidate; + } + } + + for (const actionName of this.nationPolicy.priority) { + if (!this.nationPolicy.can(actionName)) { + continue; + } + if (this.general.npcState < 2 && !AVAILABLE_INSTANT_TURN[actionName]) { + continue; + } + const handler = nationActionHandlers[actionName]; + if (!handler) { + continue; + } + const result = handler(this); + if (result) { + return result; + } + } + + return this.buildNationCandidate(ACTION_REST, {}, 'neutral'); + } + + chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null { + this.updateInstance(); + if (!this.worldRef) { + return null; + } + + if (this.general.npcState === 5) { + 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; + } + } + + if ( + readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold + ) { + const heal = this.buildGeneralCandidate('che_요양', {}, 'heal'); + if (heal) { + return heal; + } + } + + if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) { + const rebellion = generalActionHandlers['거병']?.(this); + if (rebellion) { + return rebellion; + } + } + + if (this.general.nationId === 0 && this.generalPolicy.can('국가선택')) { + const pickNation = generalActionHandlers['국가선택']?.(this); + if (pickNation) { + return pickNation; + } + const neutral = generalActionHandlers['중립']?.(this); + return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral'); + } + + if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) { + return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user'); + } + + if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) { + const worldMeta = asRecord(this.world.meta); + const initYear = readNumber(worldMeta.initYear, this.scenarioMeta?.startYear ?? this.startYear); + const initMonth = readNumber(worldMeta.initMonth, 1); + const relYearMonth = + joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth); + if (relYearMonth > 1) { + const establish = generalActionHandlers['건국']?.(this); + if (establish) { + return establish; + } + } + const move = generalActionHandlers['방랑군이동']?.(this); + if (move) { + return move; + } + } + + for (const actionName of this.generalPolicy.priority) { + if (!this.generalPolicy.can(actionName)) { + continue; + } + const handler = generalActionHandlers[actionName]; + if (!handler) { + continue; + } + const result = handler(this); + if (result) { + return result; + } + } + + const neutral = generalActionHandlers['중립']?.(this); + return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral'); + } + + getDebugState(): GeneralAiDebugState { + this.updateInstance(); + this.categorizeNationCities(); + const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth); + const startYearMonth = joinYearMonth(this.startYear + 2, 5); + const meta = asRecord(this.nation?.meta ?? {}); + const lastAttackable = readMetaNumber(meta, 'last_attackable', 0); + + return { + generalId: this.general.id, + nationId: this.nation?.id ?? null, + cityId: this.city?.id ?? null, + yearMonth, + startYear: this.startYear, + startYearMonth, + dipState: this.dipState, + attackable: this.attackable, + warTargetNation: { ...this.warTargetNation }, + genType: this.genType, + lastAttackable, + frontCities: Object.values(this.frontCities).map((city) => ({ + id: city.id, + frontState: city.frontState, + supplyState: city.supplyState, + })), + supplyCities: Object.values(this.supplyCities).map((city) => ({ + id: city.id, + frontState: city.frontState, + supplyState: city.supplyState, + })), + policy: { + minAvailableRecruitPop: this.aiConst.minAvailableRecruitPop, + minNpcWarLeadership: this.nationPolicy.minNpcWarLeadership, + minWarCrew: this.nationPolicy.minWarCrew, + cureThreshold: this.nationPolicy.cureThreshold, + }, + }; + } + + buildGeneralCandidate(action: string, args: Record, reason: string): AiCommandCandidate | null { + return this.buildCandidate(this.generalDefinitions, this.generalFallback, action, args, reason); + } + + buildNationCandidate(action: string, args: Record, reason: string): AiCommandCandidate | null { + return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason); + } + + getReservedTurn(generalId: number): ReservedTurnEntry { + return this.reservedTurnProvider.getGeneralTurn(generalId, 0); + } + + calcNationDevelopedRate(): Record { + if (this.devRate) { + return this.devRate; + } + this.categorizeNationCities(); + const devRate: Record = { all: 0 }; + const cities = Object.values(this.supplyCities); + if (cities.length === 0) { + this.devRate = devRate; + return devRate; + } + + for (const city of cities) { + const entries = this.calcCityDevelRate(city); + for (const [key, [score]] of Object.entries(entries)) { + if (key === 'trust') { + continue; + } + devRate[key] = (devRate[key] ?? 0) + score; + devRate.all += score; + } + } + for (const key of Object.keys(devRate)) { + devRate[key] /= cities.length; + } + devRate.all /= Math.max(1, Object.keys(devRate).length - 1); + this.devRate = devRate; + return devRate; + } + + calcCityDevelRate(city: City): Record { + const trust = readMetaNumber(asRecord(city.meta), 'trust', 0) / 100; + return { + trust: [trust, t통솔장], + pop: [city.populationMax > 0 ? city.population / city.populationMax : 0, t통솔장], + agri: [city.agricultureMax > 0 ? city.agriculture / city.agricultureMax : 0, t지장], + comm: [city.commerceMax > 0 ? city.commerce / city.commerceMax : 0, t지장], + secu: [city.securityMax > 0 ? city.security / city.securityMax : 0, t무장], + def: [city.defenceMax > 0 ? city.defence / city.defenceMax : 0, t무장], + wall: [city.wallMax > 0 ? city.wall / city.wallMax : 0, t무장], + }; + } + + calcWarRoute(): void { + if (this.warRoute || !this.map || !this.worldRef) { + return; + } + const target = Object.keys(this.warTargetNation).map((key) => Number(key)); + if (this.nation) { + target.push(this.nation.id); + } + this.warRoute = searchAllDistanceByNationList(this.map, this.worldRef.listCities(), target, false); + } + + categorizeNationCities(): void { + if (this.categorizedCities) { + return; + } + this.categorizedCities = true; + + if (!this.nation || !this.worldRef) { + return; + } + + const nationId = this.nation.id; + const nationCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + const frontCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + const supplyCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + const backupCities: Record< + number, + City & { dev: number; important: number; generals?: Record } + > = {}; + + for (const city of this.worldRef.listCities()) { + if (city.nationId !== nationId) { + continue; + } + const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax; + const dev = + max > 0 ? (city.agriculture + city.commerce + city.security + city.defence + city.wall) / max : 0; + const entry = { ...city, dev, important: 1, generals: {} as Record }; + nationCities[city.id] = entry; + if (city.supplyState > 0) { + supplyCities[city.id] = entry; + if (city.frontState <= 0) { + backupCities[city.id] = entry; + } + } + if (city.frontState > 0) { + frontCities[city.id] = entry; + } + } + + this.nationCities = nationCities; + this.frontCities = frontCities; + this.supplyCities = supplyCities; + this.backupCities = backupCities; + } + + categorizeNationGeneral(): void { + if (this.categorizedGenerals) { + return; + } + this.categorizedGenerals = true; + + if (!this.nation || !this.worldRef) { + return; + } + + this.categorizeNationCities(); + + const nationId = this.nation.id; + const nationGenerals = this.worldRef + .listGenerals() + .filter((general) => general.nationId === nationId && general.id !== this.general.id); + + const userGenerals: Record = {}; + const userCivilGenerals: Record = {}; + const userWarGenerals: Record = {}; + const lostGenerals: Record = {}; + const npcCivilGenerals: Record = {}; + const npcWarGenerals: Record = {}; + const troopLeaders: Record = {}; + const chiefGenerals: Record = {}; + + let lastWar = Number.MAX_SAFE_INTEGER; + for (const candidate of nationGenerals) { + const belong = readMetaNumber(asRecord(candidate.meta), 'belong', 0); + const recentWarTurn = this.calcRecentWarTurn(candidate); + if (belong > 0 && recentWarTurn >= (belong - 1) * 12) { + continue; + } + lastWar = Math.min(lastWar, recentWarTurn); + } + + for (const candidate of nationGenerals) { + const officerLevel = candidate.officerLevel; + const npcType = candidate.npcState; + const officerCity = readMetaNumber( + asRecord(candidate.meta), + 'officer_city', + readMetaNumber(asRecord(candidate.meta), 'officerCity', 0) + ); + + if (officerLevel > 4) { + chiefGenerals[officerLevel] = candidate; + } else if (officerLevel >= 2 && officerCity > 0 && this.nationCities[officerCity]) { + this.nationCities[officerCity].important += 1; + } + + const cityId = candidate.cityId; + const city = this.nationCities[cityId]; + if (city) { + city.generals ??= {}; + city.generals[candidate.id] = candidate; + if (city.supplyState <= 0) { + lostGenerals[candidate.id] = candidate; + } + } else { + lostGenerals[candidate.id] = candidate; + } + + const isTroopLeader = + npcType === 5 || + (candidate.troopId === candidate.id && this.getReservedTurn(candidate.id).action === 'che_집합'); + if (isTroopLeader) { + troopLeaders[candidate.id] = candidate; + continue; + } + + const killturn = readMetaNumber(asRecord(candidate.meta), 'killturn', 999); + if (killturn <= 5) { + npcCivilGenerals[candidate.id] = candidate; + continue; + } + + if (npcType < 2) { + userGenerals[candidate.id] = candidate; + const recentWarTurn = this.calcRecentWarTurn(candidate); + if (recentWarTurn <= lastWar + 12) { + userWarGenerals[candidate.id] = candidate; + } else if (this.dipState !== d평화 && candidate.crew >= this.nationPolicy.minWarCrew) { + userWarGenerals[candidate.id] = candidate; + } else { + userCivilGenerals[candidate.id] = candidate; + } + continue; + } + + if (candidate.stats.leadership >= this.nationPolicy.minNpcWarLeadership) { + npcWarGenerals[candidate.id] = candidate; + } else { + npcCivilGenerals[candidate.id] = candidate; + } + } + + this.nationGenerals = nationGenerals; + this.userGenerals = userGenerals; + this.userCivilGenerals = userCivilGenerals; + this.userWarGenerals = userWarGenerals; + this.lostGenerals = lostGenerals; + this.npcCivilGenerals = npcCivilGenerals; + this.npcWarGenerals = npcWarGenerals; + this.troopLeaders = troopLeaders; + this.chiefGenerals = chiefGenerals; + } + + private updateInstance(): void { + if (!this.reqUpdateInstance) { + return; + } + this.reqUpdateInstance = false; + + const nation = this.nation; + if (!nation) { + return; + } + + const baseDevelCost = this.commandEnv.develCost * 12; + const nationMeta = asRecord(nation.meta); + const prevIncomeGold = readMetaNumber(nationMeta, 'prev_income_gold', 1000); + const prevIncomeRice = readMetaNumber(nationMeta, 'prev_income_rice', 1000); + const elapsedYears = this.world.currentYear - this.startYear - 3; + const maxCandidate = Math.max( + this.nationPolicy.minimumResourceActionAmount, + prevIncomeGold / 10, + prevIncomeRice / 10, + nation.gold / 5, + nation.rice / 5, + elapsedYears * 1000 + ); + this.maxResourceActionAmount = valueFit( + roundTo(maxCandidate, -2), + null, + this.nationPolicy.maximumResourceActionAmount + ); + if (this.maxResourceActionAmount > this.aiConst.maxResourceActionAmount) { + this.maxResourceActionAmount = this.aiConst.maxResourceActionAmount; + } + + this.calcDiplomacyState(); + this.genType = this.calcGenType(); + + void baseDevelCost; + } + + private buildCandidate( + definitions: Map, + fallback: GeneralActionDefinition, + action: string, + args: Record, + reason: string + ): AiCommandCandidate | null { + const definition = definitions.get(action) ?? fallback; + const parsedArgs = definition.parseArgs(args); + if (parsedArgs === null) { + return null; + } + const constraintEnv = this.buildConstraintEnv(); + const ctx: ConstraintContext = { + actorId: this.general.id, + cityId: this.city?.id, + nationId: this.general.nationId, + args: parsedArgs as Record, + env: constraintEnv, + mode: 'full', + }; + const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record, { + general: this.general, + city: this.city, + nation: this.nation ?? null, + }); + const constraints = definition.buildConstraints(ctx, parsedArgs as never); + const result = evaluateConstraints(constraints, ctx, view); + if (result.kind !== 'allow') { + return null; + } + return { + action: definition.key, + args: parsedArgs as Record, + reason, + }; + } + + private buildConstraintEnv(): ConstraintEnv { + return { + ...this.env, + cities: this.worldRef?.listCities() ?? [], + nations: this.worldRef?.listNations() ?? [], + map: this.map, + unitSet: this.unitSet, + }; + } + + private calcGenType(): number { + const leadership = this.general.stats.leadership; + const strength = Math.max(this.general.stats.strength, 1); + const intel = Math.max(this.general.stats.intelligence, 1); + let genType = 0; + + if (strength >= intel) { + genType = t무장; + if (intel >= strength * 0.8) { + if (this.rng.nextBool(intel / strength / 2)) { + genType |= t지장; + } + } + } else { + genType = t지장; + if (strength >= intel * 0.8) { + if (this.rng.nextBool(strength / intel / 2)) { + genType |= t무장; + } + } + } + + if (leadership >= this.nationPolicy.minNpcWarLeadership) { + genType |= t통솔장; + } + + return genType; + } + + private calcDiplomacyState(): void { + if (!this.nation || !this.worldRef) { + return; + } + const nationId = this.nation.id; + const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth); + const startYearMonth = joinYearMonth(this.startYear + 2, 5); + + const warTargets = this.worldRef + .listDiplomacy() + .filter((entry) => entry.fromNationId === nationId && (entry.state === 0 || entry.state === 1)); + + if (yearMonth <= startYearMonth) { + this.dipState = warTargets.length === 0 ? d평화 : d선포; + this.attackable = false; + return; + } + + const frontStatus = this.worldRef + .listCities() + .some((city) => city.nationId === nationId && city.supplyState > 0 && city.frontState > 0); + this.attackable = frontStatus; + + let onWar = 0; + let onWarReady = 0; + const warTargetNation: Record = {}; + for (const entry of warTargets) { + if (entry.state === 0) { + onWar += 1; + warTargetNation[entry.toNationId] = 2; + } else if (entry.state === 1 && entry.term < 5) { + onWarReady += 1; + warTargetNation[entry.toNationId] = 1; + } + } + if (onWar === 0 && onWarReady === 0) { + warTargetNation[0] = 1; + } + this.warTargetNation = warTargetNation; + + const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term); + const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null; + if (minWarTerm === null) { + this.dipState = d평화; + } else if (minWarTerm > 8) { + this.dipState = d선포; + } else if (minWarTerm > 5) { + this.dipState = d징병; + } else { + this.dipState = d직전; + } + + const meta = asRecord(this.nation.meta); + const lastAttackable = readMetaNumber(meta, 'last_attackable', 0); + if (Object.prototype.hasOwnProperty.call(warTargetNation, 0) && this.attackable) { + this.dipState = d전쟁; + } else if (onWar > 0) { + if (this.attackable) { + this.dipState = d전쟁; + } else if (lastAttackable >= yearMonth - 5) { + this.dipState = d전쟁; + } + } + } + + private calcRecentWarTurn(general: TurnGeneral): number { + const recent = general.recentWarTime; + if (!recent) { + return 12000; + } + const diffMs = general.turnTime.getTime() - recent.getTime(); + if (diffMs <= 0) { + return 0; + } + const turnMs = this.turnTermMinutes * 60 * 1000; + if (turnMs <= 0) { + return 12000; + } + return Math.floor(diffMs / turnMs); + } +} + +export const shouldUseAi = (general: TurnGeneral, world: TurnWorldState): boolean => { + if (general.npcState >= 2) { + return true; + } + const meta = asRecord(general.meta); + const limit = readMetaNumber(meta, 'autorun_limit', 0); + if (limit <= 0) { + return false; + } + const current = joinYearMonth(world.currentYear, world.currentMonth); + return current < limit; +}; + +export type { GeneralAIOptions, GeneralAiDebugState }; diff --git a/app/game-engine/src/turn/ai/generalAi/general/devActions.ts b/app/game-engine/src/turn/ai/generalAi/general/devActions.ts new file mode 100644 index 0000000..b272943 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/devActions.ts @@ -0,0 +1,189 @@ +import type { GeneralAI } from '../core.js'; +import { valueFit } from '../../aiUtils.js'; +import { pickWeightedCandidate, resolveCityTrust, t무장, t지장, t통솔장 } from './helpers.js'; + +export const do일반내정 = (ai: GeneralAI) => { + const city = ai.city; + const nation = ai.nation; + if (!city || !nation) { + return null; + } + + if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) { + return null; + } + + const develRate = ai.calcCityDevelRate(city); + const isSpringSummer = ai.world.currentMonth <= 6; + const cmdList: Array<[ReturnType, number]> = []; + + if (ai.genType & t통솔장) { + if (develRate.trust[0] < 0.98) { + cmdList.push([ + ai.buildGeneralCandidate('che_주민선정', {}, '일반내정'), + (ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, + ]); + } + if (develRate.pop[0] < 0.8) { + cmdList.push([ + ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'), + ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001), + ]); + } else if (develRate.pop[0] < 0.99) { + cmdList.push([ + ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'), + ai.general.stats.leadership / valueFit(develRate.pop[0] / 4, 0.001), + ]); + } + } + + if (ai.genType & t무장) { + if (develRate.def[0] < 1) { + cmdList.push([ + ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'), + ai.general.stats.strength / valueFit(develRate.def[0], 0.001), + ]); + } + if (develRate.wall[0] < 1) { + cmdList.push([ + ai.buildGeneralCandidate('che_성벽보수', {}, '일반내정'), + ai.general.stats.strength / valueFit(develRate.wall[0], 0.001), + ]); + } + if (develRate.secu[0] < 0.9) { + cmdList.push([ + ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'), + ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1), + ]); + } else if (develRate.secu[0] < 1) { + cmdList.push([ + ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'), + ai.general.stats.strength / 2 / valueFit(develRate.secu[0], 0.001), + ]); + } + } + + if (ai.genType & t지장) { + cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), ai.general.stats.intelligence]); + if (develRate.agri[0] < 1) { + cmdList.push([ + ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'), + ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / valueFit(develRate.agri[0], 0.001, 1), + ]); + } + if (develRate.comm[0] < 1) { + cmdList.push([ + ai.buildGeneralCandidate('che_상업투자', {}, '일반내정'), + ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / valueFit(develRate.comm[0], 0.001, 1), + ]); + } + } + + return pickWeightedCandidate(ai, cmdList); +}; + +export const do긴급내정 = (ai: GeneralAI) => { + const city = ai.city; + if (!city) { + return null; + } + if (ai.dipState === 0) { + return null; + } + const trust = resolveCityTrust(city); + if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) { + return ai.buildGeneralCandidate('che_주민선정', {}, '긴급내정'); + } + if ( + city.population < ai.nationPolicy.minNpcRecruitCityPopulation && + ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin / 2) + ) { + return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정'); + } + return null; +}; + +export const do전쟁내정 = (ai: GeneralAI) => { + const city = ai.city; + const nation = ai.nation; + if (!city || !nation) { + return null; + } + if (ai.dipState === 0) { + return null; + } + if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) { + return null; + } + if (ai.rng.nextBool(0.3)) { + return null; + } + const develRate = ai.calcCityDevelRate(city); + const isSpringSummer = ai.world.currentMonth <= 6; + const cmdList: Array<[ReturnType, number]> = []; + + if (ai.genType & t통솔장) { + if (develRate.trust[0] < 0.98) { + cmdList.push([ + ai.buildGeneralCandidate('che_주민선정', {}, '전쟁내정'), + (ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, + ]); + } + 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; + cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]); + } + } + + if (ai.genType & t무장) { + if (develRate.def[0] < 0.5) { + cmdList.push([ + ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'), + ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2, + ]); + } + if (develRate.wall[0] < 0.5) { + cmdList.push([ + ai.buildGeneralCandidate('che_성벽보수', {}, '전쟁내정'), + ai.general.stats.strength / valueFit(develRate.wall[0], 0.001) / 2, + ]); + } + if (develRate.secu[0] < 0.5) { + cmdList.push([ + ai.buildGeneralCandidate('che_치안강화', {}, '전쟁내정'), + ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4, + ]); + } + } + + if (ai.genType & t지장) { + cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), ai.general.stats.intelligence]); + 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); + 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); + cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]); + } + } + + return pickWeightedCandidate(ai, cmdList); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts b/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts new file mode 100644 index 0000000..9363994 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts @@ -0,0 +1,83 @@ +import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; + +import type { GeneralAI } from '../core.js'; +import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js'; + +export const do금쌀구매 = (ai: GeneralAI) => { + const city = ai.city; + if (!city) { + return null; + } + + const trade = readMetaNumber(asRecord(city.meta), 'trade', 0); + if (trade === 0 && !ai.generalPolicy.can('상인무시')) { + return null; + } + + const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000; + const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000; + const deathRate = death / kill; + + const absGold = ai.general.gold; + const absRice = ai.general.rice; + const relGold = absGold; + const relRice = absRice * deathRate; + + const baseDevelCost = ai.commandEnv.develCost * 12; + if (absGold + absRice < baseDevelCost * 2) { + return null; + } + + const crewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId); + const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0); + const fullLeadership = ai.general.stats.leadership; + const crewAmount = fullLeadership * 100; + const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0; + const riceCost = crewAmount / 100; + + if ((relGold + relRice) * 1.5 <= goldCost + riceCost) { + return null; + } + + if (ai.general.npcState < 2 && relGold >= goldCost * 3 && relRice >= riceCost * 3) { + return null; + } + + let tryBuying = false; + if (ai.generalPolicy.can('상인무시')) { + if (relRice * 1.5 < relGold && relRice < riceCost * 2) { + tryBuying = true; + } else if (relRice * 2 < relGold) { + tryBuying = true; + } + } else if (relRice * 2 < relGold && relRice < riceCost * 3) { + tryBuying = true; + } + + if (tryBuying) { + const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount); + if (amount >= ai.nationPolicy.minimumResourceActionAmount) { + return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매'); + } + } + + let trySelling = false; + if (ai.generalPolicy.can('상인무시')) { + if (relGold * 1.5 < relRice && relGold < goldCost * 2) { + trySelling = true; + } else if (relGold * 2 < relRice) { + trySelling = true; + } + } else if (relGold * 2 < relRice && relGold < goldCost * 3) { + trySelling = true; + } + + if (trySelling) { + const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount); + if (amount >= ai.nationPolicy.minimumResourceActionAmount) { + return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매'); + } + } + + return null; +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/helpers.ts b/app/game-engine/src/turn/ai/generalAi/general/helpers.ts new file mode 100644 index 0000000..24b6808 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/helpers.ts @@ -0,0 +1,26 @@ +import type { City } from '@sammo-ts/logic'; + +import type { GeneralAI } from '../core.js'; +import { asRecord, readMetaNumber } from '../../aiUtils.js'; + +export const ACTION_REST = '휴식'; + +export const t무장 = 1; +export const t지장 = 2; +export const t통솔장 = 4; + +export const resolveCityTrust = (city: City): number => readMetaNumber(asRecord(city.meta), 'trust', 0); + +export const pickWeightedCandidate = ( + ai: GeneralAI, + list: Array<[ReturnType, number]> +) => { + const items = list.filter(([item]) => Boolean(item)) as Array< + [ReturnType, number] + >; + if (items.length === 0) { + return null; + } + const picked = ai.rng.choiceUsingWeightPair(items); + return picked ?? null; +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/index.ts b/app/game-engine/src/turn/ai/generalAi/general/index.ts new file mode 100644 index 0000000..f5aee57 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/index.ts @@ -0,0 +1,57 @@ +import type { GeneralAI } from '../core.js'; +import { do일반내정, do긴급내정, do전쟁내정 } from './devActions.js'; +import { do금쌀구매 } from './economyActions.js'; +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'; + +export { + do일반내정, + do긴급내정, + do전쟁내정, + do금쌀구매, + do징병, + do전투준비, + do소집해제, + do출병, + do후방워프, + do전방워프, + do내정워프, + do귀환, + do집합, + doNPC헌납, + doNPC사망대비, + do국가선택, + do중립, + do거병, + do건국, + do방랑군이동, +}; + +export const generalActionHandlers: Record< + string, + (ai: GeneralAI) => ReturnType | null +> = { + NPC사망대비: doNPC사망대비, + 귀환: do귀환, + 금쌀구매: do금쌀구매, + 출병: do출병, + 긴급내정: do긴급내정, + 전투준비: do전투준비, + 전방워프: do전방워프, + NPC헌납: doNPC헌납, + 징병: do징병, + 후방워프: do후방워프, + 전쟁내정: do전쟁내정, + 소집해제: do소집해제, + 일반내정: do일반내정, + 내정워프: do내정워프, + 국가선택: do국가선택, + 중립: do중립, + 집합: do집합, + 거병: do거병, + 건국: do건국, + 방랑군이동: do방랑군이동, +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts b/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts new file mode 100644 index 0000000..65f1230 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts @@ -0,0 +1,96 @@ +import type { GeneralAI } from '../core.js'; +import { asRecord, readMetaNumber } from '../../aiUtils.js'; +import { t통솔장 } from './helpers.js'; + +export const doNPC헌납 = (ai: GeneralAI) => { + const nation = ai.nation; + if (!nation) { + return null; + } + const resourceMap: Array<['rice' | 'gold', number, number, number]> = [ + ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], + ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], + ]; + const args: Array<[Record, number]> = []; + + for (const [resKey, reqNation, reqNpcWar, reqNpcDevel] of resourceMap) { + const genRes = ai.general[resKey]; + let reqRes = reqNpcDevel; + + if (ai.genType & t통솔장) { + reqRes = reqNpcWar; + } else { + if (genRes >= reqNpcWar && reqNpcWar > reqNpcDevel + 1000) { + const amount = genRes - reqNpcDevel; + args.push([{ isGold: resKey === 'gold', amount }, amount]); + continue; + } + if (genRes >= reqNpcDevel * 5 && genRes >= 5000) { + const amount = genRes - reqNpcDevel; + args.push([{ isGold: resKey === 'gold', amount }, amount]); + continue; + } + } + + if (nation[resKey] >= reqNation) { + continue; + } + if ( + resKey === 'rice' && + nation[resKey] <= ai.aiConst.minNationalRice / 2 && + genRes >= ai.aiConst.minNationalRice / 2 + ) { + const amount = genRes < ai.aiConst.minNationalRice ? genRes : genRes / 2; + args.push([{ isGold: false, amount }, amount]); + } + if (genRes < reqRes * 1.5) { + continue; + } + if (reqRes > 0 && !ai.rng.nextBool(genRes / reqRes - 0.5)) { + continue; + } + const amount = genRes - reqRes; + if (amount < ai.nationPolicy.minimumResourceActionAmount) { + continue; + } + args.push([{ isGold: resKey === 'gold', amount }, amount]); + } + + if (args.length === 0) { + return null; + } + + return ai.buildGeneralCandidate('che_헌납', ai.rng.choiceUsingWeightPair(args), 'NPC헌납'); +}; + +export const doNPC사망대비 = (ai: GeneralAI) => { + const killturn = readMetaNumber(asRecord(ai.general.meta), 'killturn', 999); + if (killturn > 5) { + return null; + } + + if (ai.general.nationId === 0) { + const search = ai.buildGeneralCandidate('che_인재탐색', {}, 'NPC사망대비'); + if (search && !ai.rng.nextBool()) { + return search; + } + return ai.buildGeneralCandidate('che_견문', {}, 'NPC사망대비'); + } + + if (ai.general.gold + ai.general.rice === 0) { + return ai.buildGeneralCandidate('che_물자조달', {}, 'NPC사망대비'); + } + + if (ai.general.gold >= ai.general.rice) { + return ai.buildGeneralCandidate( + 'che_헌납', + { isGold: true, amount: ai.aiConst.maxResourceActionAmount }, + 'NPC사망대비' + ); + } + return ai.buildGeneralCandidate( + 'che_헌납', + { isGold: false, amount: ai.aiConst.maxResourceActionAmount }, + 'NPC사망대비' + ); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts b/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts new file mode 100644 index 0000000..d4a3adc --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts @@ -0,0 +1,179 @@ +import { searchDistance } from '@sammo-ts/logic/world/distance.js'; + +import type { GeneralAI } from '../core.js'; +import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js'; +import { ACTION_REST } from './helpers.js'; + +export const do국가선택 = (ai: GeneralAI) => { + if (!ai.worldRef) { + return null; + } + if (ai.general.npcState === 9) { + const ruler = ai.worldRef + .listGenerals() + .find((general) => general.officerLevel === 12 && general.npcState === 9); + if (ruler) { + return ai.buildGeneralCandidate('che_임관', { destNationId: ruler.nationId }, '국가선택'); + } + } + + if (ai.rng.nextBool(0.3)) { + return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택'); + } + + if (ai.rng.nextBool(0.2) && ai.map) { + const neighbors = ai.map.cities.find((c) => c.id === ai.general.cityId)?.connections ?? []; + if (neighbors.length === 0) { + return null; + } + return ai.buildGeneralCandidate('che_이동', { destCityId: ai.rng.choice(neighbors) }, '국가선택'); + } + + return null; +}; + +export const do중립 = (ai: GeneralAI) => { + const nation = ai.nation; + if (!nation || ai.general.nationId === 0) { + const search = ai.buildGeneralCandidate('che_인재탐색', {}, '중립'); + if (search && !ai.rng.nextBool(0.8)) { + return search; + } + return ai.buildGeneralCandidate('che_견문', {}, '중립'); + } + + let candidates = ['che_물자조달', 'che_인재탐색']; + if (nation.gold < ai.nationPolicy.reqNationGold || nation.rice < ai.nationPolicy.reqNationRice) { + candidates = ['che_물자조달']; + } + + for (const key of candidates) { + const cmd = ai.buildGeneralCandidate(key, {}, '중립'); + if (cmd) { + return cmd; + } + } + return ai.buildGeneralCandidate(ACTION_REST, {}, '중립'); +}; + +export const do거병 = (ai: GeneralAI) => { + if (readMetaNumber(asRecord(ai.general.meta), 'makelimit', 0)) { + return null; + } + if (ai.general.npcState > 2) { + return null; + } + if (!ai.generalPolicy.can('건국')) { + return null; + } + const city = ai.city; + if (!city || !ai.map || !ai.worldRef) { + return null; + } + if ((city.level < 5 || 6 < city.level) && ai.rng.nextBool(0.5)) { + return null; + } + + const occupied = new Set( + ai.worldRef + .listCities() + .filter((c) => c.nationId !== 0) + .map((c) => c.id) + ); + for (const general of ai.worldRef.listGenerals()) { + if (general.officerLevel === 12 && general.nationId === 0) { + occupied.add(general.cityId); + } + } + + let availableNearCity = false; + const nearby = searchDistance(ai.map, ai.general.cityId, 3); + for (const [targetCityId, dist] of Object.entries(nearby)) { + const cityId = Number(targetCityId); + if (!Number.isFinite(cityId)) { + continue; + } + if (occupied.has(cityId)) { + continue; + } + const target = ai.worldRef.getCityById(cityId); + if (!target || target.level < 5 || target.level > 6) { + continue; + } + if (dist === 3 && ai.rng.nextBool()) { + continue; + } + availableNearCity = true; + break; + } + if (!availableNearCity) { + return null; + } + + 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; + if (prop >= ratio) { + return null; + } + + const relYear = Math.max(0, ai.world.currentYear - ai.startYear); + const more = valueFit(3 - relYear, 1, 3); + if (!ai.rng.nextBool(0.0075 * more)) { + return null; + } + + return ai.buildGeneralCandidate('che_거병', {}, '거병'); +}; + +export const do건국 = (ai: GeneralAI) => { + const mapName = ai.scenarioConfig.environment.mapName ?? 'sammo'; + const prefix = mapName.endsWith('_') ? mapName : `${mapName}_`; + const nationType = + 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; + + return ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국'); +}; + +export const do방랑군이동 = (ai: GeneralAI) => { + const city = ai.city; + if (!city || !ai.map || !ai.worldRef) { + return null; + } + const occupied = new Set( + ai.worldRef + .listCities() + .filter((c) => c.nationId !== 0) + .map((c) => c.id) + ); + for (const general of ai.worldRef.listGenerals()) { + if (general.officerLevel === 12 && general.nationId === 0) { + occupied.add(general.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)]); + } + if (candidates.length === 0) { + return null; + } + const destCityId = ai.rng.choiceUsingWeightPair(candidates); + if (destCityId === city.id) { + return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동'); + } + return ai.buildGeneralCandidate('che_이동', { destCityId }, '방랑군이동'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts new file mode 100644 index 0000000..2650d07 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts @@ -0,0 +1,87 @@ +import { getTechCost, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js'; + +import type { GeneralAI } from '../core.js'; +import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js'; +import { t통솔장 } from './helpers.js'; + +export const do징병 = (ai: GeneralAI) => { + const city = ai.city; + const nation = ai.nation; + if (!city || !nation || !ai.unitSet || !ai.map) { + return null; + } + if ([0, 1].includes(ai.dipState) && ai.general.npcState < 2) { + return null; + } + if (!(ai.genType & t통솔장)) { + return null; + } + if (ai.general.crew >= ai.nationPolicy.minWarCrew) { + return null; + } + + if (!ai.generalPolicy.can('한계징병')) { + const remainPop = + city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100; + if (remainPop <= 0) { + return null; + } + const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation; + if ( + city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio && + ai.rng.nextBool(remainPop / Math.max(1, maxPop)) + ) { + return null; + } + } + + const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0); + const crewAmountBase = ai.general.stats.leadership * 100; + const armType = + readMetaNumber(asRecord(ai.general.meta), 'armType', 0) || + (ai.general.stats.strength >= ai.general.stats.intelligence * 0.9 ? 1 : 4); + + const candidates = (ai.unitSet?.crewTypes ?? []) + .filter((crew) => crew.armType === armType) + .filter((crew) => + isCrewTypeAvailable(ai.unitSet!, crew.id, { + general: ai.general, + nation, + map: ai.map!, + cities: ai.worldRef?.listCities() ?? [], + currentYear: ai.world.currentYear, + startYear: ai.startYear, + }) + ); + if (candidates.length === 0) { + return null; + } + const picked = ai.rng.choiceUsingWeightPair(candidates.map((crew) => [crew, Math.max(1, crew.cost)])); + const crewTypeId = picked.id; + + let crewAmount = crewAmountBase; + const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100; + const riceCost = crewAmount / 100; + + if (ai.general.gold <= 0 || ai.general.rice <= 0) { + return null; + } + + if (ai.generalPolicy.can('모병') && ai.general.gold >= 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) { + crewAmount *= 0.5; + crewAmount = roundTo(crewAmount - 49, -2); + } + + if (!ai.generalPolicy.can('한계징병') && ai.general.rice * 1.1 <= riceCost) { + return null; + } + + return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/warActions.ts b/app/game-engine/src/turn/ai/generalAi/general/warActions.ts new file mode 100644 index 0000000..ce66d07 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/warActions.ts @@ -0,0 +1,81 @@ +import type { GeneralAI } from '../core.js'; +import { valueFit } from '../../aiUtils.js'; +import { pickWeightedCandidate } from './helpers.js'; + +export const do전투준비 = (ai: GeneralAI) => { + if ([0, 1].includes(ai.dipState)) { + return null; + } + const cmdList: Array<[ReturnType, number]> = []; + if (ai.general.train < ai.nationPolicy.properWarTrainAtmos) { + cmdList.push([ + ai.buildGeneralCandidate('che_훈련', {}, '전투준비'), + ai.commandEnv.maxTrainByCommand / valueFit(ai.general.train, 1), + ]); + } + if (ai.general.atmos < ai.nationPolicy.properWarTrainAtmos) { + cmdList.push([ + ai.buildGeneralCandidate('che_사기진작', {}, '전투준비'), + ai.commandEnv.maxAtmosByCommand / valueFit(ai.general.atmos, 1), + ]); + } + return pickWeightedCandidate(ai, cmdList); +}; + +export const do소집해제 = (ai: GeneralAI) => { + if (ai.attackable) { + return null; + } + if (ai.dipState !== 0) { + return null; + } + if (ai.general.crew === 0) { + return null; + } + if (ai.rng.nextBool(0.75)) { + return null; + } + return ai.buildGeneralCandidate('che_소집해제', {}, '소집해제'); +}; + +export const do출병 = (ai: GeneralAI) => { + const city = ai.city; + const nation = ai.nation; + if (!city || !nation || !ai.map || !ai.worldRef) { + return null; + } + if (!ai.attackable || ai.dipState !== 4) { + return null; + } + if (nation.rice < ai.aiConst.baseRice && ai.general.npcState >= 2 && ai.rng.nextBool(0.7)) { + return null; + } + if (ai.general.train < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) { + return null; + } + if (ai.general.atmos < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) { + return null; + } + if (ai.general.crew < Math.min((ai.general.stats.leadership - 2) * 100, ai.nationPolicy.minWarCrew)) { + return null; + } + if (city.frontState <= 1) { + return null; + } + + const attackableNations = Object.entries(ai.warTargetNation) + .filter(([, state]) => state !== 1) + .map(([id]) => Number(id)); + if (attackableNations.length === 0) { + attackableNations.push(0); + } + const neighbors = ai.map.cities.find((c) => c.id === city.id)?.connections ?? []; + const attackableCities = neighbors.filter((cityId) => { + const destCity = ai.worldRef?.getCityById(cityId); + return destCity ? attackableNations.includes(destCity.nationId) : false; + }); + if (attackableCities.length === 0) { + return null; + } + return ai.buildGeneralCandidate('che_출병', { destCityId: ai.rng.choice(attackableCities) }, '출병'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/general/warpActions.ts b/app/game-engine/src/turn/ai/generalAi/general/warpActions.ts new file mode 100644 index 0000000..cf04fda --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/general/warpActions.ts @@ -0,0 +1,198 @@ +import type { GeneralAI } from '../core.js'; +import { t통솔장 } from './helpers.js'; + +export const do후방워프 = (ai: GeneralAI) => { + const city = ai.city; + if (!city || !ai.nation || !ai.map) { + return null; + } + if ([0, 1].includes(ai.dipState)) { + return null; + } + if (!(ai.genType & t통솔장)) { + return null; + } + if (ai.general.crew >= ai.nationPolicy.minWarCrew) { + return null; + } + + let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; + if (!ai.generalPolicy.can('한계징병')) { + minRecruitPop = Math.max( + minRecruitPop, + ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation + ); + } + + if (ai.generalPolicy.can('한계징병')) { + if (city.population >= minRecruitPop) { + return null; + } + } else if ( + city.population / city.populationMax >= ai.nationPolicy.safeRecruitCityPopulationRatio && + city.population >= ai.nationPolicy.minNpcRecruitCityPopulation && + city.population >= minRecruitPop + ) { + return null; + } + + ai.categorizeNationCities(); + + const recruitable: Record = {}; + for (const candidate of Object.values(ai.backupCities)) { + if (candidate.id === city.id) { + continue; + } + if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) { + continue; + } + if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) { + continue; + } + if (candidate.population < minRecruitPop) { + continue; + } + recruitable[candidate.id] = candidate.population / candidate.populationMax; + } + if (Object.keys(recruitable).length === 0) { + for (const candidate of Object.values(ai.supplyCities)) { + if (candidate.id === city.id) { + continue; + } + if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) { + continue; + } + if (candidate.population <= minRecruitPop) { + continue; + } + if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) { + continue; + } + recruitable[candidate.id] = + candidate.frontState > 0 + ? candidate.population / candidate.populationMax / 2 + : candidate.population / candidate.populationMax; + } + } + if (Object.keys(recruitable).length === 0) { + return null; + } + + return ai.buildGeneralCandidate( + 'che_NPC능동', + { optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) }, + '후방워프' + ); +}; + +export const do전방워프 = (ai: GeneralAI) => { + const city = ai.city; + if (!city || !ai.nation || !ai.map) { + return null; + } + if (!ai.attackable || [0, 1].includes(ai.dipState)) { + return null; + } + if (!(ai.genType & t통솔장)) { + return null; + } + if (ai.general.crew < ai.nationPolicy.minWarCrew) { + return null; + } + if (city.frontState > 0) { + return null; + } + + ai.categorizeNationCities(); + const candidateCities: Record = {}; + for (const frontCity of Object.values(ai.frontCities)) { + if (frontCity.supplyState <= 0) { + continue; + } + candidateCities[frontCity.id] = frontCity.important; + } + if (Object.keys(candidateCities).length === 0) { + return null; + } + + return ai.buildGeneralCandidate( + 'che_NPC능동', + { optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) }, + '전방워프' + ); +}; + +export const do내정워프 = (ai: GeneralAI) => { + const city = ai.city; + if (!city || !ai.nation || !ai.map) { + return null; + } + if (ai.genType & t통솔장 && [2, 3, 4].includes(ai.dipState)) { + return null; + } + if (ai.rng.nextBool(0.6)) { + return null; + } + + const develRate = ai.calcCityDevelRate(city); + let warpProp = 1; + let availableTypeCnt = 0; + for (const [key, [value, type]] of Object.entries(develRate)) { + if (!(ai.genType & type)) { + continue; + } + warpProp *= value; + availableTypeCnt += 1; + void key; + } + if (availableTypeCnt === 0) { + return null; + } + if (!ai.rng.nextBool(warpProp)) { + return null; + } + + ai.categorizeNationCities(); + ai.categorizeNationGeneral(); + const candidateCities: Record = {}; + for (const candidate of Object.values(ai.supplyCities)) { + if (candidate.id === city.id) { + continue; + } + let realDevelRate = 0.0001; + for (const [_, [value, type]] of Object.entries(ai.calcCityDevelRate(candidate))) { + if (!(ai.genType & type)) { + continue; + } + realDevelRate += value; + } + realDevelRate /= availableTypeCnt; + if (realDevelRate >= 0.95) { + continue; + } + candidateCities[candidate.id] = + 1 / (realDevelRate * Math.sqrt((candidate.generals ? Object.keys(candidate.generals).length : 0) + 1)); + } + if (Object.keys(candidateCities).length === 0) { + return null; + } + + return ai.buildGeneralCandidate( + 'che_NPC능동', + { optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) }, + '내정워프' + ); +}; + +export const do귀환 = (ai: GeneralAI) => { + const city = ai.city; + if (!city) { + return null; + } + if (city.nationId === ai.general.nationId && city.supplyState > 0) { + return null; + } + return ai.buildGeneralCandidate('che_귀환', {}, '귀환'); +}; + +export const do집합 = (ai: GeneralAI) => ai.buildGeneralCandidate('che_집합', {}, '집합'); diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts new file mode 100644 index 0000000..74f8031 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts @@ -0,0 +1,162 @@ +import type { GeneralAI } from '../../core.js'; +import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio, selectRecruitableCity } from '../helpers.js'; + +export const doNPC후방발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.frontCities).length === 0) { + return null; + } + if (ai.dipState !== 4) { + return null; + } + + const candidates = Object.values(ai.npcWarGenerals).filter((general) => { + if (general.id === ai.general.id) { + return false; + } + if (!ai.supplyCities[general.cityId]) { + return false; + } + if (general.troopId) { + return false; + } + const city = ai.supplyCities[general.cityId]; + if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { + return false; + } + if (general.crew >= ai.nationPolicy.minWarCrew) { + return false; + } + return true; + }); + + if (candidates.length === 0) { + return null; + } + + const picked = ai.rng.choice(candidates); + const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; + const destCityCandidates = selectRecruitableCity(ai, minPop); + if (Object.keys(destCityCandidates).length === 0) { + return null; + } + + const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + return buildAssignmentCandidate(ai, picked.id, destCityId, 'NPC후방발령'); +}; + +export const doNPC구출발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + const lostCandidates = Object.values(ai.lostGenerals).filter( + (general) => general.npcState >= 2 && general.npcState !== 5 + ); + if (lostCandidates.length === 0) { + return null; + } + const destCityId = pickRandomCityId(ai, ai.supplyCities); + if (destCityId === null) { + return null; + } + const destGeneral = ai.rng.choice(lostCandidates); + return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령'); +}; + +export const doNPC전방발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.frontCities).length === 0) { + return null; + } + if ([0, 1].includes(ai.dipState)) { + return null; + } + + const candidates = Object.values(ai.npcWarGenerals).filter((general) => { + if (ai.frontCities[general.cityId]) { + return false; + } + if (!ai.nationCities[general.cityId]) { + return false; + } + if (general.crew < ai.nationPolicy.minWarCrew) { + return false; + } + if (general.troopId) { + return false; + } + if (Math.max(general.train, general.atmos) < ai.nationPolicy.properWarTrainAtmos) { + return false; + } + return true; + }); + + if (candidates.length === 0) { + return null; + } + + const cityCandidates = pickFrontCityWeight(ai); + const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + const destGeneral = ai.rng.choice(candidates); + return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC전방발령'); +}; + +export const doNPC내정발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.supplyCities).length <= 1) { + return null; + } + + const supplyCities = Object.values(ai.supplyCities); + const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length; + if (avgDev >= 0.99) { + return null; + } + + const npcGenerals = [0, 1].includes(ai.dipState) + ? [...Object.values(ai.npcWarGenerals), ...Object.values(ai.npcCivilGenerals)] + : Object.values(ai.npcCivilGenerals); + + const generalCandidates = npcGenerals.filter((general) => { + const city = ai.supplyCities[general.cityId]; + if (!city) { + return false; + } + return city.dev >= 0.95; + }); + + if (generalCandidates.length === 0) { + return null; + } + + const cityCandidates: Record = {}; + for (const city of supplyCities) { + const dev = Math.min(city.dev, 0.999); + const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1); + cityCandidates[city.id] = score; + } + + const destGeneral = ai.rng.choice(generalCandidates); + const srcCity = ai.supplyCities[destGeneral.cityId]; + const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) { + return null; + } + + return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC내정발령'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts new file mode 100644 index 0000000..90b00ad --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts @@ -0,0 +1,171 @@ +import type { GeneralAI } from '../../core.js'; +import { joinYearMonth } from '../../../aiUtils.js'; +import { + buildAssignmentCandidate, + pickRandomCityId, + pickWeightedCandidate, + resolveCityPopRatio, + resolveLastAssignment, +} from '../helpers.js'; + +export const do부대전방발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.map) { + return null; + } + if (!ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.frontCities).length === 0) { + return null; + } + + ai.calcWarRoute(); + const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); + + const troopCandidates: Array<[ReturnType, number]> = []; + + for (const leader of Object.values(ai.troopLeaders)) { + if (!ai.nationPolicy.combatForce[leader.id]) { + continue; + } + if (ai.frontCities[leader.cityId]) { + continue; + } + if (resolveLastAssignment(leader, yearMonth)) { + continue; + } + + const force = ai.nationPolicy.combatForce[leader.id]; + let [fromCityId, toCityId] = force; + + let targetCityId: number | null = null; + if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) { + targetCityId = pickRandomCityId(ai, ai.frontCities); + } else { + if (!ai.supplyCities[fromCityId]) { + toCityId = fromCityId; + fromCityId = ai.nation.capitalCityId ?? fromCityId; + } + targetCityId = fromCityId; + while (targetCityId !== null && !ai.frontCities[targetCityId]) { + const current = targetCityId; + const distance = ai.warRoute[current]?.[toCityId]; + if (distance === undefined) { + targetCityId = pickRandomCityId(ai, ai.frontCities); + break; + } + const connections: number[] = ai.map.cities.find((city) => city.id === current)?.connections ?? []; + const nextCandidates: number[] = connections.filter((nextCityId: number) => { + const nextDistance = ai.warRoute?.[nextCityId]?.[toCityId]; + return nextDistance !== undefined && nextDistance <= distance; + }); + if (nextCandidates.length === 0) { + targetCityId = pickRandomCityId(ai, ai.frontCities); + break; + } + targetCityId = ai.rng.choice(nextCandidates); + } + } + + if (targetCityId === null) { + continue; + } + troopCandidates.push([buildAssignmentCandidate(ai, leader.id, targetCityId, '부대전방발령'), 1]); + } + + return pickWeightedCandidate(ai, troopCandidates); +}; + +export const do부대후방발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.frontCities).length === 0) { + return null; + } + if (Object.keys(ai.supplyCities).length <= 1) { + return null; + } + + const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); + const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => { + if (!ai.nationPolicy.supportForce.includes(leader.id)) { + return false; + } + if (resolveLastAssignment(leader, yearMonth)) { + return false; + } + const city = ai.supplyCities[leader.cityId]; + if (!city) { + return true; + } + if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { + return false; + } + return true; + }); + + if (troopCandidates.length === 0) { + return null; + } + + const cityCandidates: Record = {}; + for (const city of Object.values(ai.backupCities)) { + const ratio = resolveCityPopRatio(city); + if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) { + cityCandidates[city.id] = ratio; + } + } + if (Object.keys(cityCandidates).length === 0) { + for (const city of Object.values(ai.supplyCities)) { + const ratio = resolveCityPopRatio(city); + if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) { + cityCandidates[city.id] = ratio; + } + } + } + if (Object.keys(cityCandidates).length === 0) { + return null; + } + + const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + const leader = ai.rng.choice(troopCandidates); + return buildAssignmentCandidate(ai, leader.id, destCityId, '부대후방발령'); +}; + +export const do부대구출발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.frontCities).length === 0) { + return null; + } + const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); + + const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => { + if (ai.nationPolicy.supportForce.includes(leader.id)) { + return false; + } + if (ai.nationPolicy.combatForce[leader.id]) { + return false; + } + if (resolveLastAssignment(leader, yearMonth)) { + return false; + } + return !ai.supplyCities[leader.cityId]; + }); + + if (troopCandidates.length === 0) { + return null; + } + + const destCityId = pickRandomCityId(ai, ai.frontCities); + if (destCityId === null) { + return null; + } + const leader = ai.rng.choice(troopCandidates); + return buildAssignmentCandidate(ai, leader.id, destCityId, '부대구출발령'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts new file mode 100644 index 0000000..9cf2c78 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts @@ -0,0 +1,229 @@ +import type { GeneralAI } from '../../core.js'; +import { + buildAssignmentCandidate, + pickFrontCityWeight, + pickRandomCityId, + resolveCityPopRatio, + selectRecruitableCity, +} from '../helpers.js'; + +export const do부대유저장후방발령 = (ai: GeneralAI) => { + if (Object.keys(ai.frontCities).length === 0) { + return null; + } + if (ai.dipState !== 4) { + return null; + } + + const candidates = Object.values(ai.userWarGenerals).filter((general) => { + if (general.id === ai.general.id) { + return false; + } + if (!ai.frontCities[general.cityId]) { + return false; + } + if (!ai.nationCities[general.cityId]) { + return false; + } + const troopLeaderId = general.troopId; + if (!troopLeaderId || !ai.troopLeaders[troopLeaderId]) { + return false; + } + if (troopLeaderId === general.id) { + return false; + } + const troopLeader = ai.troopLeaders[troopLeaderId]; + if (troopLeader.cityId !== general.cityId) { + return false; + } + if (!ai.supplyCities[troopLeader.cityId]) { + return false; + } + const city = ai.nationCities[general.cityId]; + if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { + return false; + } + if (general.crew >= ai.nationPolicy.minWarCrew) { + return false; + } + const reserved = ai.getReservedTurn(general.id); + if (reserved.action !== 'che_징병') { + return false; + } + return true; + }); + + if (candidates.length === 0) { + return null; + } + + const destCityCandidates = selectRecruitableCity(ai, ai.nationPolicy.minNpcRecruitCityPopulation); + if (Object.keys(destCityCandidates).length === 0) { + return null; + } + + const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + const destGeneral = ai.rng.choice(candidates); + return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령'); +}; + +export const do유저장후방발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (ai.dipState !== 4) { + return null; + } + if (Object.keys(ai.supplyCities).length <= 1) { + return null; + } + + const candidates = Object.values(ai.userWarGenerals).filter((general) => { + if (general.id === ai.general.id) { + return false; + } + if (!ai.supplyCities[general.cityId]) { + return false; + } + if (general.troopId) { + return false; + } + const city = ai.supplyCities[general.cityId]; + if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { + return false; + } + if (general.crew >= ai.nationPolicy.minWarCrew) { + return false; + } + return true; + }); + + if (candidates.length === 0) { + return null; + } + + const picked = ai.rng.choice(candidates); + const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; + const destCityCandidates = selectRecruitableCity(ai, minPop); + if (Object.keys(destCityCandidates).length === 0) { + return null; + } + + const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + return buildAssignmentCandidate(ai, picked.id, destCityId, '유저장후방발령'); +}; + +export const do유저장구출발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + const lostCandidates = Object.values(ai.lostGenerals).filter((general) => general.npcState < 2); + if (lostCandidates.length === 0) { + return null; + } + const destCityId = pickRandomCityId(ai, ai.frontCities) ?? pickRandomCityId(ai, ai.supplyCities); + if (destCityId === null) { + return null; + } + const destGeneral = ai.rng.choice(lostCandidates); + return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장구출발령'); +}; + +export const do유저장전방발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.frontCities).length === 0) { + return null; + } + if ([0, 1].includes(ai.dipState)) { + return null; + } + + const candidates = Object.values(ai.userWarGenerals).filter((general) => { + if (general.id === ai.general.id) { + return false; + } + if (ai.frontCities[general.cityId]) { + return false; + } + if (!ai.nationCities[general.cityId]) { + return false; + } + if (general.crew < ai.nationPolicy.minWarCrew) { + return false; + } + return true; + }); + + if (candidates.length === 0) { + return null; + } + + const cityCandidates = pickFrontCityWeight(ai); + const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + const destGeneral = ai.rng.choice(candidates); + return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령'); +}; + +export const do유저장내정발령 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.supplyCities).length <= 1) { + return null; + } + + const supplyCities = Object.values(ai.supplyCities); + const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length; + if (avgDev >= 0.99) { + return null; + } + + const userGenerals = [0, 1].includes(ai.dipState) + ? [...Object.values(ai.userWarGenerals), ...Object.values(ai.userCivilGenerals)] + : Object.values(ai.userCivilGenerals); + + const generalCandidates = userGenerals.filter((general) => { + if (general.troopId) { + return false; + } + const city = ai.supplyCities[general.cityId]; + if (!city) { + return false; + } + return city.dev >= 0.95; + }); + + if (generalCandidates.length === 0) { + return null; + } + + const cityCandidates: Record = {}; + for (const city of supplyCities) { + const dev = Math.min(city.dev, 0.999); + const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1); + cityCandidates[city.id] = score; + } + + const destGeneral = ai.rng.choice(generalCandidates); + const srcCity = ai.supplyCities[destGeneral.cityId]; + const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); + if (!Number.isFinite(destCityId)) { + return null; + } + if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) { + return null; + } + + return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장내정발령'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/capital.ts b/app/game-engine/src/turn/ai/generalAi/nation/capital.ts new file mode 100644 index 0000000..81154de --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/capital.ts @@ -0,0 +1,66 @@ +import type { GeneralAI } from '../core.js'; +import { calcCityDevRatio } from '../../aiUtils.js'; +import { searchAllDistanceByCityList } from '../../distance.js'; + +export const do천도 = (ai: GeneralAI) => { + if (!ai.nation || !ai.nation.capitalCityId) { + return null; + } + if (!ai.map) { + return null; + } + const nationCities = Object.values(ai.nationCities); + if (nationCities.length <= 1) { + return null; + } + + const cityIds = nationCities.map((city) => city.id); + const distanceList = searchAllDistanceByCityList(ai.map, cityIds); + const capitalId = ai.nation.capitalCityId; + if (!distanceList[capitalId]) { + return null; + } + + let maxDistance = 0; + for (const distances of Object.values(distanceList)) { + const sum = Object.values(distances).reduce((acc, value) => acc + value, 0); + maxDistance = Math.max(maxDistance, sum); + } + + const cityScores: Record = {}; + for (const city of nationCities) { + const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0); + if (sumDistance <= 0) { + continue; + } + const dev = calcCityDevRatio(city); + cityScores[city.id] = city.population * (maxDistance / sumDistance) * Math.sqrt(dev); + } + + 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) { + if (Number(sorted[idx][0]) === capitalId) { + return null; + } + } + + const finalCityId = Number(sorted[0]?.[0]); + if (!Number.isFinite(finalCityId)) { + return null; + } + const dist = distanceList[capitalId]?.[finalCityId]; + if (dist === undefined) { + return null; + } + let targetCityId = finalCityId; + if (dist > 1) { + const connections = ai.map.cities.find((city) => city.id === capitalId)?.connections ?? []; + const candidates = connections.filter((stopId) => distanceList[stopId]?.[finalCityId] + 1 === dist); + if (candidates.length > 0) { + targetCityId = ai.rng.choice(candidates); + } + } + + return ai.buildNationCandidate('che_천도', { destCityId: targetCityId }, '천도'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts new file mode 100644 index 0000000..10cef81 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts @@ -0,0 +1,149 @@ +import type { GeneralAI } from '../core.js'; +import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js'; +import { isNeighbor } from '../../distance.js'; +import { resolveNationIncome } from './helpers.js'; + +export const do불가침제의 = (ai: GeneralAI) => { + if (!ai.nation || ai.general.officerLevel < 12) { + return null; + } + if (!ai.worldRef) { + return null; + } + const meta = asRecord(ai.nation.meta); + const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : []; + const respAssist = asRecord(meta.resp_assist); + const respAssistTry = asRecord(meta.resp_assist_try); + const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); + + const candidateList: Record = {}; + for (const entry of recvAssist) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const destNationId = Number(entry[0]); + const amount = Number(entry[1]); + if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) { + continue; + } + const respEntry = asRecord(respAssist[`n${destNationId}`]); + const respAmount = readMetaNumber(respEntry, '1', 0); + const remain = amount - respAmount; + if (remain <= 0) { + continue; + } + if (ai.warTargetNation[destNationId]) { + continue; + } + const lastTry = readMetaNumber(asRecord(respAssistTry[`n${destNationId}`]), '1', 0); + if (lastTry >= yearMonth - 8) { + continue; + } + candidateList[destNationId] = remain; + } + + if (Object.keys(candidateList).length === 0) { + return null; + } + + const income = resolveNationIncome(ai); + if (income <= 0) { + return null; + } + + const sorted = Object.entries(candidateList).sort((a, b) => b[1] - a[1]); + let destNationId: number | null = null; + let diplomatMonth = 0; + for (const [idRaw, amount] of sorted) { + if (amount * 4 < income) { + break; + } + destNationId = Number(idRaw); + diplomatMonth = (24 * amount) / income; + break; + } + + if (!destNationId) { + return null; + } + + const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth)); + return ai.buildNationCandidate( + 'che_불가침제의', + { destNationId, year: targetYear, month: targetMonth }, + '불가침제의' + ); +}; + +export const do선전포고 = (ai: GeneralAI) => { + if (!ai.nation || ai.general.officerLevel < 12) { + return null; + } + if (ai.dipState !== 0) { + return null; + } + if (ai.attackable) { + return null; + } + if (!ai.nation.capitalCityId) { + return null; + } + if (Object.keys(ai.frontCities).length > 0) { + return null; + } + if (!ai.map || !ai.worldRef) { + return null; + } + + const avgResources = Object.values({ + ...ai.npcWarGenerals, + ...ai.npcCivilGenerals, + ...ai.userWarGenerals, + ...ai.userCivilGenerals, + }); + if (avgResources.length === 0) { + return null; + } + + let avgGold = ai.nation.gold; + let avgRice = ai.nation.rice; + for (const general of avgResources) { + const scale = general.npcState < 2 ? 0.5 : 1; + avgGold += general.gold * scale; + avgRice += general.rice * scale; + } + avgGold /= avgResources.length; + avgRice /= avgResources.length; + + const trialProp = + avgGold / Math.max(ai.nationPolicy.reqNpcWarGold * 1.5, 2000) + + avgRice / Math.max(ai.nationPolicy.reqNpcWarRice * 1.5, 2000); + const devRate = ai.calcNationDevelopedRate(); + const chance = Math.pow((trialProp + (devRate.pop + devRate.all) / 2) / 4, 6); + if (!ai.rng.nextBool(chance)) { + return null; + } + + const currentNationId = ai.nation.id; + const cities = ai.worldRef.listCities(); + const neighbors = ai.worldRef.listNations().filter((nation) => { + if (nation.id <= 0 || nation.id === currentNationId) { + return false; + } + return isNeighbor(ai.map!, cities, currentNationId, nation.id, true); + }); + if (neighbors.length === 0) { + return null; + } + + const weight: Record = {}; + for (const nation of neighbors) { + weight[nation.id] = 1 / Math.sqrt(nation.power + 1); + } + + const destNationId = Number(ai.rng.choiceUsingWeight(weight)); + if (!Number.isFinite(destNationId)) { + return null; + } + return ai.buildNationCandidate('che_선전포고', { destNationId }, '선전포고'); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts b/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts new file mode 100644 index 0000000..263a846 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts @@ -0,0 +1,94 @@ +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, number]>) => { + const items = list.filter(([item]) => Boolean(item)) as Array< + [ReturnType, number] + >; + if (items.length === 0) { + return null; + } + const picked = ai.rng.choiceUsingWeightPair(items); + return picked ?? null; +}; + +export const pickRandomCityId = (ai: GeneralAI, cities: Record): number | null => { + const ids = Object.keys(cities).map((key) => Number(key)); + if (ids.length === 0) { + return null; + } + return ai.rng.choice(ids); +}; + +export const resolveCityPopRatio = (city: City): number => { + if (city.populationMax <= 0) { + return 0; + } + return city.population / city.populationMax; +}; + +export const resolveLastAssignment = (general: GeneralAI['general'], yearMonth: number): boolean => { + const last = readMetaNumber(asRecord(general.meta), 'last발령', 0); + return last >= yearMonth; +}; + +export const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record => { + const candidates: Record = {}; + for (const city of Object.values(ai.backupCities)) { + if (city.population < minPop) { + continue; + } + const ratio = resolveCityPopRatio(city); + candidates[city.id] = ratio; + } + if (Object.keys(candidates).length > 0) { + return candidates; + } + for (const city of Object.values(ai.supplyCities)) { + if (city.population < minPop) { + continue; + } + const ratio = resolveCityPopRatio(city); + candidates[city.id] = ratio; + } + return candidates; +}; + +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 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 resolveNationIncome = (ai: GeneralAI): number => { + const cities = Object.values(ai.supplyCities); + if (cities.length === 0) { + return 0; + } + return cities.reduce((sum, city) => sum + city.population / 100 + city.agriculture / 100 + city.commerce / 100, 0); +}; + +export const pickFrontCityWeight = (ai: GeneralAI): Record => { + const candidates: Record = {}; + for (const city of Object.values(ai.frontCities)) { + candidates[city.id] = city.important; + } + return candidates; +}; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/index.ts b/app/game-engine/src/turn/ai/generalAi/nation/index.ts new file mode 100644 index 0000000..2945079 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/index.ts @@ -0,0 +1,62 @@ +import type { GeneralAI } from '../core.js'; +import { do부대전방발령, do부대후방발령, do부대구출발령 } from './assignments/troopAssignments.js'; +import { + do부대유저장후방발령, + do유저장후방발령, + do유저장구출발령, + do유저장전방발령, + do유저장내정발령, +} from './assignments/userAssignments.js'; +import { doNPC후방발령, doNPC구출발령, doNPC전방발령, doNPC내정발령 } from './assignments/npcAssignments.js'; +import { do유저장긴급포상, do유저장포상, doNPC긴급포상, doNPC포상, doNPC몰수 } from './rewards.js'; +import { do불가침제의, do선전포고 } from './diplomacy.js'; +import { do천도 } from './capital.js'; + +export { + do부대전방발령, + do부대후방발령, + do부대구출발령, + do부대유저장후방발령, + do유저장후방발령, + do유저장구출발령, + do유저장전방발령, + do유저장내정발령, + doNPC후방발령, + doNPC구출발령, + doNPC전방발령, + doNPC내정발령, + do유저장긴급포상, + do유저장포상, + doNPC긴급포상, + doNPC포상, + doNPC몰수, + do불가침제의, + do선전포고, + do천도, +}; + +export const nationActionHandlers: Record< + string, + (ai: GeneralAI) => ReturnType | null +> = { + 불가침제의: do불가침제의, + 선전포고: do선전포고, + 천도: do천도, + 유저장긴급포상: do유저장긴급포상, + 부대전방발령: do부대전방발령, + 유저장구출발령: do유저장구출발령, + 유저장후방발령: do유저장후방발령, + 부대유저장후방발령: do부대유저장후방발령, + 유저장전방발령: do유저장전방발령, + 유저장포상: do유저장포상, + 부대구출발령: do부대구출발령, + 부대후방발령: do부대후방발령, + NPC긴급포상: doNPC긴급포상, + NPC구출발령: doNPC구출발령, + NPC후방발령: doNPC후방발령, + NPC포상: doNPC포상, + NPC전방발령: doNPC전방발령, + 유저장내정발령: do유저장내정발령, + NPC내정발령: doNPC내정발령, + NPC몰수: doNPC몰수, +}; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts new file mode 100644 index 0000000..b714664 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -0,0 +1,189 @@ +import type { GeneralAI } from '../core.js'; +import { asRecord, readMetaNumber } from '../../aiUtils.js'; +import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate, resolveAwardAmount } from './helpers.js'; + +export const do유저장긴급포상 = (ai: GeneralAI) => { + const nation = ai.nation; + if (!nation) { + return null; + } + const candidates: Array<[ReturnType, number]> = []; + const resourceMap: Array<['gold' | 'rice', 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) { + continue; + } + candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), amount]); + } + } + + return pickWeightedCandidate(ai, candidates); +}; + +export const do유저장포상 = (ai: GeneralAI) => { + const nation = ai.nation; + if (!nation) { + return null; + } + const candidates: Array<[ReturnType, number]> = []; + const resourceMap: Array<['gold' | 'rice', number]> = [ + ['gold', ai.nationPolicy.reqHumanWarRecommandGold], + ['rice', ai.nationPolicy.reqHumanWarRecommandRice], + ]; + + 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) { + continue; + } + candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), amount]); + } + } + + return pickWeightedCandidate(ai, candidates); +}; + +export const doNPC긴급포상 = (ai: GeneralAI) => { + const nation = ai.nation; + if (!nation) { + return null; + } + const candidates: Array<[ReturnType, number]> = []; + const resourceMap: Array<['gold' | 'rice', number]> = [ + ['gold', ai.nationPolicy.reqNpcWarGold / 2], + ['rice', ai.nationPolicy.reqNpcWarRice / 2], + ]; + + 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.npcWarGenerals)) { + const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); + if (killturn <= 5) { + continue; + } + const amount = resolveAwardAmount(ai, general[resKey], required); + if (!amount) { + continue; + } + candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), amount]); + } + } + + return pickWeightedCandidate(ai, candidates); +}; + +export const doNPC포상 = (ai: GeneralAI) => { + const nation = ai.nation; + if (!nation) { + return null; + } + const candidates: Array<[ReturnType, number]> = []; + const resourceMap: Array<['gold' | 'rice', number, number]> = [ + ['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], + ['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], + ]; + + for (const [resKey, warReq, devReq] 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.npcWarGenerals)) { + const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); + if (killturn <= 5) { + continue; + } + const amount = resolveAwardAmount(ai, general[resKey], warReq); + if (!amount) { + continue; + } + candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]); + } + for (const general of Object.values(ai.npcCivilGenerals)) { + const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); + 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]); + } + } + + return pickWeightedCandidate(ai, candidates); +}; + +export const doNPC몰수 = (ai: GeneralAI) => { + const nation = ai.nation; + if (!nation) { + return null; + } + const candidates: Array<[ReturnType, number]> = []; + const resourceMap: Array<['gold' | 'rice', number, number]> = [ + ['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], + ['rice', 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; + } + const amount = Math.min(general[resKey] - devReq * 1.2, ai.maxResourceActionAmount); + if (amount < ai.nationPolicy.minimumResourceActionAmount) { + continue; + } + 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, + ]); + } + } + } + + return pickWeightedCandidate(ai, candidates); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/seed.ts b/app/game-engine/src/turn/ai/generalAi/seed.ts new file mode 100644 index 0000000..5025887 --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/seed.ts @@ -0,0 +1,8 @@ +import type { TurnWorldState } from '../../types.js'; +import { asRecord } from '../aiUtils.js'; + +export const buildSeedBase = (world: TurnWorldState): string => { + const meta = asRecord(world.meta); + const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id; + return String(rawSeed); +}; diff --git a/app/game-engine/src/turn/ai/generalAi/types.ts b/app/game-engine/src/turn/ai/generalAi/types.ts new file mode 100644 index 0000000..4b9aa9c --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/types.ts @@ -0,0 +1,56 @@ +import type { + City, + GeneralActionDefinition, + MapDefinition, + Nation, + ScenarioConfig, + ScenarioMeta, + TurnCommandEnv, + UnitSetDefinition, +} from '@sammo-ts/logic'; + +import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; +import type { TurnGeneral, TurnWorldState } from '../../types.js'; +import type { AiReservedTurnProvider, AiWorldView } from '../types.js'; + +export interface GeneralAIOptions { + general: TurnGeneral; + city?: City; + nation?: Nation | null; + world: TurnWorldState; + worldRef: AiWorldView | null; + reservedTurnProvider: AiReservedTurnProvider; + scenarioConfig: ScenarioConfig; + scenarioMeta?: ScenarioMeta; + map?: MapDefinition; + unitSet?: UnitSetDefinition; + commandEnv: TurnCommandEnv; + generalDefinitions: Map; + nationDefinitions: Map; + generalFallback: GeneralActionDefinition; + nationFallback: GeneralActionDefinition; +} + +export type GeneralAiDebugState = { + generalId: number; + nationId: number | null; + cityId: number | null; + yearMonth: number; + startYear: number; + startYearMonth: number; + dipState: number; + attackable: boolean; + warTargetNation: Record; + genType: number; + lastAttackable: number; + frontCities: Array<{ id: number; frontState: number; supplyState: number }>; + supplyCities: Array<{ id: number; frontState: number; supplyState: number }>; + policy: { + minAvailableRecruitPop: number; + minNpcWarLeadership: number; + minWarCrew: number; + cureThreshold: number; + }; +}; + +export type { ReservedTurnEntry }; diff --git a/app/game-engine/src/turn/ai/generalAi/worldStateView.ts b/app/game-engine/src/turn/ai/generalAi/worldStateView.ts new file mode 100644 index 0000000..1cf40ef --- /dev/null +++ b/app/game-engine/src/turn/ai/generalAi/worldStateView.ts @@ -0,0 +1,66 @@ +import type { City, Nation } from '@sammo-ts/logic'; +import type { StateView } from '@sammo-ts/logic'; + +import type { TurnGeneral } from '../../types.js'; +import type { AiWorldView } from '../types.js'; +import type { ConstraintEnv } from './constraint.js'; + +export class WorldStateView implements StateView { + constructor( + private readonly world: AiWorldView | null, + private readonly env: ConstraintEnv, + private readonly args: Record, + private readonly overrides?: { + general?: TurnGeneral; + city?: City; + nation?: Nation | null; + } + ) {} + + has(req: Parameters[0]): boolean { + return this.get(req) !== null; + } + + get(req: Parameters[0]): unknown | null { + if (!this.world) { + return null; + } + switch (req.kind) { + case 'general': + if (this.overrides?.general && this.overrides.general.id === req.id) { + return this.overrides.general; + } + return this.world.getGeneralById(req.id); + case 'generalList': + return this.world.listGenerals(); + case 'destGeneral': + return this.world.getGeneralById(req.id); + case 'city': + if (this.overrides?.city && this.overrides.city.id === req.id) { + return this.overrides.city; + } + return this.world.getCityById(req.id); + case 'destCity': + return this.world.getCityById(req.id); + case 'nation': + if (this.overrides?.nation && this.overrides.nation.id === req.id) { + return this.overrides.nation; + } + return this.world.getNationById(req.id); + case 'nationList': + return this.world.listNations(); + case 'destNation': + return this.world.getNationById(req.id); + case 'diplomacy': + return this.world.getDiplomacyEntry(req.srcNationId, req.destNationId); + case 'diplomacyList': + return this.world.listDiplomacy(); + case 'arg': + return this.args[req.key] ?? null; + case 'env': + return this.env[req.key] ?? null; + default: + return null; + } + } +} diff --git a/app/game-engine/src/turn/ai/generalAiGeneralActions.ts b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts index 7220923..e48b181 100644 --- a/app/game-engine/src/turn/ai/generalAiGeneralActions.ts +++ b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts @@ -1,942 +1 @@ -import type { City } from '@sammo-ts/logic'; -import { findCrewTypeById, getTechCost, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js'; -import { searchDistance } from '@sammo-ts/logic/world/distance.js'; - -import type { GeneralAI } from './generalAi.js'; -import { asRecord, readMetaNumber, roundTo, valueFit } from './aiUtils.js'; - -const ACTION_REST = '휴식'; - -const t무장 = 1; -const t지장 = 2; -const t통솔장 = 4; - -const resolveCityTrust = (city: City): number => readMetaNumber(asRecord(city.meta), 'trust', 0); - -const pickWeightedCandidate = ( - ai: GeneralAI, - list: Array<[ReturnType, number]> -) => { - const items = list.filter(([item]) => Boolean(item)) as Array< - [ReturnType, number] - >; - if (items.length === 0) { - return null; - } - const picked = ai.rng.choiceUsingWeightPair(items); - return picked ?? null; -}; - -export const do일반내정 = (ai: GeneralAI) => { - const city = ai.city; - const nation = ai.nation; - if (!city || !nation) { - return null; - } - - if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) { - return null; - } - - const develRate = ai.calcCityDevelRate(city); - const isSpringSummer = ai.world.currentMonth <= 6; - const cmdList: Array<[ReturnType, number]> = []; - - if (ai.genType & t통솔장) { - if (develRate.trust[0] < 0.98) { - cmdList.push([ - ai.buildGeneralCandidate('che_주민선정', {}, '일반내정'), - (ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, - ]); - } - if (develRate.pop[0] < 0.8) { - cmdList.push([ - ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'), - ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001), - ]); - } else if (develRate.pop[0] < 0.99) { - cmdList.push([ - ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'), - ai.general.stats.leadership / valueFit(develRate.pop[0] / 4, 0.001), - ]); - } - } - - if (ai.genType & t무장) { - if (develRate.def[0] < 1) { - cmdList.push([ - ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'), - ai.general.stats.strength / valueFit(develRate.def[0], 0.001), - ]); - } - if (develRate.wall[0] < 1) { - cmdList.push([ - ai.buildGeneralCandidate('che_성벽보수', {}, '일반내정'), - ai.general.stats.strength / valueFit(develRate.wall[0], 0.001), - ]); - } - if (develRate.secu[0] < 0.9) { - cmdList.push([ - ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'), - ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1), - ]); - } else if (develRate.secu[0] < 1) { - cmdList.push([ - ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'), - ai.general.stats.strength / 2 / valueFit(develRate.secu[0], 0.001), - ]); - } - } - - if (ai.genType & t지장) { - cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), ai.general.stats.intelligence]); - if (develRate.agri[0] < 1) { - cmdList.push([ - ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'), - ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / valueFit(develRate.agri[0], 0.001, 1), - ]); - } - if (develRate.comm[0] < 1) { - cmdList.push([ - ai.buildGeneralCandidate('che_상업투자', {}, '일반내정'), - ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / valueFit(develRate.comm[0], 0.001, 1), - ]); - } - } - - return pickWeightedCandidate(ai, cmdList); -}; - -export const do긴급내정 = (ai: GeneralAI) => { - const city = ai.city; - if (!city) { - return null; - } - if (ai.dipState === 0) { - return null; - } - const trust = resolveCityTrust(city); - if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) { - return ai.buildGeneralCandidate('che_주민선정', {}, '긴급내정'); - } - if ( - city.population < ai.nationPolicy.minNpcRecruitCityPopulation && - ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin / 2) - ) { - return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정'); - } - return null; -}; - -export const do전쟁내정 = (ai: GeneralAI) => { - const city = ai.city; - const nation = ai.nation; - if (!city || !nation) { - return null; - } - if (ai.dipState === 0) { - return null; - } - if (nation.rice < ai.aiConst.baseRice && ai.rng.nextBool(0.3)) { - return null; - } - if (ai.rng.nextBool(0.3)) { - return null; - } - const develRate = ai.calcCityDevelRate(city); - const isSpringSummer = ai.world.currentMonth <= 6; - const cmdList: Array<[ReturnType, number]> = []; - - if (ai.genType & t통솔장) { - if (develRate.trust[0] < 0.98) { - cmdList.push([ - ai.buildGeneralCandidate('che_주민선정', {}, '전쟁내정'), - (ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, - ]); - } - 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; - cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]); - } - } - - if (ai.genType & t무장) { - if (develRate.def[0] < 0.5) { - cmdList.push([ - ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'), - ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2, - ]); - } - if (develRate.wall[0] < 0.5) { - cmdList.push([ - ai.buildGeneralCandidate('che_성벽보수', {}, '전쟁내정'), - ai.general.stats.strength / valueFit(develRate.wall[0], 0.001) / 2, - ]); - } - if (develRate.secu[0] < 0.5) { - cmdList.push([ - ai.buildGeneralCandidate('che_치안강화', {}, '전쟁내정'), - ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4, - ]); - } - } - - if (ai.genType & t지장) { - cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), ai.general.stats.intelligence]); - 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); - 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); - cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]); - } - } - - return pickWeightedCandidate(ai, cmdList); -}; - -export const do금쌀구매 = (ai: GeneralAI) => { - const city = ai.city; - if (!city) { - return null; - } - - const trade = readMetaNumber(asRecord(city.meta), 'trade', 0); - if (trade === 0 && !ai.generalPolicy.can('상인무시')) { - return null; - } - - const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000; - const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000; - const deathRate = death / kill; - - const absGold = ai.general.gold; - const absRice = ai.general.rice; - const relGold = absGold; - const relRice = absRice * deathRate; - - const baseDevelCost = ai.commandEnv.develCost * 12; - if (absGold + absRice < baseDevelCost * 2) { - return null; - } - - const crewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId); - const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0); - const fullLeadership = ai.general.stats.leadership; - const crewAmount = fullLeadership * 100; - const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0; - const riceCost = crewAmount / 100; - - if ((relGold + relRice) * 1.5 <= goldCost + riceCost) { - return null; - } - - if (ai.general.npcState < 2 && relGold >= goldCost * 3 && relRice >= riceCost * 3) { - return null; - } - - let tryBuying = false; - if (ai.generalPolicy.can('상인무시')) { - if (relRice * 1.5 < relGold && relRice < riceCost * 2) { - tryBuying = true; - } else if (relRice * 2 < relGold) { - tryBuying = true; - } - } else if (relRice * 2 < relGold && relRice < riceCost * 3) { - tryBuying = true; - } - - if (tryBuying) { - const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount); - if (amount >= ai.nationPolicy.minimumResourceActionAmount) { - return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매'); - } - } - - let trySelling = false; - if (ai.generalPolicy.can('상인무시')) { - if (relGold * 1.5 < relRice && relGold < goldCost * 2) { - trySelling = true; - } else if (relGold * 2 < relRice) { - trySelling = true; - } - } else if (relGold * 2 < relRice && relGold < goldCost * 3) { - trySelling = true; - } - - if (trySelling) { - const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount); - if (amount >= ai.nationPolicy.minimumResourceActionAmount) { - return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매'); - } - } - - return null; -}; - -export const do징병 = (ai: GeneralAI) => { - const city = ai.city; - const nation = ai.nation; - if (!city || !nation || !ai.unitSet || !ai.map) { - return null; - } - if ([0, 1].includes(ai.dipState) && ai.general.npcState < 2) { - return null; - } - if (!(ai.genType & t통솔장)) { - return null; - } - if (ai.general.crew >= ai.nationPolicy.minWarCrew) { - return null; - } - - if (!ai.generalPolicy.can('한계징병')) { - const remainPop = - city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100; - if (remainPop <= 0) { - return null; - } - const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation; - if ( - city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio && - ai.rng.nextBool(remainPop / Math.max(1, maxPop)) - ) { - return null; - } - } - - const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0); - const crewAmountBase = ai.general.stats.leadership * 100; - const armType = - readMetaNumber(asRecord(ai.general.meta), 'armType', 0) || - (ai.general.stats.strength >= ai.general.stats.intelligence * 0.9 ? 1 : 4); - - const candidates = (ai.unitSet?.crewTypes ?? []) - .filter((crew) => crew.armType === armType) - .filter((crew) => - isCrewTypeAvailable(ai.unitSet!, crew.id, { - general: ai.general, - nation, - map: ai.map!, - cities: ai.worldRef?.listCities() ?? [], - currentYear: ai.world.currentYear, - startYear: ai.startYear, - }) - ); - if (candidates.length === 0) { - return null; - } - const picked = ai.rng.choiceUsingWeightPair(candidates.map((crew) => [crew, Math.max(1, crew.cost)])); - const crewTypeId = picked.id; - - let crewAmount = crewAmountBase; - const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100; - const riceCost = crewAmount / 100; - - if (ai.general.gold <= 0 || ai.general.rice <= 0) { - return null; - } - - if (ai.generalPolicy.can('모병') && ai.general.gold >= 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) { - crewAmount *= 0.5; - crewAmount = roundTo(crewAmount - 49, -2); - } - - if (!ai.generalPolicy.can('한계징병') && ai.general.rice * 1.1 <= riceCost) { - return null; - } - - return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병'); -}; - -export const do전투준비 = (ai: GeneralAI) => { - if ([0, 1].includes(ai.dipState)) { - return null; - } - const cmdList: Array<[ReturnType, number]> = []; - if (ai.general.train < ai.nationPolicy.properWarTrainAtmos) { - cmdList.push([ - ai.buildGeneralCandidate('che_훈련', {}, '전투준비'), - ai.commandEnv.maxTrainByCommand / valueFit(ai.general.train, 1), - ]); - } - if (ai.general.atmos < ai.nationPolicy.properWarTrainAtmos) { - cmdList.push([ - ai.buildGeneralCandidate('che_사기진작', {}, '전투준비'), - ai.commandEnv.maxAtmosByCommand / valueFit(ai.general.atmos, 1), - ]); - } - return pickWeightedCandidate(ai, cmdList); -}; - -export const do소집해제 = (ai: GeneralAI) => { - if (ai.attackable) { - return null; - } - if (ai.dipState !== 0) { - return null; - } - if (ai.general.crew === 0) { - return null; - } - if (ai.rng.nextBool(0.75)) { - return null; - } - return ai.buildGeneralCandidate('che_소집해제', {}, '소집해제'); -}; - -export const do출병 = (ai: GeneralAI) => { - const city = ai.city; - const nation = ai.nation; - if (!city || !nation || !ai.map || !ai.worldRef) { - return null; - } - if (!ai.attackable || ai.dipState !== 4) { - return null; - } - if (nation.rice < ai.aiConst.baseRice && ai.general.npcState >= 2 && ai.rng.nextBool(0.7)) { - return null; - } - if (ai.general.train < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) { - return null; - } - if (ai.general.atmos < Math.min(100, ai.nationPolicy.properWarTrainAtmos)) { - return null; - } - if (ai.general.crew < Math.min((ai.general.stats.leadership - 2) * 100, ai.nationPolicy.minWarCrew)) { - return null; - } - if (city.frontState <= 1) { - return null; - } - - const attackableNations = Object.entries(ai.warTargetNation) - .filter(([, state]) => state !== 1) - .map(([id]) => Number(id)); - if (attackableNations.length === 0) { - attackableNations.push(0); - } - const neighbors = ai.map.cities.find((c) => c.id === city.id)?.connections ?? []; - const attackableCities = neighbors.filter((cityId) => { - const destCity = ai.worldRef?.getCityById(cityId); - return destCity ? attackableNations.includes(destCity.nationId) : false; - }); - if (attackableCities.length === 0) { - return null; - } - return ai.buildGeneralCandidate('che_출병', { destCityId: ai.rng.choice(attackableCities) }, '출병'); -}; - -export const doNPC헌납 = (ai: GeneralAI) => { - const nation = ai.nation; - if (!nation) { - return null; - } - const resourceMap: Array<['rice' | 'gold', number, number, number]> = [ - ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], - ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], - ]; - const args: Array<[Record, number]> = []; - - for (const [resKey, reqNation, reqNpcWar, reqNpcDevel] of resourceMap) { - const genRes = ai.general[resKey]; - let reqRes = reqNpcDevel; - - if (ai.genType & t통솔장) { - reqRes = reqNpcWar; - } else { - if (genRes >= reqNpcWar && reqNpcWar > reqNpcDevel + 1000) { - const amount = genRes - reqNpcDevel; - args.push([{ isGold: resKey === 'gold', amount }, amount]); - continue; - } - if (genRes >= reqNpcDevel * 5 && genRes >= 5000) { - const amount = genRes - reqNpcDevel; - args.push([{ isGold: resKey === 'gold', amount }, amount]); - continue; - } - } - - if (nation[resKey] >= reqNation) { - continue; - } - if ( - resKey === 'rice' && - nation[resKey] <= ai.aiConst.minNationalRice / 2 && - genRes >= ai.aiConst.minNationalRice / 2 - ) { - const amount = genRes < ai.aiConst.minNationalRice ? genRes : genRes / 2; - args.push([{ isGold: false, amount }, amount]); - } - if (genRes < reqRes * 1.5) { - continue; - } - if (reqRes > 0 && !ai.rng.nextBool(genRes / reqRes - 0.5)) { - continue; - } - const amount = genRes - reqRes; - if (amount < ai.nationPolicy.minimumResourceActionAmount) { - continue; - } - args.push([{ isGold: resKey === 'gold', amount }, amount]); - } - - if (args.length === 0) { - return null; - } - - return ai.buildGeneralCandidate('che_헌납', ai.rng.choiceUsingWeightPair(args), 'NPC헌납'); -}; - -export const do후방워프 = (ai: GeneralAI) => { - const city = ai.city; - if (!city || !ai.nation || !ai.map) { - return null; - } - if ([0, 1].includes(ai.dipState)) { - return null; - } - if (!(ai.genType & t통솔장)) { - return null; - } - if (ai.general.crew >= ai.nationPolicy.minWarCrew) { - return null; - } - - let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; - if (!ai.generalPolicy.can('한계징병')) { - minRecruitPop = Math.max( - minRecruitPop, - ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation - ); - } - - if (ai.generalPolicy.can('한계징병')) { - if (city.population >= minRecruitPop) { - return null; - } - } else if ( - city.population / city.populationMax >= ai.nationPolicy.safeRecruitCityPopulationRatio && - city.population >= ai.nationPolicy.minNpcRecruitCityPopulation && - city.population >= minRecruitPop - ) { - return null; - } - - ai.categorizeNationCities(); - - const recruitable: Record = {}; - for (const candidate of Object.values(ai.backupCities)) { - if (candidate.id === city.id) { - continue; - } - if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) { - continue; - } - if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) { - continue; - } - if (candidate.population < minRecruitPop) { - continue; - } - recruitable[candidate.id] = candidate.population / candidate.populationMax; - } - if (Object.keys(recruitable).length === 0) { - for (const candidate of Object.values(ai.supplyCities)) { - if (candidate.id === city.id) { - continue; - } - if (candidate.population < ai.nationPolicy.minNpcRecruitCityPopulation) { - continue; - } - if (candidate.population <= minRecruitPop) { - continue; - } - if (candidate.population / candidate.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio) { - continue; - } - recruitable[candidate.id] = - candidate.frontState > 0 - ? candidate.population / candidate.populationMax / 2 - : candidate.population / candidate.populationMax; - } - } - if (Object.keys(recruitable).length === 0) { - return null; - } - - return ai.buildGeneralCandidate( - 'che_NPC능동', - { optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) }, - '후방워프' - ); -}; - -export const do전방워프 = (ai: GeneralAI) => { - const city = ai.city; - if (!city || !ai.nation || !ai.map) { - return null; - } - if (!ai.attackable || [0, 1].includes(ai.dipState)) { - return null; - } - if (!(ai.genType & t통솔장)) { - return null; - } - if (ai.general.crew < ai.nationPolicy.minWarCrew) { - return null; - } - if (city.frontState > 0) { - return null; - } - - ai.categorizeNationCities(); - const candidateCities: Record = {}; - for (const frontCity of Object.values(ai.frontCities)) { - if (frontCity.supplyState <= 0) { - continue; - } - candidateCities[frontCity.id] = frontCity.important; - } - if (Object.keys(candidateCities).length === 0) { - return null; - } - - return ai.buildGeneralCandidate( - 'che_NPC능동', - { optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) }, - '전방워프' - ); -}; - -export const do내정워프 = (ai: GeneralAI) => { - const city = ai.city; - if (!city || !ai.nation || !ai.map) { - return null; - } - if (ai.genType & t통솔장 && [2, 3, 4].includes(ai.dipState)) { - return null; - } - if (ai.rng.nextBool(0.6)) { - return null; - } - - const develRate = ai.calcCityDevelRate(city); - let warpProp = 1; - let availableTypeCnt = 0; - for (const [key, [value, type]] of Object.entries(develRate)) { - if (!(ai.genType & type)) { - continue; - } - warpProp *= value; - availableTypeCnt += 1; - void key; - } - if (availableTypeCnt === 0) { - return null; - } - if (!ai.rng.nextBool(warpProp)) { - return null; - } - - ai.categorizeNationCities(); - ai.categorizeNationGeneral(); - const candidateCities: Record = {}; - for (const candidate of Object.values(ai.supplyCities)) { - if (candidate.id === city.id) { - continue; - } - let realDevelRate = 0.0001; - for (const [_, [value, type]] of Object.entries(ai.calcCityDevelRate(candidate))) { - if (!(ai.genType & type)) { - continue; - } - realDevelRate += value; - } - realDevelRate /= availableTypeCnt; - if (realDevelRate >= 0.95) { - continue; - } - candidateCities[candidate.id] = - 1 / (realDevelRate * Math.sqrt((candidate.generals ? Object.keys(candidate.generals).length : 0) + 1)); - } - if (Object.keys(candidateCities).length === 0) { - return null; - } - - return ai.buildGeneralCandidate( - 'che_NPC능동', - { optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(candidateCities) }, - '내정워프' - ); -}; - -export const do귀환 = (ai: GeneralAI) => { - const city = ai.city; - if (!city) { - return null; - } - if (city.nationId === ai.general.nationId && city.supplyState > 0) { - return null; - } - return ai.buildGeneralCandidate('che_귀환', {}, '귀환'); -}; - -export const do집합 = (ai: GeneralAI) => ai.buildGeneralCandidate('che_집합', {}, '집합'); - -export const do국가선택 = (ai: GeneralAI) => { - if (!ai.worldRef) { - return null; - } - if (ai.general.npcState === 9) { - const ruler = ai.worldRef - .listGenerals() - .find((general) => general.officerLevel === 12 && general.npcState === 9); - if (ruler) { - return ai.buildGeneralCandidate('che_임관', { destNationId: ruler.nationId }, '국가선택'); - } - } - - if (ai.rng.nextBool(0.3)) { - return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택'); - } - - if (ai.rng.nextBool(0.2) && ai.map) { - const neighbors = ai.map.cities.find((c) => c.id === ai.general.cityId)?.connections ?? []; - if (neighbors.length === 0) { - return null; - } - return ai.buildGeneralCandidate('che_이동', { destCityId: ai.rng.choice(neighbors) }, '국가선택'); - } - - return null; -}; - -export const doNPC사망대비 = (ai: GeneralAI) => { - const killturn = readMetaNumber(asRecord(ai.general.meta), 'killturn', 999); - if (killturn > 5) { - return null; - } - - if (ai.general.nationId === 0) { - const search = ai.buildGeneralCandidate('che_인재탐색', {}, 'NPC사망대비'); - if (search && !ai.rng.nextBool()) { - return search; - } - return ai.buildGeneralCandidate('che_견문', {}, 'NPC사망대비'); - } - - if (ai.general.gold + ai.general.rice === 0) { - return ai.buildGeneralCandidate('che_물자조달', {}, 'NPC사망대비'); - } - - if (ai.general.gold >= ai.general.rice) { - return ai.buildGeneralCandidate( - 'che_헌납', - { isGold: true, amount: ai.aiConst.maxResourceActionAmount }, - 'NPC사망대비' - ); - } - return ai.buildGeneralCandidate( - 'che_헌납', - { isGold: false, amount: ai.aiConst.maxResourceActionAmount }, - 'NPC사망대비' - ); -}; - -export const do중립 = (ai: GeneralAI) => { - const nation = ai.nation; - if (!nation || ai.general.nationId === 0) { - const search = ai.buildGeneralCandidate('che_인재탐색', {}, '중립'); - if (search && !ai.rng.nextBool(0.8)) { - return search; - } - return ai.buildGeneralCandidate('che_견문', {}, '중립'); - } - - let candidates = ['che_물자조달', 'che_인재탐색']; - if (nation.gold < ai.nationPolicy.reqNationGold || nation.rice < ai.nationPolicy.reqNationRice) { - candidates = ['che_물자조달']; - } - - for (const key of candidates) { - const cmd = ai.buildGeneralCandidate(key, {}, '중립'); - if (cmd) { - return cmd; - } - } - return ai.buildGeneralCandidate(ACTION_REST, {}, '중립'); -}; - -export const do거병 = (ai: GeneralAI) => { - if (readMetaNumber(asRecord(ai.general.meta), 'makelimit', 0)) { - return null; - } - if (ai.general.npcState > 2) { - return null; - } - if (!ai.generalPolicy.can('건국')) { - return null; - } - const city = ai.city; - if (!city || !ai.map || !ai.worldRef) { - return null; - } - if ((city.level < 5 || 6 < city.level) && ai.rng.nextBool(0.5)) { - return null; - } - - const occupied = new Set( - ai.worldRef - .listCities() - .filter((c) => c.nationId !== 0) - .map((c) => c.id) - ); - for (const general of ai.worldRef.listGenerals()) { - if (general.officerLevel === 12 && general.nationId === 0) { - occupied.add(general.cityId); - } - } - - let availableNearCity = false; - const nearby = searchDistance(ai.map, ai.general.cityId, 3); - for (const [targetCityId, dist] of Object.entries(nearby)) { - const cityId = Number(targetCityId); - if (!Number.isFinite(cityId)) { - continue; - } - if (occupied.has(cityId)) { - continue; - } - const target = ai.worldRef.getCityById(cityId); - if (!target || target.level < 5 || target.level > 6) { - continue; - } - if (dist === 3 && ai.rng.nextBool()) { - continue; - } - availableNearCity = true; - break; - } - if (!availableNearCity) { - return null; - } - - 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; - if (prop >= ratio) { - return null; - } - - const relYear = Math.max(0, ai.world.currentYear - ai.startYear); - const more = valueFit(3 - relYear, 1, 3); - if (!ai.rng.nextBool(0.0075 * more)) { - return null; - } - - return ai.buildGeneralCandidate('che_거병', {}, '거병'); -}; - -export const do건국 = (ai: GeneralAI) => { - const mapName = ai.scenarioConfig.environment.mapName ?? 'sammo'; - const prefix = mapName.endsWith('_') ? mapName : `${mapName}_`; - const nationType = - 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; - - return ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국'); -}; - -export const do방랑군이동 = (ai: GeneralAI) => { - const city = ai.city; - if (!city || !ai.map || !ai.worldRef) { - return null; - } - const occupied = new Set( - ai.worldRef - .listCities() - .filter((c) => c.nationId !== 0) - .map((c) => c.id) - ); - for (const general of ai.worldRef.listGenerals()) { - if (general.officerLevel === 12 && general.nationId === 0) { - occupied.add(general.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)]); - } - if (candidates.length === 0) { - return null; - } - const destCityId = ai.rng.choiceUsingWeightPair(candidates); - if (destCityId === city.id) { - return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동'); - } - return ai.buildGeneralCandidate('che_이동', { destCityId }, '방랑군이동'); -}; - -export const generalActionHandlers: Record< - string, - (ai: GeneralAI) => ReturnType | null -> = { - NPC사망대비: doNPC사망대비, - 귀환: do귀환, - 금쌀구매: do금쌀구매, - 출병: do출병, - 긴급내정: do긴급내정, - 전투준비: do전투준비, - 전방워프: do전방워프, - NPC헌납: doNPC헌납, - 징병: do징병, - 후방워프: do후방워프, - 전쟁내정: do전쟁내정, - 소집해제: do소집해제, - 일반내정: do일반내정, - 내정워프: do내정워프, - 국가선택: do국가선택, - 중립: do중립, - 집합: do집합, - 거병: do거병, - 건국: do건국, - 방랑군이동: do방랑군이동, -}; +export * from './generalAi/general/index.js'; diff --git a/app/game-engine/src/turn/ai/generalAiNationActions.ts b/app/game-engine/src/turn/ai/generalAiNationActions.ts index e29140c..7aa76a6 100644 --- a/app/game-engine/src/turn/ai/generalAiNationActions.ts +++ b/app/game-engine/src/turn/ai/generalAiNationActions.ts @@ -1,1055 +1 @@ -import type { City } from '@sammo-ts/logic'; - -import type { GeneralAI } from './generalAi.js'; -import { asRecord, calcCityDevRatio, joinYearMonth, parseYearMonth, readMetaNumber } from './aiUtils.js'; -import { isNeighbor, searchAllDistanceByCityList } from './distance.js'; - -const pickWeightedCandidate = (ai: GeneralAI, list: Array<[ReturnType, number]>) => { - const items = list.filter(([item]) => Boolean(item)) as Array< - [ReturnType, number] - >; - if (items.length === 0) { - return null; - } - const picked = ai.rng.choiceUsingWeightPair(items); - return picked ?? null; -}; - -const pickRandomCityId = (ai: GeneralAI, cities: Record): number | null => { - const ids = Object.keys(cities).map((key) => Number(key)); - if (ids.length === 0) { - return null; - } - return ai.rng.choice(ids); -}; - -const resolveCityPopRatio = (city: City): number => { - if (city.populationMax <= 0) { - return 0; - } - return city.population / city.populationMax; -}; - -const resolveLastAssignment = (general: GeneralAI['general'], yearMonth: number): boolean => { - const last = readMetaNumber(asRecord(general.meta), 'last발령', 0); - return last >= yearMonth; -}; - -const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record => { - const candidates: Record = {}; - for (const city of Object.values(ai.backupCities)) { - if (city.population < minPop) { - continue; - } - const ratio = resolveCityPopRatio(city); - candidates[city.id] = ratio; - } - if (Object.keys(candidates).length > 0) { - return candidates; - } - for (const city of Object.values(ai.supplyCities)) { - if (city.population < minPop) { - continue; - } - const ratio = resolveCityPopRatio(city); - candidates[city.id] = ratio; - } - return candidates; -}; - -const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) => - ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason); - -const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) => - ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason); - -const buildAwardCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) => - ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason); - -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; -}; - -const resolveNationIncome = (ai: GeneralAI): number => { - const cities = Object.values(ai.supplyCities); - if (cities.length === 0) { - return 0; - } - return cities.reduce((sum, city) => sum + city.population / 100 + city.agriculture / 100 + city.commerce / 100, 0); -}; - -const pickFrontCityWeight = (ai: GeneralAI): Record => { - const candidates: Record = {}; - for (const city of Object.values(ai.frontCities)) { - candidates[city.id] = city.important; - } - return candidates; -}; - -export const do부대전방발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.map) { - return null; - } - if (!ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.frontCities).length === 0) { - return null; - } - - ai.calcWarRoute(); - const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); - - const troopCandidates: Array<[ReturnType, number]> = []; - - for (const leader of Object.values(ai.troopLeaders)) { - if (!ai.nationPolicy.combatForce[leader.id]) { - continue; - } - if (ai.frontCities[leader.cityId]) { - continue; - } - if (resolveLastAssignment(leader, yearMonth)) { - continue; - } - - const force = ai.nationPolicy.combatForce[leader.id]; - let [fromCityId, toCityId] = force; - - let targetCityId: number | null = null; - if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) { - targetCityId = pickRandomCityId(ai, ai.frontCities); - } else { - if (!ai.supplyCities[fromCityId]) { - toCityId = fromCityId; - fromCityId = ai.nation.capitalCityId ?? fromCityId; - } - targetCityId = fromCityId; - while (targetCityId !== null && !ai.frontCities[targetCityId]) { - const current = targetCityId; - const distance = ai.warRoute[current]?.[toCityId]; - if (distance === undefined) { - targetCityId = pickRandomCityId(ai, ai.frontCities); - break; - } - const connections: number[] = ai.map.cities.find((city) => city.id === current)?.connections ?? []; - const nextCandidates: number[] = connections.filter((nextCityId: number) => { - const nextDistance = ai.warRoute?.[nextCityId]?.[toCityId]; - return nextDistance !== undefined && nextDistance <= distance; - }); - if (nextCandidates.length === 0) { - targetCityId = pickRandomCityId(ai, ai.frontCities); - break; - } - targetCityId = ai.rng.choice(nextCandidates); - } - } - - if (targetCityId === null) { - continue; - } - troopCandidates.push([buildAssignmentCandidate(ai, leader.id, targetCityId, '부대전방발령'), 1]); - } - - return pickWeightedCandidate(ai, troopCandidates); -}; - -export const do부대후방발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.frontCities).length === 0) { - return null; - } - if (Object.keys(ai.supplyCities).length <= 1) { - return null; - } - - const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); - const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => { - if (!ai.nationPolicy.supportForce.includes(leader.id)) { - return false; - } - if (resolveLastAssignment(leader, yearMonth)) { - return false; - } - const city = ai.supplyCities[leader.cityId]; - if (!city) { - return true; - } - if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { - return false; - } - return true; - }); - - if (troopCandidates.length === 0) { - return null; - } - - const cityCandidates: Record = {}; - for (const city of Object.values(ai.backupCities)) { - const ratio = resolveCityPopRatio(city); - if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) { - cityCandidates[city.id] = ratio; - } - } - if (Object.keys(cityCandidates).length === 0) { - for (const city of Object.values(ai.supplyCities)) { - const ratio = resolveCityPopRatio(city); - if (ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio) { - cityCandidates[city.id] = ratio; - } - } - } - if (Object.keys(cityCandidates).length === 0) { - return null; - } - - const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - const leader = ai.rng.choice(troopCandidates); - return buildAssignmentCandidate(ai, leader.id, destCityId, '부대후방발령'); -}; - -export const do부대구출발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.frontCities).length === 0) { - return null; - } - const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); - - const troopCandidates = Object.values(ai.troopLeaders).filter((leader) => { - if (ai.nationPolicy.supportForce.includes(leader.id)) { - return false; - } - if (ai.nationPolicy.combatForce[leader.id]) { - return false; - } - if (resolveLastAssignment(leader, yearMonth)) { - return false; - } - return !ai.supplyCities[leader.cityId]; - }); - - if (troopCandidates.length === 0) { - return null; - } - - const destCityId = pickRandomCityId(ai, ai.frontCities); - if (destCityId === null) { - return null; - } - const leader = ai.rng.choice(troopCandidates); - return buildAssignmentCandidate(ai, leader.id, destCityId, '부대구출발령'); -}; - -export const do부대유저장후방발령 = (ai: GeneralAI) => { - if (Object.keys(ai.frontCities).length === 0) { - return null; - } - if (ai.dipState !== 4) { - return null; - } - - const candidates = Object.values(ai.userWarGenerals).filter((general) => { - if (general.id === ai.general.id) { - return false; - } - if (!ai.frontCities[general.cityId]) { - return false; - } - if (!ai.nationCities[general.cityId]) { - return false; - } - const troopLeaderId = general.troopId; - if (!troopLeaderId || !ai.troopLeaders[troopLeaderId]) { - return false; - } - if (troopLeaderId === general.id) { - return false; - } - const troopLeader = ai.troopLeaders[troopLeaderId]; - if (troopLeader.cityId !== general.cityId) { - return false; - } - if (!ai.supplyCities[troopLeader.cityId]) { - return false; - } - const city = ai.nationCities[general.cityId]; - if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { - return false; - } - if (general.crew >= ai.nationPolicy.minWarCrew) { - return false; - } - const reserved = ai.getReservedTurn(general.id); - if (reserved.action !== 'che_징병') { - return false; - } - return true; - }); - - if (candidates.length === 0) { - return null; - } - - const destCityCandidates = selectRecruitableCity(ai, ai.nationPolicy.minNpcRecruitCityPopulation); - if (Object.keys(destCityCandidates).length === 0) { - return null; - } - - const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - const destGeneral = ai.rng.choice(candidates); - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령'); -}; - -export const do유저장후방발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (ai.dipState !== 4) { - return null; - } - if (Object.keys(ai.supplyCities).length <= 1) { - return null; - } - - const candidates = Object.values(ai.userWarGenerals).filter((general) => { - if (general.id === ai.general.id) { - return false; - } - if (!ai.supplyCities[general.cityId]) { - return false; - } - if (general.troopId) { - return false; - } - const city = ai.supplyCities[general.cityId]; - if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { - return false; - } - if (general.crew >= ai.nationPolicy.minWarCrew) { - return false; - } - return true; - }); - - if (candidates.length === 0) { - return null; - } - - const picked = ai.rng.choice(candidates); - const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; - const destCityCandidates = selectRecruitableCity(ai, minPop); - if (Object.keys(destCityCandidates).length === 0) { - return null; - } - - const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - return buildAssignmentCandidate(ai, picked.id, destCityId, '유저장후방발령'); -}; - -export const do유저장구출발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - const lostCandidates = Object.values(ai.lostGenerals).filter((general) => general.npcState < 2); - if (lostCandidates.length === 0) { - return null; - } - const destCityId = pickRandomCityId(ai, ai.frontCities) ?? pickRandomCityId(ai, ai.supplyCities); - if (destCityId === null) { - return null; - } - const destGeneral = ai.rng.choice(lostCandidates); - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장구출발령'); -}; - -export const do유저장전방발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.frontCities).length === 0) { - return null; - } - if ([0, 1].includes(ai.dipState)) { - return null; - } - - const candidates = Object.values(ai.userWarGenerals).filter((general) => { - if (general.id === ai.general.id) { - return false; - } - if (ai.frontCities[general.cityId]) { - return false; - } - if (!ai.nationCities[general.cityId]) { - return false; - } - if (general.crew < ai.nationPolicy.minWarCrew) { - return false; - } - return true; - }); - - if (candidates.length === 0) { - return null; - } - - const cityCandidates = pickFrontCityWeight(ai); - const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - const destGeneral = ai.rng.choice(candidates); - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령'); -}; - -export const do유저장내정발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.supplyCities).length <= 1) { - return null; - } - - const supplyCities = Object.values(ai.supplyCities); - const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length; - if (avgDev >= 0.99) { - return null; - } - - const userGenerals = [0, 1].includes(ai.dipState) - ? [...Object.values(ai.userWarGenerals), ...Object.values(ai.userCivilGenerals)] - : Object.values(ai.userCivilGenerals); - - const generalCandidates = userGenerals.filter((general) => { - if (general.troopId) { - return false; - } - const city = ai.supplyCities[general.cityId]; - if (!city) { - return false; - } - return city.dev >= 0.95; - }); - - if (generalCandidates.length === 0) { - return null; - } - - const cityCandidates: Record = {}; - for (const city of supplyCities) { - const dev = Math.min(city.dev, 0.999); - const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1); - cityCandidates[city.id] = score; - } - - const destGeneral = ai.rng.choice(generalCandidates); - const srcCity = ai.supplyCities[destGeneral.cityId]; - const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) { - return null; - } - - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장내정발령'); -}; - -export const doNPC후방발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.frontCities).length === 0) { - return null; - } - if (ai.dipState !== 4) { - return null; - } - - const candidates = Object.values(ai.npcWarGenerals).filter((general) => { - if (general.id === ai.general.id) { - return false; - } - if (!ai.supplyCities[general.cityId]) { - return false; - } - if (general.troopId) { - return false; - } - const city = ai.supplyCities[general.cityId]; - if (resolveCityPopRatio(city) >= ai.nationPolicy.safeRecruitCityPopulationRatio) { - return false; - } - if (general.crew >= ai.nationPolicy.minWarCrew) { - return false; - } - return true; - }); - - if (candidates.length === 0) { - return null; - } - - const picked = ai.rng.choice(candidates); - const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; - const destCityCandidates = selectRecruitableCity(ai, minPop); - if (Object.keys(destCityCandidates).length === 0) { - return null; - } - - const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - return buildAssignmentCandidate(ai, picked.id, destCityId, 'NPC후방발령'); -}; - -export const doNPC구출발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - const lostCandidates = Object.values(ai.lostGenerals).filter( - (general) => general.npcState >= 2 && general.npcState !== 5 - ); - if (lostCandidates.length === 0) { - return null; - } - const destCityId = pickRandomCityId(ai, ai.supplyCities); - if (destCityId === null) { - return null; - } - const destGeneral = ai.rng.choice(lostCandidates); - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령'); -}; - -export const doNPC전방발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.frontCities).length === 0) { - return null; - } - if ([0, 1].includes(ai.dipState)) { - return null; - } - - const candidates = Object.values(ai.npcWarGenerals).filter((general) => { - if (ai.frontCities[general.cityId]) { - return false; - } - if (!ai.nationCities[general.cityId]) { - return false; - } - if (general.crew < ai.nationPolicy.minWarCrew) { - return false; - } - if (general.troopId) { - return false; - } - if (Math.max(general.train, general.atmos) < ai.nationPolicy.properWarTrainAtmos) { - return false; - } - return true; - }); - - if (candidates.length === 0) { - return null; - } - - const cityCandidates = pickFrontCityWeight(ai); - const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - const destGeneral = ai.rng.choice(candidates); - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC전방발령'); -}; - -export const doNPC내정발령 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.supplyCities).length <= 1) { - return null; - } - - const supplyCities = Object.values(ai.supplyCities); - const avgDev = supplyCities.reduce((sum, city) => sum + city.dev, 0) / supplyCities.length; - if (avgDev >= 0.99) { - return null; - } - - const npcGenerals = [0, 1].includes(ai.dipState) - ? [...Object.values(ai.npcWarGenerals), ...Object.values(ai.npcCivilGenerals)] - : Object.values(ai.npcCivilGenerals); - - const generalCandidates = npcGenerals.filter((general) => { - const city = ai.supplyCities[general.cityId]; - if (!city) { - return false; - } - return city.dev >= 0.95; - }); - - if (generalCandidates.length === 0) { - return null; - } - - const cityCandidates: Record = {}; - for (const city of supplyCities) { - const dev = Math.min(city.dev, 0.999); - const score = Math.pow(1 - dev, 2) / Math.sqrt((city.generals ? Object.keys(city.generals).length : 0) + 1); - cityCandidates[city.id] = score; - } - - const destGeneral = ai.rng.choice(generalCandidates); - const srcCity = ai.supplyCities[destGeneral.cityId]; - const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); - if (!Number.isFinite(destCityId)) { - return null; - } - if (srcCity && srcCity.dev <= (ai.supplyCities[destCityId]?.dev ?? 0)) { - return null; - } - - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC내정발령'); -}; - -export const do유저장긴급포상 = (ai: GeneralAI) => { - const nation = ai.nation; - if (!nation) { - return null; - } - const candidates: Array<[ReturnType, number]> = []; - const resourceMap: Array<['gold' | 'rice', 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) { - continue; - } - candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), amount]); - } - } - - return pickWeightedCandidate(ai, candidates); -}; - -export const do유저장포상 = (ai: GeneralAI) => { - const nation = ai.nation; - if (!nation) { - return null; - } - const candidates: Array<[ReturnType, number]> = []; - const resourceMap: Array<['gold' | 'rice', number]> = [ - ['gold', ai.nationPolicy.reqHumanWarRecommandGold], - ['rice', ai.nationPolicy.reqHumanWarRecommandRice], - ]; - - 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) { - continue; - } - candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), amount]); - } - } - - return pickWeightedCandidate(ai, candidates); -}; - -export const doNPC긴급포상 = (ai: GeneralAI) => { - const nation = ai.nation; - if (!nation) { - return null; - } - const candidates: Array<[ReturnType, number]> = []; - const resourceMap: Array<['gold' | 'rice', number]> = [ - ['gold', ai.nationPolicy.reqNpcWarGold / 2], - ['rice', ai.nationPolicy.reqNpcWarRice / 2], - ]; - - 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.npcWarGenerals)) { - const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); - if (killturn <= 5) { - continue; - } - const amount = resolveAwardAmount(ai, general[resKey], required); - if (!amount) { - continue; - } - candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), amount]); - } - } - - return pickWeightedCandidate(ai, candidates); -}; - -export const doNPC포상 = (ai: GeneralAI) => { - const nation = ai.nation; - if (!nation) { - return null; - } - const candidates: Array<[ReturnType, number]> = []; - const resourceMap: Array<['gold' | 'rice', number, number]> = [ - ['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], - ['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], - ]; - - for (const [resKey, warReq, devReq] 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.npcWarGenerals)) { - const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); - if (killturn <= 5) { - continue; - } - const amount = resolveAwardAmount(ai, general[resKey], warReq); - if (!amount) { - continue; - } - candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]); - } - for (const general of Object.values(ai.npcCivilGenerals)) { - const killturn = readMetaNumber(asRecord(general.meta), 'killturn', 999); - 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]); - } - } - - return pickWeightedCandidate(ai, candidates); -}; - -export const doNPC몰수 = (ai: GeneralAI) => { - const nation = ai.nation; - if (!nation) { - return null; - } - const candidates: Array<[ReturnType, number]> = []; - const resourceMap: Array<['gold' | 'rice', number, number]> = [ - ['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], - ['rice', 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; - } - const amount = Math.min(general[resKey] - devReq * 1.2, ai.maxResourceActionAmount); - if (amount < ai.nationPolicy.minimumResourceActionAmount) { - continue; - } - 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]); - } - } - } - - return pickWeightedCandidate(ai, candidates); -}; - -export const do불가침제의 = (ai: GeneralAI) => { - if (!ai.nation || ai.general.officerLevel < 12) { - return null; - } - if (!ai.worldRef) { - return null; - } - const meta = asRecord(ai.nation.meta); - const recvAssist = Array.isArray(meta.recv_assist) ? meta.recv_assist : []; - const respAssist = asRecord(meta.resp_assist); - const respAssistTry = asRecord(meta.resp_assist_try); - const yearMonth = joinYearMonth(ai.world.currentYear, ai.world.currentMonth); - - const candidateList: Record = {}; - for (const entry of recvAssist) { - if (!Array.isArray(entry) || entry.length < 2) { - continue; - } - const destNationId = Number(entry[0]); - const amount = Number(entry[1]); - if (!Number.isFinite(destNationId) || !Number.isFinite(amount)) { - continue; - } - const respEntry = asRecord(respAssist[`n${destNationId}`]); - const respAmount = readMetaNumber(respEntry, '1', 0); - const remain = amount - respAmount; - if (remain <= 0) { - continue; - } - if (ai.warTargetNation[destNationId]) { - continue; - } - const lastTry = readMetaNumber(asRecord(respAssistTry[`n${destNationId}`]), '1', 0); - if (lastTry >= yearMonth - 8) { - continue; - } - candidateList[destNationId] = remain; - } - - if (Object.keys(candidateList).length === 0) { - return null; - } - - const income = resolveNationIncome(ai); - if (income <= 0) { - return null; - } - - const sorted = Object.entries(candidateList).sort((a, b) => b[1] - a[1]); - let destNationId: number | null = null; - let diplomatMonth = 0; - for (const [idRaw, amount] of sorted) { - if (amount * 4 < income) { - break; - } - destNationId = Number(idRaw); - diplomatMonth = (24 * amount) / income; - break; - } - - if (!destNationId) { - return null; - } - - const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth)); - return ai.buildNationCandidate( - 'che_불가침제의', - { destNationId, year: targetYear, month: targetMonth }, - '불가침제의' - ); -}; - -export const do선전포고 = (ai: GeneralAI) => { - if (!ai.nation || ai.general.officerLevel < 12) { - return null; - } - if (ai.dipState !== 0) { - return null; - } - if (ai.attackable) { - return null; - } - if (!ai.nation.capitalCityId) { - return null; - } - if (Object.keys(ai.frontCities).length > 0) { - return null; - } - if (!ai.map || !ai.worldRef) { - return null; - } - - const avgResources = Object.values({ - ...ai.npcWarGenerals, - ...ai.npcCivilGenerals, - ...ai.userWarGenerals, - ...ai.userCivilGenerals, - }); - if (avgResources.length === 0) { - return null; - } - - let avgGold = ai.nation.gold; - let avgRice = ai.nation.rice; - for (const general of avgResources) { - const scale = general.npcState < 2 ? 0.5 : 1; - avgGold += general.gold * scale; - avgRice += general.rice * scale; - } - avgGold /= avgResources.length; - avgRice /= avgResources.length; - - const trialProp = - avgGold / Math.max(ai.nationPolicy.reqNpcWarGold * 1.5, 2000) + - avgRice / Math.max(ai.nationPolicy.reqNpcWarRice * 1.5, 2000); - const devRate = ai.calcNationDevelopedRate(); - const chance = Math.pow((trialProp + (devRate.pop + devRate.all) / 2) / 4, 6); - if (!ai.rng.nextBool(chance)) { - return null; - } - - const currentNationId = ai.nation.id; - const cities = ai.worldRef.listCities(); - const neighbors = ai.worldRef.listNations().filter((nation) => { - if (nation.id <= 0 || nation.id === currentNationId) { - return false; - } - return isNeighbor(ai.map!, cities, currentNationId, nation.id, true); - }); - if (neighbors.length === 0) { - return null; - } - - const weight: Record = {}; - for (const nation of neighbors) { - weight[nation.id] = 1 / Math.sqrt(nation.power + 1); - } - - const destNationId = Number(ai.rng.choiceUsingWeight(weight)); - if (!Number.isFinite(destNationId)) { - return null; - } - return ai.buildNationCandidate('che_선전포고', { destNationId }, '선전포고'); -}; - -export const do천도 = (ai: GeneralAI) => { - if (!ai.nation || !ai.nation.capitalCityId) { - return null; - } - if (!ai.map) { - return null; - } - const nationCities = Object.values(ai.nationCities); - if (nationCities.length <= 1) { - return null; - } - - const cityIds = nationCities.map((city) => city.id); - const distanceList = searchAllDistanceByCityList(ai.map, cityIds); - const capitalId = ai.nation.capitalCityId; - if (!distanceList[capitalId]) { - return null; - } - - let maxDistance = 0; - for (const distances of Object.values(distanceList)) { - const sum = Object.values(distances).reduce((acc, value) => acc + value, 0); - maxDistance = Math.max(maxDistance, sum); - } - - const cityScores: Record = {}; - for (const city of nationCities) { - const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0); - if (sumDistance <= 0) { - continue; - } - const dev = calcCityDevRatio(city); - cityScores[city.id] = city.population * (maxDistance / sumDistance) * Math.sqrt(dev); - } - - 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) { - if (Number(sorted[idx][0]) === capitalId) { - return null; - } - } - - const finalCityId = Number(sorted[0]?.[0]); - if (!Number.isFinite(finalCityId)) { - return null; - } - const dist = distanceList[capitalId]?.[finalCityId]; - if (dist === undefined) { - return null; - } - let targetCityId = finalCityId; - if (dist > 1) { - const connections = ai.map.cities.find((city) => city.id === capitalId)?.connections ?? []; - const candidates = connections.filter((stopId) => distanceList[stopId]?.[finalCityId] + 1 === dist); - if (candidates.length > 0) { - targetCityId = ai.rng.choice(candidates); - } - } - - return ai.buildNationCandidate('che_천도', { destCityId: targetCityId }, '천도'); -}; - -export const nationActionHandlers: Record< - string, - (ai: GeneralAI) => ReturnType | null -> = { - 불가침제의: do불가침제의, - 선전포고: do선전포고, - 천도: do천도, - 유저장긴급포상: do유저장긴급포상, - 부대전방발령: do부대전방발령, - 유저장구출발령: do유저장구출발령, - 유저장후방발령: do유저장후방발령, - 부대유저장후방발령: do부대유저장후방발령, - 유저장전방발령: do유저장전방발령, - 유저장포상: do유저장포상, - 부대구출발령: do부대구출발령, - 부대후방발령: do부대후방발령, - NPC긴급포상: doNPC긴급포상, - NPC구출발령: doNPC구출발령, - NPC후방발령: doNPC후방발령, - NPC포상: doNPC포상, - NPC전방발령: doNPC전방발령, - 유저장내정발령: do유저장내정발령, - NPC내정발령: doNPC내정발령, - NPC몰수: doNPC몰수, -}; +export * from './generalAi/nation/index.js';