fix: preserve nation and recruit AI boundaries
This commit is contained in:
@@ -5,8 +5,8 @@ import {
|
|||||||
isCrewTypeAvailable,
|
isCrewTypeAvailable,
|
||||||
} from '@sammo-ts/logic/world/unitSet.js';
|
} from '@sammo-ts/logic/world/unitSet.js';
|
||||||
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||||
|
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||||
import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
|
import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
|
||||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
|
||||||
|
|
||||||
import type { GeneralAI } from '../core.js';
|
import type { GeneralAI } from '../core.js';
|
||||||
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
||||||
@@ -106,19 +106,20 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
}
|
}
|
||||||
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
|
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
|
||||||
let armTypeDraw: number | null = null;
|
let armTypeDraw: number | null = null;
|
||||||
const armType = forcedArmType > 0
|
const armType =
|
||||||
? forcedArmType
|
forcedArmType > 0
|
||||||
: traceEnabled
|
? forcedArmType
|
||||||
? (() => {
|
: traceEnabled
|
||||||
armTypeDraw = ai.rng.nextFloat1();
|
? (() => {
|
||||||
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
|
armTypeDraw = ai.rng.nextFloat1();
|
||||||
for (const [candidate, weight] of armTypeWeights) {
|
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
|
||||||
if (cursor <= weight) return candidate;
|
for (const [candidate, weight] of armTypeWeights) {
|
||||||
cursor -= Math.max(0, weight);
|
if (cursor <= weight) return candidate;
|
||||||
}
|
cursor -= Math.max(0, weight);
|
||||||
return armTypeWeights.at(-1)![0];
|
}
|
||||||
})()
|
return armTypeWeights.at(-1)![0];
|
||||||
: ai.rng.choiceUsingWeightPair(armTypeWeights);
|
})()
|
||||||
|
: ai.rng.choiceUsingWeightPair(armTypeWeights);
|
||||||
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
|
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
|
||||||
|
|
||||||
const candidates = (ai.unitSet?.crewTypes ?? [])
|
const candidates = (ai.unitSet?.crewTypes ?? [])
|
||||||
@@ -170,39 +171,31 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
const crewTypeId = picked.id;
|
const crewTypeId = picked.id;
|
||||||
|
|
||||||
let crewAmount = crewAmountBase;
|
let crewAmount = crewAmountBase;
|
||||||
const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
|
|
||||||
// Ref asks the concrete che_징병 command for getCost() before deciding
|
// Ref asks the concrete che_징병 command for getCost() before deciding
|
||||||
// whether to halve the requested crew. That path includes personality,
|
// whether to halve the requested crew. In particular, that command caps
|
||||||
// traits, items, and the final integer rounding; using the raw unit price
|
// the charge at the actually refillable amount when the selected type is
|
||||||
// makes che_출세 (+20% cost) recruit a full stack incorrectly.
|
// already equipped, then applies traits/items and legacy rounding.
|
||||||
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
|
const recruitContext = {
|
||||||
const goldCost = Math.round(
|
general: ai.general,
|
||||||
actionPipeline.onCalcDomestic(
|
nation,
|
||||||
{
|
...(ai.worldRef
|
||||||
general: ai.general,
|
? {
|
||||||
nation,
|
worldView: {
|
||||||
...(ai.worldRef
|
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||||
? {
|
listGeneralsByCity: (cityId: number) =>
|
||||||
worldView: {
|
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
listNations: () => ai.worldRef!.listNations(),
|
||||||
listGeneralsByCity: (cityId: number) =>
|
},
|
||||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
}
|
||||||
listNations: () => ai.worldRef!.listNations(),
|
: {}),
|
||||||
},
|
time: {
|
||||||
}
|
year: ai.world.currentYear,
|
||||||
: {}),
|
month: ai.world.currentMonth,
|
||||||
time: {
|
startYear: ai.startYear,
|
||||||
year: ai.world.currentYear,
|
},
|
||||||
month: ai.world.currentMonth,
|
};
|
||||||
startYear: ai.startYear,
|
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||||
},
|
const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold;
|
||||||
},
|
|
||||||
'징병',
|
|
||||||
'cost',
|
|
||||||
rawGoldCost,
|
|
||||||
{ armType: picked.armType }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
||||||
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
||||||
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
|
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ const normalizeGeneralMetaDatabaseIntegers = (meta: TurnGeneral['meta']): TurnGe
|
|||||||
// Keeping fractional action results in memory until the monthly flush changes
|
// Keeping fractional action results in memory until the monthly flush changes
|
||||||
// later aggregation (notably nation power), even if the eventual DB rows look
|
// later aggregation (notably nation power), even if the eventual DB rows look
|
||||||
// identical after they are rounded.
|
// identical after they are rounded.
|
||||||
const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({
|
export const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({
|
||||||
...general,
|
...general,
|
||||||
nationId: toLegacyDatabaseInt(general.nationId),
|
nationId: toLegacyDatabaseInt(general.nationId),
|
||||||
cityId: toLegacyDatabaseInt(general.cityId),
|
cityId: toLegacyDatabaseInt(general.cityId),
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } fr
|
|||||||
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js';
|
import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js';
|
||||||
|
import { normalizeGeneralDatabaseIntegers } from './inMemoryWorld.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js';
|
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js';
|
||||||
import type { ReservedTurnEntry } from './reservedTurnStore.js';
|
import type { ReservedTurnEntry } from './reservedTurnStore.js';
|
||||||
@@ -1691,6 +1692,12 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
nationAiState = ai.getDebugState();
|
nationAiState = ai.getDebugState();
|
||||||
}
|
}
|
||||||
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
||||||
|
// Ref persists a completed nation command before it chooses and
|
||||||
|
// executes the general command for the same turn. Preserve that
|
||||||
|
// MariaDB INT boundary so fractional rewards cannot leak into the
|
||||||
|
// following command or its AI refresh.
|
||||||
|
currentGeneral = normalizeGeneralDatabaseIntegers(currentGeneral);
|
||||||
|
worldOverlay?.syncGeneral(currentGeneral);
|
||||||
if (
|
if (
|
||||||
worldView &&
|
worldView &&
|
||||||
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
|
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
|
||||||
|
|||||||
@@ -629,6 +629,35 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => {
|
||||||
|
const ai = makeAi({
|
||||||
|
dipState: 2,
|
||||||
|
general: {
|
||||||
|
gold: 1_030,
|
||||||
|
rice: 970,
|
||||||
|
crew: 334,
|
||||||
|
crewTypeId: 1,
|
||||||
|
meta: {
|
||||||
|
killturn: 100,
|
||||||
|
fullLeadership: 70,
|
||||||
|
rank_killcrew: 1_000,
|
||||||
|
rank_deathcrew: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generalActionModules: singleActionModuleStack({
|
||||||
|
eventHandlers: {},
|
||||||
|
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||||
|
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
|
||||||
|
}),
|
||||||
|
rng: makeRng([], [0, 0]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ref prices only the 6,666 refillable soldiers: 800 gold, below the
|
||||||
|
// 820-gold reserve. It therefore keeps the full rice requirement and
|
||||||
|
// rejects recruitment, instead of halving both crew and rice cost.
|
||||||
|
expect(do징병(ai)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[0, 2000],
|
[0, 2000],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { NationTurnCommandSpec } from './index.js';
|
|||||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
|
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destCityID: z.preprocess(normalizeLegacyIntegerArg, z.number()),
|
destCityID: z.preprocess(normalizeLegacyIntegerArg, z.number()),
|
||||||
@@ -109,8 +110,11 @@ export class ActionDefinition<
|
|||||||
public readonly key = 'che_천도';
|
public readonly key = 'che_천도';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
public readonly countsAsInheritanceActiveAction = true;
|
public readonly countsAsInheritanceActiveAction = true;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
constructor(private readonly env: TurnCommandEnv) {}
|
constructor(private readonly env: TurnCommandEnv) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): MoveCapitalArgs | null {
|
parseArgs(raw: unknown): MoveCapitalArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
@@ -256,8 +260,9 @@ export class ActionDefinition<
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
general.experience += 5 * (dist * 2 + 1);
|
const reward = 5 * (dist * 2 + 1);
|
||||||
general.dedication += 5 * (dist * 2 + 1);
|
general.experience += this.pipeline.onCalcStat(context, 'experience', reward);
|
||||||
|
general.dedication += this.pipeline.onCalcStat(context, 'dedication', reward);
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,12 +343,22 @@ describe('Nation Actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('che_천도 (Move Capital)', () => {
|
describe('che_천도 (Move Capital)', () => {
|
||||||
it('changes nation capital city', () => {
|
it('changes nation capital city and applies general reward modules', () => {
|
||||||
const nation = buildNation(1);
|
const nation = buildNation(1);
|
||||||
const city1 = buildCity(1, 1);
|
const city1 = buildCity(1, 1);
|
||||||
const city2 = buildCity(2, 1);
|
const city2 = buildCity(2, 1);
|
||||||
const general = buildGeneral(1, 1, 1);
|
const general = buildGeneral(1, 1, 1);
|
||||||
const env = { develCost: 100, baseGold: 100, baseRice: 100 };
|
const env = {
|
||||||
|
develCost: 100,
|
||||||
|
baseGold: 100,
|
||||||
|
baseRice: 100,
|
||||||
|
generalActionModules: [
|
||||||
|
{
|
||||||
|
onCalcStat: (_context: unknown, statName: string, value: number) =>
|
||||||
|
statName === 'experience' ? value * 0.9 : value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
const definition = new MoveCapitalAction(env as any);
|
const definition = new MoveCapitalAction(env as any);
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
@@ -374,6 +384,8 @@ describe('Nation Actions', () => {
|
|||||||
patch: expect.objectContaining({ capitalCityId: 2 }),
|
patch: expect.objectContaining({ capitalCityId: 2 }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
expect(general.experience).toBe(113.5);
|
||||||
|
expect(general.dedication).toBe(115);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user