fix: match scenario 2601 monthly seed progression

This commit is contained in:
2026-08-03 18:56:23 +00:00
parent 2f17584075
commit 58b9a230a7
92 changed files with 3696 additions and 587 deletions
@@ -167,7 +167,11 @@ export class TurnDaemonLifecycle {
const nowMs = this.clock.nowMs();
const nextTurnMs = nextRunTime.getTime();
if (nowMs >= nextTurnMs) {
await this.runOnce({ reason: 'schedule', targetTime: nextRunTime });
// Ref checkDelay() executes every turn due at the observed
// wall-clock time in one snapshot. Using only the oldest due
// timestamp lets generals created by that batch run before a
// monthly boundary, although Ref defers them to the next pass.
await this.runOnce({ reason: 'schedule', targetTime: new Date(nowMs) });
continue;
}
@@ -195,9 +199,10 @@ export class TurnDaemonLifecycle {
const lastTurnTime = new Date(this.status.lastTurnTime);
const nextGeneralTurnTime = await this.stateStore.loadNextGeneralTurnTime();
const nextTickTime = this.getNextTickTime(lastTurnTime);
// 가장 빠른 장수 턴과 현재 틱 경계 먼저 오는 시각을 선택한다.
// 같은 시각이면 Ref처럼 월 경계 먼저 처리한다. 해당 장수는 월
// 처리 직후 다음 daemon pass에서 과거 due turn으로 실행된다.
const nextTurnTime =
nextGeneralTurnTime && nextGeneralTurnTime.getTime() <= nextTickTime.getTime()
nextGeneralTurnTime && nextGeneralTurnTime.getTime() < nextTickTime.getTime()
? nextGeneralTurnTime
: nextTickTime;
+38 -16
View File
@@ -153,7 +153,7 @@ const resolveKillturnFromDeathYear = (
if (!Number.isFinite(deathYear) || deathYear <= 0) {
return fallback;
}
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
const diff = (deathYear - currentYear) * 12 + (deathMonth - 1) + currentMonth - 1;
return Math.max(diff, 0);
};
@@ -209,18 +209,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const hiddenSeed =
integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex');
const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition,
map,
unitSet,
options: {
includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true,
hiddenSeed,
},
});
seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents);
const connector = createGamePostgresConnector({ url: options.databaseUrl });
const now = options.now ?? new Date();
const tickSeconds =
install?.turnTermMinutes !== undefined
@@ -229,6 +217,22 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
const sync = install?.sync ?? false;
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition,
map,
unitSet,
options: {
includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true,
hiddenSeed,
initialYear: startState.currentYear,
initialMonth: startState.currentMonth,
turnTermMinutes,
},
});
seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents);
const connector = createGamePostgresConnector({ url: options.databaseUrl });
const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
@@ -257,7 +261,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const worldMeta: Record<string, unknown> = {
scenarioId: options.scenarioId,
scenarioMeta: seed.scenarioMeta,
// ResetHelper persists the actual pre-opening calendar separately from
// scenario.startyear. NPC wandering-nation AI and founding constraints
// derive their opening-month gates from these values.
initYear: startState.currentYear,
initMonth: startState.currentMonth,
genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))),
// Ref seeds game_env.develcost before the first general turn. The
// monthly pre-handler recalculates the same value at each boundary.
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now),
opentime: formatDateTime(now),
@@ -493,6 +505,8 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience ?? 0,
dedication: general.dedication ?? 0,
officerLevel: general.officerLevel,
gold: generalGold,
rice: generalRice,
@@ -501,9 +515,17 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
weaponCode: general.weapon ?? 'None',
bookCode: general.book ?? 'None',
itemCode: general.item ?? 'None',
turnTime: now,
age: resolveGeneralAge(scenario.startYear ?? null, general.birthYear),
startAge: resolveGeneralAge(scenario.startYear ?? null, general.birthYear),
turnTime: new Date(
now.getTime() +
Math.floor(
(typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0) / 1_000
)
),
age: resolveGeneralAge(startState.currentYear, general.birthYear),
// Legacy GeneralBuilder leaves startage at the schema default on install.
startAge: 20,
personalCode: general.personality ?? 'None',
specialCode: general.special ?? 'None',
special2Code: general.specialWar ?? 'None',
+15
View File
@@ -85,6 +85,21 @@ export const parseYearMonth = (value: number): [number, number] => {
return [year, month];
};
export const withCanonicalArgumentAliases = (args: Record<string, unknown>): Record<string, unknown> => {
const normalized = { ...args };
for (const [legacyKey, canonicalKey] of [
['destCityID', 'destCityId'],
['destNationID', 'destNationId'],
['destGeneralID', 'destGeneralId'],
['destTroopID', 'destTroopId'],
] as const) {
if (normalized[canonicalKey] === undefined && normalized[legacyKey] !== undefined) {
normalized[canonicalKey] = normalized[legacyKey];
}
}
return normalized;
};
export const calcCityDevRatio = (city: City): number => {
const total = city.agriculture + city.commerce + city.security + city.defence + city.wall;
const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax;
+339 -16
View File
@@ -13,6 +13,7 @@ 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 { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
import type { TurnGeneral, TurnWorldState } from '../../types.js';
@@ -26,6 +27,7 @@ import {
readRequiredMetaNumber,
roundTo,
valueFit,
withCanonicalArgumentAliases,
} from '../aiUtils.js';
import { searchAllDistanceByNationList } from '../distance.js';
import { generalActionHandlers } from '../generalAiGeneralActions.js';
@@ -48,10 +50,36 @@ const d징병 = 2;
const d직전 = 3;
const d전쟁 = 4;
export const resolveLegacyAiStats = (
general: Pick<TurnGeneral, 'injury' | 'officerLevel' | 'stats'>,
nation: Nation | null | undefined,
maxStatLevel: number
) => {
const maxLevel = Math.max(1, maxStatLevel);
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
const injuryRatio = (100 - Math.max(0, Math.min(general.injury, 100))) / 100;
const nationLevel = nation?.level ?? 0;
const leadershipBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0;
const leadershipWithBonus = (value: number): number => clampStat(clampStat(value) + leadershipBonus);
const injuredLeadership = general.stats.leadership * injuryRatio;
const injuredStrength = general.stats.strength * injuryRatio;
const injuredIntelligence = general.stats.intelligence * injuryRatio;
return {
fullLeadership: leadershipWithBonus(general.stats.leadership),
fullStrength: clampStat(general.stats.strength + Math.round(general.stats.intelligence / 4)),
fullIntelligence: clampStat(general.stats.intelligence + Math.round(general.stats.strength / 4)),
effectiveLeadership: Math.trunc(leadershipWithBonus(injuredLeadership)),
effectiveStrength: Math.trunc(clampStat(injuredStrength + Math.round(injuredIntelligence / 4))),
effectiveIntelligence: Math.trunc(clampStat(injuredIntelligence + Math.round(injuredStrength / 4))),
};
};
export class GeneralAI {
public readonly general: TurnGeneral;
public readonly city?: City;
public readonly nation?: Nation | null;
public general: TurnGeneral;
public city?: City;
public nation?: Nation | null;
public readonly world: TurnWorldState;
public readonly worldRef: AiWorldView | null;
public readonly map?: MapDefinition;
@@ -125,11 +153,15 @@ export class GeneralAI {
private devRate: Record<string, number> | null = null;
private categorizedCities = false;
private categorizedGenerals = false;
private promotionPatches: Array<{ generalId: number; officerLevel: number; officerCity: number }> = [];
private promotionNationMeta: Record<string, unknown> | null = null;
private readonly initialGeneralMeta: Record<string, unknown>;
private readonly reservedTurnProvider: AiReservedTurnProvider;
constructor(options: GeneralAIOptions) {
this.general = { ...options.general, meta: { ...options.general.meta } };
this.initialGeneralMeta = { ...options.general.meta };
this.city = options.city;
const nation =
options.nation ??
@@ -160,7 +192,51 @@ export class GeneralAI {
this.world.currentMonth,
this.general.id
);
this.rng = new RandUtil(LiteHashDRBG.build(seed));
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_SEED_TRACE ${JSON.stringify({
generalId: this.general.id,
year: this.world.currentYear,
month: this.world.currentMonth,
seedHex: Buffer.from(seed).toString('hex'),
})}\n`
);
}
const baseRng = new RandUtil(LiteHashDRBG.build(seed));
const traceRng = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id));
let traceSequence = 0;
this.rng = traceRng
? new Proxy(baseRng, {
get: (target, property, receiver) => {
const value = Reflect.get(target, property, receiver);
if (typeof value !== 'function') return value;
return (...args: unknown[]) => {
const result = Reflect.apply(value, receiver, args);
if (
['nextFloat1', 'nextRangeInt', 'nextInt', 'nextBit', 'nextBool', 'choice', 'choiceUsingWeight', 'choiceUsingWeightPair'].includes(
String(property)
)
) {
process.stdout.write(
`AI_RNG_TRACE ${JSON.stringify({
generalId: this.general.id,
sequence: traceSequence++,
method: String(property),
caller: new Error().stack?.split('\n')[2]?.trim() ?? null,
result:
result === null || ['string', 'number', 'boolean'].includes(typeof result)
? result
: Array.isArray(result)
? `[array:${result.length}]`
: '[object]',
})}\n`
);
}
return result;
};
},
})
: baseRng;
const constValues = asRecord(this.scenarioConfig.const);
this.aiConst = {
@@ -176,7 +252,7 @@ export class GeneralAI {
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
availableNationTypes: Array.isArray(constValues.availableNationType)
? constValues.availableNationType.filter((value) => typeof value === 'string')
: [],
: NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'),
};
const generalPolicy = new AutorunGeneralPolicy(
@@ -223,6 +299,14 @@ export class GeneralAI {
this.categorizeNationCities();
this.categorizeNationGeneral();
if (this.general.npcState >= 2 && [3, 6, 9, 12].includes(this.world.currentMonth)) {
if (this.general.officerLevel === 12) {
this.chooseNpcPromotion();
} else {
this.chooseNonLordPromotion();
}
}
if (reservedTurn.action !== ACTION_REST) {
const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
if (reservedCandidate) {
@@ -243,6 +327,16 @@ export class GeneralAI {
}
const result = handler(this);
if (result) {
// Ref refreshes the cached AI state after these selected nation
// commands, before choosing the general command with the same
// RNG. The refresh includes another mixed-general type draw.
if (
['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes(
actionName
)
) {
this.reqUpdateInstance = true;
}
return result;
}
}
@@ -250,13 +344,47 @@ export class GeneralAI {
return this.buildNationCandidate(ACTION_REST, {}, 'neutral');
}
consumePromotionPatches(): {
generals: Array<{ generalId: number; officerLevel: number; officerCity: number }>;
nationMeta: Record<string, unknown> | null;
} {
const result = {
generals: this.promotionPatches,
nationMeta: this.promotionNationMeta,
};
this.promotionPatches = [];
this.promotionNationMeta = null;
return result;
}
consumePersistentGeneralMetaPatch(): { set: Record<string, unknown>; unset: string[] } {
const transientKeys = new Set([
'fullLeadership',
'fullStrength',
'fullIntelligence',
'effectiveLeadership',
'effectiveStrength',
'effectiveIntelligence',
]);
const set: Record<string, unknown> = {};
for (const [key, value] of Object.entries(this.general.meta)) {
if (transientKeys.has(key) || Object.is(this.initialGeneralMeta[key], value)) continue;
set[key] = value;
}
const unset = Object.keys(this.initialGeneralMeta).filter(
(key) => !transientKeys.has(key) && !Object.prototype.hasOwnProperty.call(this.general.meta, key)
);
return { set, unset };
}
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance();
if (!this.worldRef) {
return null;
}
const npcMessage = asRecord(this.general.meta).npcmsg;
const generalMeta = asRecord(this.general.meta);
const npcMessage = generalMeta.npcmsg ?? generalMeta.text;
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
}
@@ -336,7 +464,13 @@ export class GeneralAI {
}
for (const actionName of this.generalPolicy.priority) {
if (!this.generalPolicy.can(actionName)) {
const allowed = this.generalPolicy.can(actionName);
if (!allowed) {
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result: null })}\n`
);
}
continue;
}
const handler = generalActionHandlers[actionName];
@@ -344,6 +478,11 @@ export class GeneralAI {
continue;
}
const result = handler(this);
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result })}\n`
);
}
if (result) {
return result;
}
@@ -354,7 +493,6 @@ export class GeneralAI {
}
getDebugState(): GeneralAiDebugState {
this.updateInstance();
this.categorizeNationCities();
const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth);
const startYearMonth = joinYearMonth(this.startYear + 2, 5);
@@ -626,10 +764,35 @@ export class GeneralAI {
}
this.reqUpdateInstance = false;
const nation = this.nation;
if (!nation) {
return;
const refreshedGeneral = this.worldRef?.getGeneralById(this.general.id);
if (refreshedGeneral) {
this.general = { ...refreshedGeneral, meta: { ...refreshedGeneral.meta } };
}
const refreshedCity = this.worldRef?.getCityById(this.general.cityId);
if (refreshedCity) {
this.city = refreshedCity;
}
const refreshedNation =
this.general.nationId > 0 ? (this.worldRef?.getNationById(this.general.nationId) ?? null) : null;
if (refreshedNation !== undefined) {
this.nation = refreshedNation ? { ...refreshedNation, meta: { ...refreshedNation.meta } } : refreshedNation;
}
const nation =
this.nation ??
({
id: 0,
name: '재야',
color: '#000000',
capitalCityId: null,
chiefGeneralId: null,
gold: 0,
rice: 0,
power: 0,
level: 0,
typeCode: 'neutral',
meta: {},
} satisfies Nation);
const baseDevelCost = this.commandEnv.develCost * 12;
const nationMeta = asRecord(nation.meta);
@@ -654,6 +817,7 @@ export class GeneralAI {
}
this.calcDiplomacyState();
this.refreshLegacyFullStats();
this.genType = this.calcGenType();
void baseDevelCost;
@@ -671,16 +835,17 @@ export class GeneralAI {
if (parsedArgs === null) {
return null;
}
const constraintArgs = withCanonicalArgumentAliases(parsedArgs as Record<string, unknown>);
const constraintEnv = this.buildConstraintEnv();
const ctx: ConstraintContext = {
actorId: this.general.id,
cityId: this.city?.id,
nationId: this.general.nationId,
args: parsedArgs as Record<string, unknown>,
args: constraintArgs,
env: constraintEnv,
mode: 'full',
};
const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record<string, unknown>, {
const view = new WorldStateView(this.worldRef, constraintEnv, constraintArgs, {
general: this.general,
city: this.city,
nation: this.nation ?? null,
@@ -688,6 +853,11 @@ export class GeneralAI {
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
const result = evaluateConstraints(constraints, ctx, view);
if (result.kind !== 'allow') {
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_GENERAL_CONSTRAINT_TRACE ${JSON.stringify({ generalId: this.general.id, action, args: parsedArgs, result })}\n`
);
}
return null;
}
return {
@@ -708,9 +878,10 @@ export class GeneralAI {
}
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);
const meta = asRecord(this.general.meta);
const leadership = readMetaNumber(meta, 'fullLeadership', this.general.stats.leadership);
const strength = Math.max(readMetaNumber(meta, 'fullStrength', this.general.stats.strength), 1);
const intel = Math.max(readMetaNumber(meta, 'fullIntelligence', this.general.stats.intelligence), 1);
let genType: number;
if (strength >= intel) {
@@ -736,6 +907,153 @@ export class GeneralAI {
return genType;
}
private refreshLegacyFullStats(): void {
const stats = resolveLegacyAiStats(
this.general,
this.nation,
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
);
this.general.meta = {
...this.general.meta,
...stats,
};
}
private chooseNpcPromotion(): void {
if (!this.nation || !this.worldRef) {
return;
}
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
const generals = this.worldRef
.listGenerals()
.filter((candidate) => candidate.nationId === this.nation!.id)
.sort((left, right) => {
const leftScore = left.stats.leadership * 2 + left.stats.strength + left.stats.intelligence;
const rightScore = right.stats.leadership * 2 + right.stats.strength + right.stats.intelligence;
// Ref's nation query returns primary-key order and uasort keeps
// that order when the raw-stat score ties.
return rightScore - leftScore || left.id - right.id;
});
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
for (let chiefLevel = 11; chiefLevel >= minChiefLevel; chiefLevel -= 1) {
if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) {
continue;
}
const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel);
if (oldChief) {
const newChiefProbability = this.rng.nextBool(0.1) ? 1 : 0;
// GeneralAI.php performs a second nextBool(0) call on the
// rejection path. Preserve that consumption for the shared
// nation/general AI RNG stream.
if (newChiefProbability < 1 && !this.rng.nextBool(newChiefProbability)) {
continue;
}
}
const nextChief = generals.find((candidate) => {
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4 || candidate.npcState < 2) {
return false;
}
const killturn = readRequiredMetaNumber(
asRecord(candidate.meta),
'killturn',
`generalId=${candidate.id}`
);
if (killturn < 36) {
return false;
}
if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) {
return false;
}
if (
chiefLevel !== 11 &&
chiefLevel % 2 === 1 &&
candidate.stats.intelligence < this.aiConst.chiefStatMin
) {
return false;
}
return true;
});
if (!nextChief) {
continue;
}
if (oldChief) {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
}
this.promotionPatches.push({ generalId: nextChief.id, officerLevel: chiefLevel, officerCity: 0 });
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
process.stdout.write(
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'lord', actor: this.general.id, chiefLevel, picked: nextChief.id })}\n`
);
}
effectiveOfficerLevel.set(nextChief.id, chiefLevel);
chiefSet |= 1 << chiefLevel;
}
if (this.promotionPatches.length > 0) {
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
}
}
private chooseNonLordPromotion(): void {
if (!this.nation) {
return;
}
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
const pools = [this.npcWarGenerals, this.npcCivilGenerals, this.userWarGenerals, this.userCivilGenerals];
for (let chiefLevel = minChiefLevel; chiefLevel <= 12; chiefLevel += 1) {
if (
(chiefSet & (1 << chiefLevel)) !== 0 ||
this.chiefGenerals[chiefLevel] ||
this.general.officerLevel === chiefLevel
) {
continue;
}
let picked: TurnGeneral | null = null;
for (let trial = 0; trial < 5; trial += 1) {
const pool = pools.find((candidatePool) => Object.keys(candidatePool).length > 0);
if (!pool) {
break;
}
const candidate = this.rng.choice(Object.values(pool));
if (candidate.officerLevel !== 1) {
continue;
}
if (
chiefLevel !== 11 &&
((chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) ||
(chiefLevel % 2 === 1 && candidate.stats.intelligence < this.aiConst.chiefStatMin))
) {
continue;
}
picked = candidate;
break;
}
if (!picked) {
continue;
}
picked.officerLevel = chiefLevel;
picked.meta = { ...picked.meta, officer_city: 0 };
this.promotionPatches.push({ generalId: picked.id, officerLevel: chiefLevel, officerCity: 0 });
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
process.stdout.write(
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'non-lord', actor: this.general.id, chiefLevel, picked: picked.id })}\n`
);
}
this.chiefGenerals[chiefLevel] = picked;
chiefSet |= 1 << chiefLevel;
}
if (this.promotionPatches.length > 0) {
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
}
}
private calcDiplomacyState(): void {
if (!this.nation || !this.worldRef) {
return;
@@ -815,6 +1133,11 @@ export class GeneralAI {
}
// legacy GeneralAI.php 기준: 평화/선포 상태에서 병력 보유 여부로 d징병 전환하지 않음.
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
process.stdout.write(
`AI_DIPLOMACY_TRACE ${JSON.stringify({ generalId: this.general.id, warTargets, dipState: this.dipState })}\n`
);
}
}
private calcRecentWarTurn(general: TurnGeneral): number {
@@ -27,6 +27,10 @@ export const do일반내정 = (ai: GeneralAI) => {
}
const develRate = ai.calcCityDevelRate(city);
const generalMeta = asRecord(ai.general.meta);
const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership);
const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength);
const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -35,18 +39,18 @@ export const do일반내정 = (ai: GeneralAI) => {
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,
(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),
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),
leadership / valueFit(develRate.pop[0] / 4, 0.001),
]);
}
}
@@ -55,51 +59,69 @@ export const do일반내정 = (ai: GeneralAI) => {
if (develRate.def[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'),
ai.general.stats.strength / valueFit(develRate.def[0], 0.001),
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),
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),
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),
strength / 2 / valueFit(develRate.secu[0], 0.001),
]);
}
}
if (ai.genType & t지장) {
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 2000)
: ai.general.stats.intelligence;
// PHP's `%` coerces the FLOAT nation tech value to an integer
// before taking the remainder. Keeping the fraction here can move
// a weighted draw across a candidate boundary.
const nextTech = (Math.trunc(tech) % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 2000) : intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), weight]);
}
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),
((isSpringSummer ? 1.2 : 0.8) * 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),
((isSpringSummer ? 0.8 : 1.2) * intelligence) / valueFit(develRate.comm[0], 0.001, 1),
]);
}
}
return pickWeightedCandidate(ai, cmdList);
const picked = pickWeightedCandidate(ai, cmdList);
const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? [];
if (traceGeneralIds.includes(String(ai.general.id))) {
process.stdout.write(
`AI_DEVEL_TRACE ${JSON.stringify({
generalId: ai.general.id,
year: ai.world.currentYear,
month: ai.world.currentMonth,
genType: ai.genType,
city,
candidates: cmdList.flatMap(([candidate, weight]) =>
candidate ? [{ action: candidate.action, weight }] : []
),
picked: picked?.action ?? null,
})}\n`
);
}
return picked;
};
export const do긴급내정 = (ai: GeneralAI) => {
@@ -111,12 +133,13 @@ export const do긴급내정 = (ai: GeneralAI) => {
return null;
}
const trust = resolveCityTrust(city);
if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) {
const leadership = readMetaNumber(asRecord(ai.general.meta), 'effectiveLeadership', ai.general.stats.leadership);
if (trust < 70 && ai.rng.nextBool(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)
ai.rng.nextBool(leadership / ai.aiConst.chiefStatMin / 2)
) {
return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정');
}
@@ -139,6 +162,10 @@ export const do전쟁내정 = (ai: GeneralAI) => {
return null;
}
const develRate = ai.calcCityDevelRate(city);
const generalMeta = asRecord(ai.general.meta);
const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership);
const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength);
const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -147,13 +174,13 @@ export const do전쟁내정 = (ai: GeneralAI) => {
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,
(leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
]);
}
if (develRate.pop[0] < 0.8) {
const weight = [1, 3].includes(city.frontState)
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
? leadership / valueFit(develRate.pop[0], 0.001)
: leadership / valueFit(develRate.pop[0], 0.001) / 2;
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
}
}
@@ -162,52 +189,62 @@ export const do전쟁내정 = (ai: GeneralAI) => {
if (develRate.def[0] < 0.5) {
cmdList.push([
ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'),
ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2,
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,
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,
strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4,
]);
}
}
if (ai.genType & t지장) {
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 3000)
: ai.general.stats.intelligence;
const nextTech = (Math.trunc(tech) % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 3000) : intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), weight]);
}
if (develRate.agri[0] < 0.5) {
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.agri[0], 0.001, 1);
? ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 4 / valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 2 / valueFit(develRate.agri[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
}
if (develRate.comm[0] < 0.5) {
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.comm[0], 0.001, 1);
? ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 4 / valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 2 / valueFit(develRate.comm[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
}
}
return pickWeightedCandidate(ai, cmdList);
const picked = pickWeightedCandidate(ai, cmdList);
const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? [];
if (traceGeneralIds.includes(String(ai.general.id))) {
process.stdout.write(
`AI_DEVEL_TRACE ${JSON.stringify({
generalId: ai.general.id,
year: ai.world.currentYear,
month: ai.world.currentMonth,
mode: '전쟁내정',
genType: ai.genType,
dipState: ai.dipState,
city,
nationTech: tech,
candidates: cmdList.flatMap(([candidate, weight]) =>
candidate ? [{ action: candidate.action, weight }] : []
),
picked: picked?.action ?? null,
})}\n`
);
}
return picked;
};
@@ -1,11 +1,22 @@
import { GeneralActionPipeline } from '@sammo-ts/logic';
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 traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
const trace = (stage: string, values: Record<string, unknown> = {}) => {
if (!traceEnabled) {
return;
}
process.stderr.write(
`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
);
};
const city = ai.city;
if (!city) {
trace('no-city');
return null;
}
@@ -14,8 +25,9 @@ export const do금쌀구매 = (ai: GeneralAI) => {
return null;
}
const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000;
const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000;
const generalMeta = asRecord(ai.general.meta);
const kill = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)) + 50000;
const death = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)) + 50000;
const deathRate = death / kill;
const absGold = ai.general.gold;
@@ -24,16 +36,64 @@ export const do금쌀구매 = (ai: GeneralAI) => {
const relRice = absRice * deathRate;
const baseDevelCost = ai.commandEnv.develCost * 12;
trace('resources', {
absGold,
absRice,
relGold,
relRice,
deathRate,
baseDevelCost,
canIgnoreTrader: ai.generalPolicy.can('상인무시'),
trade,
});
if (absGold + absRice < baseDevelCost * 2) {
trace('insufficient-base-resource');
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 fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
const crewAmount = fullLeadership * 100;
const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
const riceCost = crewAmount / 100;
const rawGoldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const goldCost = Math.round(
actionPipeline.onCalcDomestic(
{
general: ai.general,
nation: ai.nation ?? undefined,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
},
'징병',
'cost',
rawGoldCost,
{ armType: crewType?.armType ?? 0 }
) * (ai.generalPolicy.can('모병') ? 2 : 1)
);
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
trace('recruit-cost', {
crewTypeId: crewType?.id ?? null,
crewCost: crewType?.cost ?? null,
crewRice: crewType?.rice ?? null,
tech,
crewAmount,
goldCost,
riceCost,
});
if ((relGold + relRice) * 1.5 <= goldCost + riceCost) {
return null;
@@ -55,9 +115,15 @@ export const do금쌀구매 = (ai: GeneralAI) => {
}
if (tryBuying) {
const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
const amount = valueFit(
Math.floor((relGold - relRice) / (1 + deathRate)),
100,
ai.aiConst.maxResourceActionAmount
);
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
const result = ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
trace('buy', { amount, minimumResourceActionAmount: ai.nationPolicy.minimumResourceActionAmount, result });
return result;
}
}
@@ -73,7 +139,11 @@ export const do금쌀구매 = (ai: GeneralAI) => {
}
if (trySelling) {
const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
const amount = valueFit(
Math.floor((relRice - relGold) / (1 + deathRate)),
100,
ai.aiConst.maxResourceActionAmount
);
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
}
@@ -1,4 +1,4 @@
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
import { searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
@@ -23,7 +23,8 @@ export const do국가선택 = (ai: GeneralAI) => {
return null;
}
if (ai.world.currentYear < ai.startYear + 3) {
const nations = ai.worldRef.listNations();
// Ref queries the nation table, which has no synthetic neutral row.
const nations = ai.worldRef.listNations().filter((nation) => nation.id > 0);
const nationCount = nations.length;
const notFullNationCount = nations.filter((nation) => {
const count = ai.worldRef!.listGenerals().filter((general) => general.nationId === nation.id).length;
@@ -104,18 +105,16 @@ export const do거병 = (ai: GeneralAI) => {
.map((c) => c.id)
);
for (const general of ai.worldRef.listGenerals()) {
if (general.officerLevel === 12 && general.nationId === 0) {
// Ref joins through city and checks city.nation=0. A ruler of a newly
// raised wandering nation therefore still occupies its neutral city.
if (general.officerLevel === 12 && ai.worldRef.getCityById(general.cityId)?.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;
}
const nearby = searchDistanceEntries(ai.map, ai.general.cityId, 3);
for (const [cityId, dist] of nearby) {
if (occupied.has(cityId)) {
continue;
}
@@ -159,7 +158,10 @@ export const do건국 = (ai: GeneralAI) => {
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
: 'che_도적';
const colorType = ai.rng.nextRangeInt(0, 32);
const nationName = `${Array.from(ai.general.name).slice(1).join('')}`;
// Ref stores the NPC display prefix in general.name and removes it here.
// Core's installed scenario rows keep the prefix in npcState instead.
const characters = Array.from(ai.general.name);
const nationName = `${characters[0] === 'ⓝ' ? characters.slice(1).join('') : ai.general.name}`;
const result = ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
if (result) {
@@ -198,13 +200,13 @@ export const do방랑군이동 = (ai: GeneralAI) => {
if (!city || !ai.map || !ai.worldRef) {
return null;
}
const lordCities = ai.worldRef
.listGenerals()
.filter((general) => general.officerLevel === 12 && general.nationId === 0)
.map((general) => general.cityId);
if (lordCities.filter((cityId) => cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
const rulers = ai.worldRef.listGenerals().filter((general) => general.officerLevel === 12);
if (rulers.filter((general) => general.cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
return null;
}
const lordCities = rulers
.filter((general) => ai.worldRef!.getCityById(general.cityId)?.nationId === 0)
.map((general) => general.cityId);
const occupied = new Set(
ai.worldRef
@@ -222,11 +224,10 @@ export const do방랑군이동 = (ai: GeneralAI) => {
}
if (movingTargetCityId === null) {
const nearby = searchDistance(ai.map, city.id, 4);
const nearby = searchDistanceEntries(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)) {
for (const [cityId, dist] of nearby) {
if (occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
@@ -6,6 +6,7 @@ import {
} from '@sammo-ts/logic/world/unitSet.js';
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
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 { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
@@ -40,26 +41,46 @@ const getRequiredTech = (crewType: CrewTypeDefinition): number | null => {
};
export const do징병 = (ai: GeneralAI) => {
const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
const trace = (stage: string, values: Record<string, unknown> = {}) => {
if (traceEnabled) {
process.stdout.write(
`AI_RECRUIT_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
);
}
};
const city = ai.city;
const nation = ai.nation;
if (!city || !nation || !ai.unitSet || !ai.map) {
trace('missing-context');
return null;
}
if ([0, 1].includes(ai.dipState)) {
trace('diplomacy', { dipState: ai.dipState });
return null;
}
if (!(ai.genType & t통솔장)) {
trace('general-type', { genType: ai.genType });
return null;
}
if (ai.general.crew >= ai.nationPolicy.minWarCrew) {
trace('existing-crew', { crew: ai.general.crew, minWarCrew: ai.nationPolicy.minWarCrew });
return null;
}
const generalMeta = asRecord(ai.general.meta);
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
trace('population-policy', {
population: city.population,
populationMax: city.populationMax,
safeRatio: ai.nationPolicy.safeRecruitCityPopulationRatio,
minPopulation: ai.nationPolicy.minNpcRecruitCityPopulation,
canLimitRecruit: ai.generalPolicy.can('한계징병'),
});
if (!ai.generalPolicy.can('한계징병')) {
const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100;
if (remainPop <= 0) {
trace('population-floor', { remainPop, fullLeadership });
return null;
}
const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation;
@@ -67,6 +88,7 @@ export const do징병 = (ai: GeneralAI) => {
city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio &&
ai.rng.nextBool(remainPop / Math.max(1, maxPop))
) {
trace('population-random', { remainPop, maxPop, fullLeadership });
return null;
}
}
@@ -82,10 +104,22 @@ export const do징병 = (ai: GeneralAI) => {
) {
forcedArmType = 0;
}
const armType =
forcedArmType > 0
? forcedArmType
: ai.rng.choiceUsingWeightPair(buildRecruitArmTypeWeights(ai.general, warConfig.armTypes));
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
let armTypeDraw: number | null = null;
const armType = forcedArmType > 0
? forcedArmType
: traceEnabled
? (() => {
armTypeDraw = ai.rng.nextFloat1();
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
for (const [candidate, weight] of armTypeWeights) {
if (cursor <= weight) return candidate;
cursor -= Math.max(0, weight);
}
return armTypeWeights.at(-1)![0];
})()
: ai.rng.choiceUsingWeightPair(armTypeWeights);
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
const candidates = (ai.unitSet?.crewTypes ?? [])
.filter((crew) => crew.armType === armType)
@@ -100,11 +134,17 @@ export const do징병 = (ai: GeneralAI) => {
})
);
if (candidates.length === 0) {
trace('no-crew-type', { armType });
return null;
}
let picked = ai.rng.choiceUsingWeightPair(
candidates.map((crew) => [crew, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)])
);
trace('crew-type', {
armType,
candidates: candidates.map((crew) => [crew.id, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]),
picked: picked.id,
});
if (ai.generalPolicy.can('고급병종')) {
const currentCrewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId);
if (
@@ -130,7 +170,39 @@ export const do징병 = (ai: GeneralAI) => {
const crewTypeId = picked.id;
let crewAmount = crewAmountBase;
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
// Ref asks the concrete che_징병 command for getCost() before deciding
// whether to halve the requested crew. That path includes personality,
// traits, items, and the final integer rounding; using the raw unit price
// makes che_출세 (+20% cost) recruit a full stack incorrectly.
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const goldCost = Math.round(
actionPipeline.onCalcDomestic(
{
general: ai.general,
nation,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
},
'징병',
'cost',
rawGoldCost,
{ armType: picked.armType }
)
);
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
@@ -139,6 +211,7 @@ export const do징병 = (ai: GeneralAI) => {
const remainingGold = ai.general.gold - fullLeadership * 3;
const remainingRice = ai.general.rice - fullLeadership * 4;
if (remainingGold <= 0 || remainingRice <= 0) {
trace('reserve-floor', { remainingGold, remainingRice, fullLeadership });
return null;
}
@@ -156,8 +229,18 @@ export const do징병 = (ai: GeneralAI) => {
}
if (!ai.generalPolicy.can('한계징병') && remainingRice * 1.1 <= riceCost) {
trace('rice-cost', { remainingGold, remainingRice, goldCost, riceCost, crewAmount, crewTypeId });
return null;
}
return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
const result = ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
trace(result ? 'selected' : 'constraint', {
remainingGold,
remainingRice,
goldCost,
riceCost,
crewAmount,
crewTypeId,
});
return result;
};
@@ -1,5 +1,5 @@
import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js';
export const do후방워프 = (ai: GeneralAI) => {
@@ -20,11 +20,13 @@ export const do후방워프 = (ai: GeneralAI) => {
return null;
}
let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
// Ref uses getLeadership(false): item/officer bonuses included, injury ignored.
const fullLeadership = readMetaNumber(asRecord(ai.general.meta), 'fullLeadership', ai.general.stats.leadership);
let minRecruitPop = fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop;
if (!ai.generalPolicy.can('한계징병')) {
minRecruitPop = Math.max(
minRecruitPop,
ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
);
}
@@ -82,6 +84,12 @@ export const do후방워프 = (ai: GeneralAI) => {
return null;
}
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
process.stdout.write(
`AI_WARP_TRACE ${JSON.stringify({ generalId: ai.general.id, kind: 'rear', fullLeadership, minRecruitPop, recruitable })}\n`
);
}
return ai.buildGeneralCandidate(
'che_NPC능동',
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) },
@@ -158,7 +166,6 @@ export const do내정워프 = (ai: GeneralAI) => {
}
ai.categorizeNationCities();
ai.categorizeNationGeneral();
const candidateCities: Record<number, number> = {};
for (const candidate of Object.values(ai.supplyCities)) {
if (candidate.id === city.id) {
@@ -1,5 +1,6 @@
import type { GeneralAI } from '../../core.js';
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio, selectRecruitableCity } from '../helpers.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js';
export const doNPC후방발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
@@ -12,6 +13,26 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
return null;
}
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const actionContext = (general: GeneralAI['general']) => ({
general,
nation: ai.nation,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
});
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
if (general.id === ai.general.id) {
return false;
@@ -29,16 +50,56 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
if (actionPipeline.onCalcDomestic(actionContext(general), '징집인구', 'score', 100) <= 1) {
return false;
}
return true;
});
if (candidates.length === 0) {
return null;
}
if (Object.keys(ai.supplyCities).length === 1) {
return null;
}
const picked = ai.rng.choice(candidates);
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
const destCityCandidates = selectRecruitableCity(ai, minPop);
const fullLeadership = actionPipeline.onCalcStat(
actionContext(picked),
'leadership',
picked.stats.leadership
) as number;
const minPop = Math.max(
fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop,
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
);
const destCityCandidates: Record<number, number> = {};
for (const city of Object.values(ai.backupCities)) {
const ratio = resolveCityPopRatio(city);
if (
city.id !== ai.city?.id &&
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
city.population >= minPop &&
ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio
) {
destCityCandidates[city.id] = ratio;
}
}
if (Object.keys(destCityCandidates).length === 0) {
for (const city of Object.values(ai.supplyCities)) {
const ratio = resolveCityPopRatio(city);
if (
city.id !== ai.city?.id &&
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
city.population > minPop &&
ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio
) {
// Ref contains a non-assigning `pop_ratio / 2` expression for
// front cities, so the persisted behavior keeps this weight.
destCityCandidates[city.id] = ratio;
}
}
}
if (Object.keys(destCityCandidates).length === 0) {
return null;
}
@@ -102,12 +163,14 @@ export const doNPC전방발령 = (ai: GeneralAI) => {
return null;
}
// Ref consumes the general draw before the weighted front-city draw.
// Reversing these two calls keeps the seed but assigns a different person.
const destGeneral = ai.rng.choice(candidates);
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전방발령');
};
@@ -2,9 +2,40 @@ import type { GeneralAI } from '../core.js';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral } from '../../../types.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate } from './helpers.js';
import { buildAwardCandidate, buildSeizureCandidate } from './helpers.js';
type ResourceName = 'gold' | 'rice';
type ResourceCandidate = [{ destGeneralId: number; amount: number; isGold: boolean }, number];
const pickResourceCandidate = (
ai: GeneralAI,
action: 'award' | 'seizure',
candidates: ResourceCandidate[],
reason: string
) => {
if (candidates.length === 0) {
return null;
}
// Ref chooses the raw argument tuple first and only then checks the
// command's full constraints. The draw is therefore observable even
// when the selected command is rejected. Building/filtering every
// command before the draw shifts the shared nation/general AI stream.
const selected = ai.rng.choiceUsingWeightPair(candidates);
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
process.stdout.write(
`AI_REWARD_SELECTION_TRACE ${JSON.stringify({
engine: 'core',
actor: ai.general.id,
reason,
candidates,
selected,
})}\n`
);
}
return action === 'award'
? buildAwardCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason)
: buildSeizureCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason);
};
const clampLegacy = (value: number, min: number | null, max: number | null): number => {
if (min !== null && max !== null && max < min) {
@@ -13,13 +44,30 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num
return Math.max(min ?? -Infinity, Math.min(max ?? Infinity, value));
};
const getFullLeadership = (general: TurnGeneral): number =>
readMetaNumber(asRecord(general.meta), 'fullLeadership', general.stats.leadership);
const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
const nationLevel = ai.nation?.level ?? 0;
const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0;
const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max;
return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat));
};
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, multiplier: number): number => {
const getCrewGoldCost = (
ai: GeneralAI,
general: TurnGeneral,
baseMultiplier: number,
finalMultiplier = 1
): number => {
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
return (crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(general) * multiplier;
// Ref evaluates costWithTech() first, including its `/ 100`, and then
// applies `* 100 * baseMultiplier * finalMultiplier` from GeneralAI.
// Keeping that operation order is observable at exact resource boundaries
// (for example 3036 versus 3036.0000000000005).
return (
((((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * 100 *
baseMultiplier) *
finalMultiplier
);
};
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
@@ -35,7 +83,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number]> = [
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
@@ -50,7 +98,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
if (!canUseGeneral(general)) {
continue;
}
let required = getCrewGoldCost(ai, general, 3 * 1.1);
let required = getCrewGoldCost(ai, general, 3, 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, minimum);
}
@@ -64,14 +112,11 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'),
generals.length - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, '유저장긴급포상');
};
export const do유저장포상 = (ai: GeneralAI) => {
@@ -79,7 +124,7 @@ export const do유저장포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number, number]> = [
[
'gold',
@@ -109,7 +154,7 @@ export const do유저장포상 = (ai: GeneralAI) => {
}
let enough: number;
if (ai.userWarGenerals[general.id]) {
let required = getCrewGoldCost(ai, general, 6 * 1.1);
let required = getCrewGoldCost(ai, general, 6, 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, warMinimum);
}
@@ -126,14 +171,11 @@ export const do유저장포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'),
generals.length - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, '유저장포상');
};
export const doNPC긴급포상 = (ai: GeneralAI) => {
@@ -141,7 +183,7 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
@@ -173,14 +215,11 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'),
generals.length - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, 'NPC긴급포상');
};
export const doNPC포상 = (ai: GeneralAI) => {
@@ -188,7 +227,7 @@ export const doNPC포상 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
@@ -208,7 +247,7 @@ export const doNPC포상 = (ai: GeneralAI) => {
if (!canUseGeneral(general)) {
continue;
}
let required = getCrewGoldCost(ai, general, 3 * 1.1);
let required = getCrewGoldCost(ai, general, 3, 1.1);
if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, warMinimum);
}
@@ -222,10 +261,23 @@ export const doNPC포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
process.stdout.write(
`AI_REWARD_TRACE ${JSON.stringify({
engine: 'core',
actor: ai.general.id,
target: general.id,
resource: resKey,
nationResource: nation[resKey],
targetResource: general[resKey],
required,
enough,
maxResourceActionAmount: ai.maxResourceActionAmount,
amount,
})}\n`
);
}
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]);
}
for (const [index, general] of civilGenerals.entries()) {
if (general[resKey] >= civilMinimum) {
@@ -239,14 +291,11 @@ export const doNPC포상 = (ai: GeneralAI) => {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'award', candidates, 'NPC포상');
};
export const doNPC몰수 = (ai: GeneralAI) => {
@@ -254,7 +303,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
if (!nation) {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const candidates: ResourceCandidate[] = [];
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
@@ -269,7 +318,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
break;
}
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]);
}
const nationDelta = nationMinimum * 1.5 - nation[resKey];
@@ -294,9 +343,9 @@ export const doNPC몰수 = (ai: GeneralAI) => {
break;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]);
}
}
return pickWeightedCandidate(ai, candidates);
return pickResourceCandidate(ai, 'seizure', candidates, 'NPC몰수');
};
+8 -6
View File
@@ -474,9 +474,11 @@ const buildNationUpdate = (
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? Math.trunc(nation.meta.tech) : 0,
gold: toLegacyDatabaseInt(nation.gold),
rice: toLegacyDatabaseInt(nation.rice),
// Ref persists nation.tech as FLOAT. Domestic research commonly produces
// tenths, and truncating here changes the following month's state/power.
tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? nation.meta.tech : 0,
level: nation.level,
typeCode: nation.typeCode,
meta: asJson({
@@ -755,11 +757,11 @@ export const createDatabaseTurnHooks = async (
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
gold: toLegacyDatabaseInt(nation.gold),
rice: toLegacyDatabaseInt(nation.rice),
tech:
typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech)
? Math.trunc(nation.meta.tech)
? nation.meta.tech
: 0,
level: nation.level,
typeCode: nation.typeCode,
@@ -57,7 +57,18 @@ export class InMemoryTurnProcessor implements TurnProcessor {
let generalPartial = false;
let nextCheckpoint: TurnCheckpoint | undefined = undefined;
const dueGenerals = this.world.listDueGenerals(targetTime, checkpoint);
const previousLastTurnTime = this.world.getState().lastTurnTime;
const firstTickTime = getNextTickTime(previousLastTurnTime, this.tickMinutes);
// Ref processes `turntime < monthlyBoundary` before the monthly turn. A
// general exactly on the boundary therefore runs only after that month
// has advanced, on the daemon's following pass.
const useStrictGeneralCutoff =
firstTickTime.getTime() === targetTime.getTime() || targetTime.getTime() <= previousLastTurnTime.getTime();
const generalCutoff =
useStrictGeneralCutoff
? new Date(targetTime.getTime() - 1)
: targetTime;
const dueGenerals = this.world.listDueGenerals(generalCutoff, checkpoint);
for (const general of dueGenerals) {
if (processedGenerals >= budget.maxGenerals || isBudgetExpired()) {
partial = true;
+72 -11
View File
@@ -233,15 +233,76 @@ const mergeTriggerState = (
meta: { ...base.meta, ...(patch.meta ?? {}) },
});
const applyGeneralPatch = (base: TurnGeneral, patch: Partial<TurnGeneral>): TurnGeneral => ({
...base,
...patch,
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats,
role: patch.role ? mergeRole(base.role, patch.role) : base.role,
triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
const toLegacyDatabaseInt = (value: number): number => {
if (!Number.isFinite(value)) return 0;
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const LEGACY_INTEGER_GENERAL_META_KEYS = [
'leadership_exp',
'strength_exp',
'intel_exp',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
'explevel',
'dedlevel',
'killturn',
'myset',
] as const;
const normalizeGeneralMetaDatabaseIntegers = (meta: TurnGeneral['meta']): TurnGeneral['meta'] => {
const normalized = { ...meta };
for (const key of LEGACY_INTEGER_GENERAL_META_KEYS) {
const value = normalized[key];
if (typeof value === 'number') {
normalized[key] = toLegacyDatabaseInt(value);
}
}
return normalized;
};
// Ref writes these values to MariaDB integer columns after every general turn.
// Keeping fractional action results in memory until the monthly flush changes
// later aggregation (notably nation power), even if the eventual DB rows look
// identical after they are rounded.
const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({
...general,
nationId: toLegacyDatabaseInt(general.nationId),
cityId: toLegacyDatabaseInt(general.cityId),
troopId: toLegacyDatabaseInt(general.troopId),
stats: {
leadership: toLegacyDatabaseInt(general.stats.leadership),
strength: toLegacyDatabaseInt(general.stats.strength),
intelligence: toLegacyDatabaseInt(general.stats.intelligence),
},
experience: toLegacyDatabaseInt(general.experience),
dedication: toLegacyDatabaseInt(general.dedication),
officerLevel: toLegacyDatabaseInt(general.officerLevel),
injury: toLegacyDatabaseInt(general.injury),
gold: toLegacyDatabaseInt(general.gold),
rice: toLegacyDatabaseInt(general.rice),
crew: toLegacyDatabaseInt(general.crew),
crewTypeId: toLegacyDatabaseInt(general.crewTypeId),
train: toLegacyDatabaseInt(general.train),
atmos: toLegacyDatabaseInt(general.atmos),
age: toLegacyDatabaseInt(general.age),
npcState: toLegacyDatabaseInt(general.npcState),
meta: normalizeGeneralMetaDatabaseIntegers(general.meta),
});
const applyGeneralPatch = (base: TurnGeneral, patch: Partial<TurnGeneral>): TurnGeneral =>
normalizeGeneralDatabaseIntegers({
...base,
...patch,
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats,
role: patch.role ? mergeRole(base.role, patch.role) : base.role,
triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
const applyCityPatch = (base: City, patch: Partial<City>): City => ({
...base,
...patch,
@@ -690,7 +751,7 @@ export class InMemoryTurnWorld {
}
const worldKillturn = resolveWorldKillturn(this.state.meta);
const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime);
const ensured = ensureGeneralKillturn(normalized, worldKillturn);
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
this.generals.set(general.id, ensured);
this.dirtyGeneralIds.add(general.id);
this.createdGeneralIds.add(general.id);
@@ -994,10 +1055,10 @@ export class InMemoryTurnWorld {
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
if (!result.deleted?.general) {
const nextGeneral = {
const nextGeneral = normalizeGeneralDatabaseIntegers({
...(result.general ?? currentGeneral),
turnTime: nextTurnAt,
};
});
this.generals.set(nextGeneral.id, nextGeneral);
this.dirtyGeneralIds.add(nextGeneral.id);
}
@@ -1067,7 +1128,7 @@ export class InMemoryTurnWorld {
}
const worldKillturn = resolveWorldKillturn(this.state.meta);
const normalized = normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime);
const ensured = ensureGeneralKillturn(normalized, worldKillturn);
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
this.generals.set(createdGeneral.id, ensured);
this.dirtyGeneralIds.add(createdGeneral.id);
this.createdGeneralIds.add(createdGeneral.id);
+21 -5
View File
@@ -21,6 +21,9 @@ import type { InMemoryTurnWorld, TurnCalendarHandler, TurnCalendarContext } from
import { resolveAppliedNationRate } from './nationTaxRate.js';
import type { TurnGeneral } from './types.js';
const DEFAULT_BASE_GOLD = 0;
const DEFAULT_BASE_RICE = 2_000;
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
@@ -97,8 +100,6 @@ const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType<ActionLogger['flush
}
};
const roundResource = (value: number): number => Math.round(value);
const applyIncomeOutcome = (
current: number,
income: number,
@@ -144,7 +145,11 @@ const processIncomeForNation = (
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
const incomeValue = roundResource(income);
// Ref calculates the payout ratio from the pre-persistence income value.
// Half-unit income (for example 943.5) is therefore not rounded before
// salaries are distributed, even though the integer nation column is
// rounded when the final state is flushed to MariaDB.
const incomeValue = income;
const originOutcome = getOutcome(100, nationGenerals);
const bill = resolveNationBill(nation);
const outcome = Math.round((bill / 100) * originOutcome);
@@ -167,6 +172,14 @@ const processIncomeForNation = (
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
for (const general of nationGenerals) {
const pay = Math.round(getBill(general.dedication) * ratio);
if (
process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' &&
(process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id))
) {
process.stdout.write(
`MONTHLY_RESOURCE_CORE ${JSON.stringify({ action: 'ProcessIncome', type, generalId: general.id, current: general[type], pay, ratio, originOutcome })}\n`
);
}
if (type === 'gold') {
world.updateGeneral(general.id, { gold: general.gold + pay });
} else {
@@ -197,8 +210,8 @@ export const createIncomeHandler = (options: {
nationTraits: Map<string, NationTraitModule>;
}): IncomeHandler => {
const constValues = asRecord(options.scenarioConfig.const);
const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], 0);
const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], 0);
const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD);
const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE);
const runResource = (type: 'gold' | 'rice'): void => {
const world = options.getWorld();
@@ -217,6 +230,9 @@ export const createIncomeHandler = (options: {
}
for (const nation of nations) {
if (nation.id <= 0) {
continue;
}
const nationGenerals = byNation.get(nation.id) ?? [];
const officerCounts = buildOfficerCountMap(nationGenerals);
processIncomeForNation(
@@ -241,6 +241,11 @@ export const applyInitialChangeCityEvents = <T extends CitySeed>(
throw new Error('Only unconditional initial events are supported.');
}
for (const rawAction of rawEvent.slice(1)) {
if (Array.isArray(rawAction) && rawAction[0] === 'NoticeToHistoryLog') {
// Initial history logging is handled by the install history
// boundary; it has no city-state effect here.
continue;
}
if (!Array.isArray(rawAction) || rawAction[0] !== 'ChangeCity') {
throw new Error('Only ChangeCity initial actions are supported.');
}
@@ -134,6 +134,7 @@ export const createRaiseDisasterHandler = (options: {
const securityRatio = clamp(city.security / city.securityMax / 0.8, 0, 1);
const affectRatio = isGood ? 1.01 + securityRatio * 0.04 : 0.8 + securityRatio * 0.15;
const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0;
const storedTrust = Math.fround(isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio);
world.updateCity(city.id, {
state: picked.stateCode,
population: roundLegacyIntegerColumn(
@@ -158,9 +159,25 @@ export const createRaiseDisasterHandler = (options: {
),
meta: {
...city.meta,
trust: isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio,
// Ref assigns the SQL expression to a MariaDB FLOAT
// column at each disaster event boundary.
trust: storedTrust,
},
});
if ((process.env.CORE_AI_TRACE_CITY_IDS?.split(',') ?? []).includes(String(city.id))) {
process.stdout.write(
`MONTHLY_FLOAT_TRACE ${JSON.stringify({
engine: 'core',
cityId: city.id,
year: environment.year,
month: environment.month,
isGood,
inputTrust: trust,
affectRatio,
storedTrust,
})}\n`
);
}
if (isGood) {
continue;
@@ -86,7 +86,9 @@ export const createRandomizeCityTradeRateHandler = (options: {
)
);
for (const city of world.listCities()) {
// Ref's `SELECT city, level FROM city` walks the primary city key.
// Make RNG consumption independent from PostgreSQL snapshot/insertion order.
for (const city of world.listCities().sort((left, right) => left.id - right.id)) {
const probability = CITY_TRADE_PROBABILITY_BY_LEVEL[city.level];
if (probability === undefined) {
throw new Error(`Unsupported city level for RandomizeCityTradeRate: ${city.level} (cityId=${city.id})`);
@@ -226,6 +228,11 @@ export const createMonthlyEventHandler = (options: {
const year = targetCode === 'pre_month' ? context.previousYear : context.currentYear;
const month = targetCode === 'pre_month' ? context.previousMonth : context.currentMonth;
const remainingNationCount = world.listNations().filter((nation) => nation.id > 0).length;
// Ref does not write game_env.turntime until every event and
// postUpdateMonthly step has completed. Event actions therefore see
// the previous monthly boundary even after turnDate() has advanced
// year/month. Generated general turn times depend on this distinction.
const legacyTurnTime = new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000);
for (const event of world.listEvents(targetCode)) {
const environment: MonthlyEventEnvironment = {
@@ -233,7 +240,7 @@ export const createMonthlyEventHandler = (options: {
month,
startyear: options.startYear,
currentEventID: event.id,
turnTime: context.turnTime,
turnTime: legacyTurnTime,
};
if (!evaluateCondition(event.condition, environment, remainingNationCount)) {
continue;
@@ -249,6 +249,12 @@ export const createUpdateNationLevelHandler = (options: {
const hiddenSeed = resolveHiddenSeed(world);
for (const nation of world.listNations().sort((left, right) => left.id - right.id)) {
// Legacy persists only founded nations in `nation`; Core also keeps
// an id=0 sentinel so neutral cities can retain a Nation reference.
// The sentinel must never participate in title promotion.
if (nation.id <= 0) {
continue;
}
const cityCount = cityCounts.get(nation.id) ?? 0;
let newLevel = 0;
for (let level = 0; level < NATION_LEVEL_CITY_COUNTS.length; level += 1) {
@@ -7,6 +7,11 @@ import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'
const MAX_AVAILABLE_WAR_SETTING_COUNT = 10;
const MONTHLY_AVAILABLE_WAR_SETTING_INCREMENT = 2;
export const roundLegacyNationPowerValue = (value: number): number => {
const stabilized = Number(value.toPrecision(15));
return stabilized >= 0 ? Math.floor(stabilized + 0.5) : Math.ceil(stabilized - 0.5);
};
const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
@@ -32,15 +37,16 @@ const calculateNationPower = (
): {
power: number;
totalCrew: number;
trace: Record<string, number>;
} => {
const nation = world.getNationById(nationId);
if (!nation) {
return { power: 0, totalCrew: 0 };
return { power: 0, totalCrew: 0, trace: {} };
}
const generals = world.listGenerals().filter((general) => general.nationId === nationId);
const suppliedCities = world.listCities().filter((city) => city.nationId === nationId && city.supplyState === 1);
const generalResources = generals.reduce((sum, general) => sum + general.gold + general.rice, 0);
const resourcePower = Math.round((nation.gold + nation.rice + generalResources) / 100);
const resourcePower = roundLegacyNationPowerValue((nation.gold + nation.rice + generalResources) / 100);
const techPower = readNumber(asRecord(nation.meta).tech);
let cityPower = 0;
@@ -62,7 +68,7 @@ const calculateNationPower = (
city.defenceMax,
0
);
cityPower = maximum > 0 ? Math.round((population * current) / maximum / 100) : 0;
cityPower = maximum > 0 ? roundLegacyNationPowerValue((population * current) / maximum / 100) : 0;
}
let generalPower = 0;
@@ -90,16 +96,29 @@ const calculateNationPower = (
totalCrew += general.crew;
}
const power = Math.round(
const power = roundLegacyNationPowerValue(
(resourcePower +
techPower +
cityPower +
generalPower +
Math.round(dexterityPower / 1000) +
Math.round(experiencePower / 100)) /
roundLegacyNationPowerValue(dexterityPower / 1000) +
roundLegacyNationPowerValue(experiencePower / 100)) /
10
);
return { power, totalCrew };
return {
power,
totalCrew,
trace: {
resourcePower,
techPower,
cityPower,
generalPower,
dexterityPowerRaw: dexterityPower / 1000,
dexterityPower: roundLegacyNationPowerValue(dexterityPower / 1000),
experiencePowerRaw: experiencePower / 100,
experiencePower: roundLegacyNationPowerValue(experiencePower / 100),
},
};
};
const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => {
@@ -110,10 +129,25 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => {
citiesByNation.set(city.nationId, names);
}
const nations = world.listNations().sort((left, right) => left.id - right.id);
// Core keeps an internal nation 0 record, while Ref's nation table/query
// starts at 1. It must not consume a monthly RNG draw or receive power.
const nations = world
.listNations()
.filter((nation) => nation.id > 0)
.sort((left, right) => left.id - right.id);
for (const nation of nations) {
const calculated = calculateNationPower(world, nation.id);
const power = Math.round(calculated.power * rng.nextRange(0.95, 1.05));
const multiplier = rng.nextRange(0.95, 1.05);
const power = roundLegacyNationPowerValue(calculated.power * multiplier);
const traceIds = (process.env.SEED_PARITY_TRACE_NATION_POWER_IDS ?? '')
.split(',')
.map((value) => Number(value.trim()))
.filter(Number.isFinite);
if (traceIds.includes(nation.id)) {
process.stdout.write(
`NATION_POWER_TRACE ${JSON.stringify({ nationId: nation.id, ...calculated.trace, basePower: calculated.power, multiplier, power })}\n`
);
}
const meta = asRecord(nation.meta);
const previousMax = asRecord(meta.max_power);
const previousCities = Array.isArray(previousMax.maxCities)
@@ -7,7 +7,12 @@ import { resolveAppliedNationRate } from './nationTaxRate.js';
type SemiAnnualResource = 'gold' | 'rice';
const roundLegacyIntegerColumn = (value: number): number => Math.round(value);
const roundLegacyIntegerColumn = (value: number): number => {
// MariaDB evaluates the decimal rate expression before ROUND(). Binary
// arithmetic can instead produce values such as 2029.4999999999998.
const stabilized = Number(value.toPrecision(15));
return stabilized >= 0 ? Math.floor(stabilized + 0.5) : Math.ceil(stabilized - 0.5);
};
const parseResource = (args: readonly unknown[]): SemiAnnualResource => {
const resource = args[0];
@@ -24,6 +29,8 @@ const resolveBasePopulationIncrease = (world: InMemoryTurnWorld): number => {
const decayDomesticValue = (value: number): number => roundLegacyIntegerColumn(value * 0.99);
export const storeLegacySemiAnnualTrust = (value: number): number => Math.fround(Math.max(0, Math.min(100, value)));
const applyResourceMaintenance = (value: number, ratios: readonly [number, number][]): number => {
if (value <= 1_000) {
return value;
@@ -81,6 +88,9 @@ export const createProcessSemiAnnualHandler = (options: {
const basePopulationIncrease = resolveBasePopulationIncrease(world);
for (const nation of world.listNations()) {
if (nation.id <= 0) {
continue;
}
const rate = resolveAppliedNationRate(nation.meta);
let populationRatio = (30 - rate) / 200;
const trait = options.nationTraits?.get(nation.typeCode);
@@ -115,7 +125,10 @@ export const createProcessSemiAnnualHandler = (options: {
wall: roundLegacyIntegerColumn(Math.min(city.wallMax, city.wall * (1 + genericRatio))),
meta: {
...city.meta,
trust: Math.max(0, Math.min(100, trust + trustDiff)),
// Ref's UPDATE persists trust to a MariaDB FLOAT before
// the next monthly action reads it. Core stores this
// field in JSON, so emulate that binary32 boundary here.
trust: storeLegacySemiAnnualTrust(trust + trustDiff),
},
});
}
@@ -124,6 +137,14 @@ export const createProcessSemiAnnualHandler = (options: {
for (const general of world.listGenerals()) {
const current = general[resource];
const next = applyResourceMaintenance(current, [[10_000, 0.97]]);
if (
process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' &&
(process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id))
) {
process.stdout.write(
`MONTHLY_RESOURCE_CORE ${JSON.stringify({ action: 'ProcessSemiAnnual', resource, generalId: general.id, current, next })}\n`
);
}
if (next !== current) {
world.updateGeneral(general.id, { [resource]: next });
}
@@ -69,6 +69,8 @@ export const createProcessWarIncomeHandler = (options: {
for (const city of cities) {
const dead = asNumber(city.meta.dead, 0);
world.updateCity(city.id, {
// MariaDB rounds the SQL expression when assigning it to
// Ref's integer pop column (including an exact .5 result).
population: Math.round(city.population + dead * 0.2),
meta: {
...city.meta,
+142 -31
View File
@@ -1,6 +1,24 @@
import { asRecord } from '@sammo-ts/common';
import {
createIncomeActionContext,
getGoldIncome,
getOutcome,
getRiceIncome,
getWallIncome,
getWarGoldIncome,
type City,
type CityIncomeSource,
type Nation,
type NationIncomeContext,
type NationTraitModule,
type ScenarioConfig,
type TurnCommandEnv,
type UnitSetDefinition,
} from '@sammo-ts/logic';
import type { TurnCalendarContext, TurnCalendarHandler, InMemoryTurnWorld } from './inMemoryWorld.js';
import type { City, Nation } from '@sammo-ts/logic';
import { joinYearMonth, readNumber } from './ai/aiUtils.js';
import { readNumber } from './ai/aiUtils.js';
import { AutorunNationPolicy } from './ai/policies.js';
const calcNationDevelopedRate = (cities: City[]): { pop: number; all: number } => {
if (cities.length === 0) {
@@ -51,15 +69,129 @@ const resolveNpcTaxRate = (cities: City[]): number => {
const shouldUpdateRate = (month: number): boolean => month === 6 || month === 12;
const isNpcMonarchNation = (nation: Nation, world: InMemoryTurnWorld): boolean => {
if (!nation.capitalCityId || !nation.chiefGeneralId) {
return false;
const toIncomeCity = (city: City): CityIncomeSource => ({
id: city.id,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
trust: readNumber(asRecord(city.meta).trust, 50),
supplyState: city.supplyState,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
meta: asRecord(city.meta),
});
const buildOfficerCounts = (world: InMemoryTurnWorld, nationId: number): Map<number, number> => {
const result = new Map<number, number>();
for (const general of world.listGenerals()) {
if (general.nationId !== nationId || general.officerLevel < 2 || general.officerLevel > 4) continue;
const officerCity = readNumber(asRecord(general.meta).officer_city, 0);
if (officerCity > 0 && general.cityId === officerCity) {
result.set(officerCity, (result.get(officerCity) ?? 0) + 1);
}
}
const chief = world.getGeneralById(nation.chiefGeneralId);
return Boolean(chief && chief.npcState >= 2);
return result;
};
export const createNpcTaxHandler = (options: { getWorld: () => InMemoryTurnWorld | null }): TurnCalendarHandler => {
const clampBill = (value: number): number => Math.max(20, Math.min(200, Math.trunc(value)));
const resolveNpcMonarch = (nation: Nation, world: InMemoryTurnWorld) => {
const chief = nation.chiefGeneralId
? world.getGeneralById(nation.chiefGeneralId)
: world
.listGenerals()
.find((general) => general.nationId === nation.id && general.officerLevel === 12) ?? null;
return chief && chief.npcState >= 2 ? chief : null;
};
type NpcFinanceOptions = {
commandEnv?: TurnCommandEnv;
scenarioConfig?: ScenarioConfig;
unitSet?: UnitSetDefinition;
nationTraits?: ReadonlyMap<string, NationTraitModule>;
};
export const calculateNpcNationFinance = (
world: InMemoryTurnWorld,
nation: Nation,
currentMonth: number,
options: NpcFinanceOptions
): Nation['meta'] | null => {
if (!shouldUpdateRate(currentMonth)) {
return null;
}
const chief = resolveNpcMonarch(nation, world);
if (!chief) return null;
const cities = world.listCities();
const rawNationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0);
const rate = resolveNpcTaxRate(rawNationCities);
// Ref chooses the default rate during the ruler turn even when a newly
// founded nation has not supplied its first city yet. In that state it
// leaves the existing bill untouched because there is no income basis.
if (rawNationCities.length === 0) {
return { ...nation.meta, rate };
}
if (!options.commandEnv || !options.scenarioConfig) {
return { ...nation.meta, rate };
}
const trait = options.nationTraits?.get(nation.typeCode);
const actionContext = createIncomeActionContext(nation);
const incomeContext: NationIncomeContext = {
rate,
...(trait?.onCalcNationalIncome
? { modifyIncome: (type, amount) => trait.onCalcNationalIncome!(actionContext, type, amount) }
: {}),
};
const nationCities = rawNationCities.map(toIncomeCity);
const officerCounts = buildOfficerCounts(world, nation.id);
const generals = world
.listGenerals()
// Ref's GeneralAI::$nationGenerals is built with `no != current ruler`.
// chooseGoldBillRate therefore omits the ruler's own stipend from the
// outcome used to choose bill, despite a dead local append that looks
// as if it intended to include the ruler.
.filter((general) => general.nationId === nation.id && general.id !== chief.id && general.npcState !== 5);
const outcome = Math.max(1, getOutcome(100, generals));
const policy = new AutorunNationPolicy({
general: chief,
aiOptions: null,
nationPolicy: asRecord(nation.meta).npc_nation_policy as Record<string, unknown> | null,
serverPolicy: asRecord(world.getState().meta).npc_nation_policy as Record<string, unknown> | null,
nation,
env: options.commandEnv,
scenarioConfig: options.scenarioConfig,
...(options.unitSet ? { unitSet: options.unitSet } : {}),
});
const income =
currentMonth === 12
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) +
getWarGoldIncome(incomeContext, nationCities)
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level);
const currentResource = currentMonth === 12 ? nation.gold : nation.rice;
const requiredResource = currentMonth === 12 ? policy.reqNationGold : policy.reqNationRice;
let bill = Math.trunc((income / outcome) * 90);
if (currentResource + income - outcome > requiredResource * 2) {
const moreBill = ((currentResource + income - requiredResource * 2) / outcome) * 80;
if (moreBill > bill) bill = Math.trunc((moreBill + bill) / 2);
}
return { ...nation.meta, rate, bill: clampBill(bill) };
};
export const createNpcTaxHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
commandEnv?: TurnCommandEnv;
scenarioConfig?: ScenarioConfig;
unitSet?: UnitSetDefinition;
nationTraits?: ReadonlyMap<string, NationTraitModule>;
}): TurnCalendarHandler => {
return {
onMonthChanged: (context: TurnCalendarContext) => {
if (!shouldUpdateRate(context.currentMonth)) {
@@ -69,30 +201,9 @@ export const createNpcTaxHandler = (options: { getWorld: () => InMemoryTurnWorld
if (!world) {
return;
}
const worldState = world.getState();
const meta = worldState.meta ?? {};
const initYear = readNumber(meta.initYear, NaN);
const initMonth = readNumber(meta.initMonth, NaN);
const hasInit = Number.isFinite(initYear) && Number.isFinite(initMonth);
const monthsSinceStart = hasInit
? joinYearMonth(context.currentYear, context.currentMonth) - joinYearMonth(initYear, initMonth)
: null;
const cities = world.listCities();
for (const nation of world.listNations()) {
if (!isNpcMonarchNation(nation, world)) {
continue;
}
const nationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0);
let rate = resolveNpcTaxRate(nationCities);
if (monthsSinceStart !== null && monthsSinceStart <= 4) {
rate = Math.min(rate, 10);
}
world.updateNation(nation.id, {
meta: {
...nation.meta,
rate,
},
});
const nextMeta = calculateNpcNationFinance(world, nation, context.currentMonth, options);
if (nextMeta) world.updateNation(nation.id, { meta: nextMeta });
}
},
};
@@ -129,7 +129,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
]),
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
maxStatLevel: resolveNumber(constValues, ['maxLevel'], config.stat.max),
maxDedicationLevel: resolveNumber(constValues, ['maxDedLevel'], 30),
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
+146 -33
View File
@@ -10,6 +10,7 @@ import type {
ScenarioConfig,
ScenarioMeta,
Troop,
GeneralTurnCommandKey,
TurnCommandProfile,
TurnCommandEnv,
UnitSetDefinition,
@@ -58,6 +59,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js';
import { withCanonicalArgumentAliases } from './ai/aiUtils.js';
import { rankMetaKey } from './rankData.js';
import {
hasScenarioStaticEventHandler,
@@ -66,6 +68,7 @@ import {
} from './scenarioStaticEvents.js';
const DEFAULT_ACTION = '휴식';
const AI_INTERNAL_GENERAL_ACTION_KEYS = ['che_NPC능동'] as const satisfies readonly GeneralTurnCommandKey[];
const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_소집해제',
@@ -96,6 +99,12 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_첩보',
'che_임관',
'che_상업투자',
// These legacy classes inherit che_상업투자::run(), including its
// unconditional checkStatChange() tail.
'che_농지개간',
'che_수비강화',
'che_성벽보수',
'che_치안강화',
'che_장비매매',
'che_장수대상임관',
'che_징병',
@@ -682,21 +691,6 @@ class WorldStateView implements StateView {
const extractArgsRecord = (value: unknown): Record<string, unknown> => asRecord(value);
const withCanonicalArgumentAliases = (args: Record<string, unknown>): Record<string, unknown> => {
const normalized = { ...args };
for (const [legacyKey, canonicalKey] of [
['destCityID', 'destCityId'],
['destNationID', 'destNationId'],
['destGeneralID', 'destGeneralId'],
['destTroopID', 'destTroopId'],
] as const) {
if (normalized[canonicalKey] === undefined && normalized[legacyKey] !== undefined) {
normalized[canonicalKey] = normalized[legacyKey];
}
}
return normalized;
};
const buildConstraintContext = (
general: TurnGeneral,
city: City | undefined,
@@ -737,6 +731,11 @@ export const createReservedTurnHandler = async (options: {
commandEnv?: TurnCommandEnv;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
calculateNpcNationFinance?: (
world: InMemoryTurnWorld,
nation: Nation,
currentMonth: number
) => Nation['meta'] | null;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -774,6 +773,17 @@ export const createReservedTurnHandler = async (options: {
};
const generalModuleLoader = new GeneralTurnCommandLoader();
const nationModuleLoader = new NationTurnCommandLoader();
// NPC AI emits a few engine-internal commands that are intentionally not
// exposed by the scenario's player command profile. Keep their definitions
// available to AI resolution without adding them to the public profile.
for (const key of AI_INTERNAL_GENERAL_ACTION_KEYS) {
const module = await generalModuleLoader.load(key);
if (!generalDefinitions.has(key)) {
generalDefinitions.set(key, module.commandSpec.createDefinition(env));
}
seenActionKeys.add(key);
applyActionContextBuilder(module);
}
for (const key of commandProfile.general) {
if (seenActionKeys.has(key)) {
continue;
@@ -830,6 +840,9 @@ export const createReservedTurnHandler = async (options: {
return {
execute(context): GeneralTurnResult {
// Legacy reads the current game_env.develcost for every command.
// Scenario const is only a fallback; the value changes with year.
env.develCost = readMetaNumber(asRecord(context.world.meta), 'develcost', env.develCost);
const worldRef = options.getWorld();
const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null;
const worldView = worldOverlay?.view ?? worldRef;
@@ -1034,6 +1047,12 @@ export const createReservedTurnHandler = async (options: {
specificContext = baseContext;
}
const actionContext = specificContext ?? baseContext;
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral };
process.stdout.write(
`AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n`
);
}
const executionDefinition = definition as unknown as {
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
@@ -1115,9 +1134,29 @@ export const createReservedTurnHandler = async (options: {
},
actionArgs
);
if (
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) &&
resolution.patches
) {
process.stdout.write(
`AI_ACTION_PATCH_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, patches: resolution.patches })}\n`
);
}
for (const troopId of resolution.deletedTroopIds ?? []) {
commandDeletedTroopIds.add(troopId);
}
for (const plan of resolution.reservedGeneralTurnPlans ?? []) {
for (let turnIdx = 0; turnIdx < plan.joinTurn; turnIdx += 1) {
options.reservedTurns.setGeneralTurn(plan.generalId, turnIdx, {
action: 'che_견문',
args: {},
});
}
options.reservedTurns.setGeneralTurn(plan.generalId, plan.joinTurn, {
action: 'che_임관',
args: { destNationId: plan.destNationId },
});
}
currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity;
@@ -1559,6 +1598,7 @@ export const createReservedTurnHandler = async (options: {
}
let hasReservedTurn = false;
let sharedAi: GeneralAI | undefined;
if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) {
let nationCommand = options.reservedTurns.getNationTurn(
currentNation.id,
@@ -1570,7 +1610,7 @@ export const createReservedTurnHandler = async (options: {
}
let nationAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
if (worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
sharedAi = new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -1587,13 +1627,66 @@ export const createReservedTurnHandler = async (options: {
generalFallback,
nationFallback,
});
const ai = sharedAi;
const candidate = ai.chooseNationTurn(nationCommand);
if (candidate) {
if (
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(
String(currentGeneral.id)
)
) {
process.stdout.write(
`AI_NATION_CANDIDATE_TRACE ${JSON.stringify({ generalId: currentGeneral.id, candidate })}\n`
);
}
nationCommand = { action: candidate.action, args: candidate.args };
}
const promotion = ai.consumePromotionPatches();
for (const entry of promotion.generals) {
const promotedGeneral = worldOverlay?.view.getGeneralById(entry.generalId);
const patch = {
officerLevel: entry.officerLevel,
...(promotedGeneral
? { meta: { ...promotedGeneral.meta, officer_city: entry.officerCity } }
: {}),
};
patches.generals.push({ id: entry.generalId, patch });
worldOverlay?.applyGeneralPatch(entry.generalId, patch);
if (entry.generalId === currentGeneral.id) {
currentGeneral = applyGeneralPatch(currentGeneral, patch);
}
}
if (promotion.nationMeta && currentNation) {
currentNation = { ...currentNation, meta: promotion.nationMeta as Nation['meta'] };
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
}
if (currentNation && currentGeneral.officerLevel === 12 && options.calculateNpcNationFinance) {
const baseWorld = options.getWorld();
const financeMeta = baseWorld
? options.calculateNpcNationFinance(baseWorld, currentNation, context.world.currentMonth)
: null;
if (financeMeta) {
currentNation = { ...currentNation, meta: financeMeta as Nation['meta'] };
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
}
}
nationAiState = ai.getDebugState();
}
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
if (
worldView &&
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
) {
process.stdout.write(
`AI_DIPLOMACY_TRACE ${JSON.stringify({
generalId: currentGeneral.id,
stage: 'after-nation-action',
entries: worldView
.listDiplomacy()
.filter((entry) => entry.fromNationId === currentGeneral.nationId && entry.state <= 1),
})}\n`
);
}
options.onActionResolved?.({
kind: 'nation',
generalId: currentGeneral.id,
@@ -1618,23 +1711,25 @@ export const createReservedTurnHandler = async (options: {
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
let generalAutorunMode = false;
if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) {
const ai = new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
world: context.world,
worldRef: worldView,
reservedTurnProvider,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
commandEnv: env,
generalDefinitions,
nationDefinitions,
generalFallback,
nationFallback,
});
const ai =
sharedAi ??
new GeneralAI({
general: currentGeneral,
city: currentCity,
nation: currentNation,
world: context.world,
worldRef: worldView,
reservedTurnProvider,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
commandEnv: env,
generalDefinitions,
nationDefinitions,
generalFallback,
nationFallback,
});
const candidate = ai.chooseGeneralTurn(generalCommand);
if (candidate) {
generalAutorunMode =
@@ -1642,6 +1737,18 @@ export const createReservedTurnHandler = async (options: {
JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {});
generalCommand = { action: candidate.action, args: candidate.args };
}
const aiMetaPatch = ai.consumePersistentGeneralMetaPatch();
if (Object.keys(aiMetaPatch.set).length > 0 || aiMetaPatch.unset.length > 0) {
const nextMeta = { ...currentGeneral.meta } as Record<string, unknown>;
for (const key of aiMetaPatch.unset) {
delete nextMeta[key];
}
currentGeneral = {
...currentGeneral,
meta: { ...nextMeta, ...aiMetaPatch.set } as TurnGeneral['meta'],
};
worldOverlay?.syncGeneral(currentGeneral);
}
generalAiState = ai.getDebugState();
}
const generalResult = isBlocked
@@ -1854,6 +1961,12 @@ export const createReservedTurnHandler = async (options: {
},
};
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
process.stdout.write(
`AI_GENERAL_PRE_APPLY_TRACE ${JSON.stringify({ engine: 'core', generalId: currentGeneral.id, stats: currentGeneral.stats, experience: currentGeneral.experience, dedication: currentGeneral.dedication, meta: { leadership_exp: currentGeneral.meta.leadership_exp, strength_exp: currentGeneral.meta.strength_exp, intel_exp: currentGeneral.meta.intel_exp, dex1: currentGeneral.meta.dex1, dex2: currentGeneral.meta.dex2, dex3: currentGeneral.meta.dex3, dex4: currentGeneral.meta.dex4, dex5: currentGeneral.meta.dex5 } })}\n`
);
}
const result: GeneralTurnResult = {
general: currentGeneral,
city: currentCity,
@@ -460,6 +460,20 @@ export class InMemoryReservedTurnStore {
this.dirtyGeneralIds.add(generalId);
}
setGeneralTurn(generalId: number, turnIdx: number, entry: ReservedTurnEntry): void {
if (turnIdx < 0 || turnIdx >= this.maxGeneralTurns) {
return;
}
const turns = this.getGeneralTurns(generalId).slice();
turns[turnIdx] = {
action: normalizeAction(entry.action),
args: normalizeArgs(entry.args),
};
this.generalTurns.set(generalId, turns);
this.pendingGeneralInitializationIds.delete(generalId);
this.dirtyGeneralIds.add(generalId);
}
ensureNationTurns(nationId: number, officerLevel: number): void {
const key = buildNationKey(nationId, officerLevel);
this.getNationTurns(nationId, officerLevel);
+14 -18
View File
@@ -44,7 +44,9 @@ const safeJsonParse = <T>(raw: string | null): T | null => {
const resolveTermSeconds = (tickSeconds: number): number => {
const turnMinutes = Math.max(1, Math.round(tickSeconds / 60));
return Math.min(120, Math.max(5, turnMinutes)) * 60;
// Ref calcTournamentTerm() receives the turn length in minutes but returns
// that clamped numeric value as tournament seconds.
return Math.min(120, Math.max(5, turnMinutes));
};
const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>): number[] => {
@@ -57,17 +59,6 @@ const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>):
);
};
const shuffledDefaultPattern = (
hiddenSeed: string | number,
previousYear: number,
previousMonth: number
): number[] =>
new RandUtil(
new LiteHashDRBG(
simpleSerialize(hiddenSeed, 'monthly', previousYear, previousMonth, 'tournamentPattern')
)
).shuffle([0, 0, 1, 2, 3]);
export const createTournamentAutoStartHandler = (options: {
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
@@ -114,10 +105,9 @@ export const createTournamentAutoStartHandler = (options: {
}
const pattern = readPattern(world, config);
const resolvedPattern =
pattern.length > 0
? pattern
: shuffledDefaultPattern(hiddenSeed, context.previousYear, context.previousMonth);
// The deterministic Ref comparison branch replaces PHP's global
// shuffle() with this same already-advanced monthly RNG.
const resolvedPattern = pattern.length > 0 ? pattern : rng.shuffle([0, 0, 1, 2, 3]);
const type = resolvedPattern.pop() ?? 0;
world.updateWorldMeta({ tournamentPattern: resolvedPattern });
const now = options.now?.() ?? new Date();
@@ -133,8 +123,14 @@ export const createTournamentAutoStartHandler = (options: {
openYear: context.currentYear,
openMonth: context.currentMonth,
termSeconds,
nextAt: new Date(now.getTime() + termSeconds * 1_000).toISOString(),
bettingId: undefined,
// Ref startTournament() passes calcTournamentTerm()'s seconds
// value to DateInterval's minute field. Preserve that historical
// initial enrollment delay; later tournament phases use seconds.
nextAt: new Date(now.getTime() + termSeconds * 60_000).toISOString(),
bettingId:
typeof previousState?.bettingId === 'number' && Number.isFinite(previousState.bettingId)
? previousState.bettingId + 1
: 1,
bettingCloseAt: undefined,
winnerId: undefined,
bettingSettled: false,
+12
View File
@@ -20,6 +20,7 @@ import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { composeCalendarHandlers } from './calendarHandlers.js';
import { createIncomeHandler } from './incomeHandler.js';
import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js';
import { calculateNpcNationFinance } from './npcTaxHandler.js';
import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js';
import { createMonthlyWanderHandler } from './monthlyWanderHandler.js';
import {
@@ -471,6 +472,10 @@ const createTurnDaemonRuntimeWithLease = async (
onTournamentRollConsumed: (consumed) => {
monthlyTournamentRollConsumed = consumed;
},
// Deterministic/manual runtimes must schedule the tournament from the
// same clock that advances the game world. Production still falls
// back to the system clock.
now: () => new Date(options.clock?.nowMs() ?? Date.now()),
});
const yearbookHandler = createYearbookHandler({
profileName: options.profileName ?? options.profile,
@@ -508,6 +513,13 @@ const createTurnDaemonRuntimeWithLease = async (
getWorld: () => worldRef,
commandProfile,
commandEnv: monthlyCommandEnv,
calculateNpcNationFinance: (financeWorld, nation, currentMonth) =>
calculateNpcNationFinance(financeWorld, nation, currentMonth, {
commandEnv: monthlyCommandEnv,
scenarioConfig: snapshot.scenarioConfig,
unitSet: snapshot.unitSet,
nationTraits: nationTraitMap,
}),
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
onActionResolved: options.onActionResolved,
})),
+21 -11
View File
@@ -389,18 +389,28 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
inheritanceByUser.set(row.userId, bucket);
}
const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row]));
const generals = generalRows.map((row) =>
mapGeneralRow(
row,
ranksByGeneral.get(row.id) ?? [],
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [],
accessByGeneral.get(row.id)
// MariaDB legacy scans these tables in their primary-key order. Prisma
// findMany() does not promise an order, and Map insertion order can
// otherwise leak into monthly RNG and AI candidate traversal.
const generals = generalRows
.map((row) =>
mapGeneralRow(
row,
ranksByGeneral.get(row.id) ?? [],
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [],
accessByGeneral.get(row.id)
)
)
);
const cities = cityRows.map(mapCityRow);
const nations = nationRows.map(mapNationRow);
const diplomacy = diplomacyRows.map(mapDiplomacyRow);
const troops = troopRows.map(mapTroopRow);
.sort((left, right) => left.id - right.id);
const cities = cityRows.map(mapCityRow).sort((left, right) => left.id - right.id);
const nations = nationRows.map(mapNationRow).sort((left, right) => left.id - right.id);
const diplomacy = diplomacyRows
.map(mapDiplomacyRow)
.sort(
(left, right) =>
left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
);
const troops = troopRows.map(mapTroopRow).sort((left, right) => left.id - right.id);
const worldConfig = asRecord(worldState.config);
const scenarioConfig = mapScenarioConfig(worldState.config);
@@ -1,14 +1,18 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '@sammo-ts/logic';
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import type { GeneralAI } from '../src/turn/ai/generalAi.js';
import { resolveLegacyAiStats } from '../src/turn/ai/generalAi/core.js';
import { withCanonicalArgumentAliases } from '../src/turn/ai/aiUtils.js';
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
import { do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
import { do거병, do건국, do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
import { do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
import { doNPC몰수, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
import { do내정워프, do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
import { doNPC몰수, doNPC포상, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
import { doNPC전방발령, doNPC후방발령 } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
type Candidate = {
action: string;
@@ -74,7 +78,24 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng =>
};
};
const baseGeneral = (): General => ({
const singleActionModuleStack = (
module: NonNullable<GeneralAI['commandEnv']['generalActionModules']>[number]
): NonNullable<GeneralAI['commandEnv']['generalActionModules']> => {
const noOp = {};
return createRefOrderedActionStack({
nation: noOp,
officer: noOp,
domestic: noOp,
war: noOp,
personality: module,
crewType: null,
inheritance: noOp,
scenario: null,
items: [],
});
};
const baseGeneral = (): General & { turnTime: Date } => ({
id: 1,
name: '가상장수',
nationId: 1,
@@ -99,6 +120,7 @@ const baseGeneral = (): General => ({
atmos: 0,
age: 30,
npcState: 2,
turnTime: new Date('0190-01-01T00:00:00Z'),
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 100, fullLeadership: 70 },
});
@@ -155,6 +177,7 @@ const makeAi = (
nations?: Nation[];
generals?: General[];
disabledPolicyActions?: string[];
generalActionModules?: NonNullable<GeneralAI['commandEnv']['generalActionModules']>;
} = {}
): GeneralAI => {
const general = {
@@ -278,6 +301,7 @@ const makeAi = (
maxTechLevel: 10,
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
generalActionModules: overrides.generalActionModules ?? [],
},
aiConst: {
baseGold: 1000,
@@ -352,6 +376,109 @@ const makeAi = (
* selection and RNG-sensitive gates, not TypeScript implementation details.
*/
describe('legacy NPC AI final-decision parity', () => {
it('normalizes legacy uppercase destination IDs before AI constraint checks', () => {
expect(
withCanonicalArgumentAliases({
destGeneralID: 2,
destCityID: 3,
destNationID: 4,
destTroopID: 5,
})
).toMatchObject({
destGeneralId: 2,
destCityId: 3,
destNationId: 4,
destTroopId: 5,
});
});
it('applies the legacy nation-level leadership bonus for officers', () => {
const ruler = { ...baseGeneral(), officerLevel: 12, injury: 0 };
const nation = { ...baseNation(), level: 1 };
expect(resolveLegacyAiStats(ruler, nation, 255)).toMatchObject({
fullLeadership: 72,
effectiveLeadership: 72,
});
expect(resolveLegacyAiStats({ ...ruler, officerLevel: 5 }, nation, 255)).toMatchObject({
fullLeadership: 71,
effectiveLeadership: 71,
});
expect(resolveLegacyAiStats({ ...ruler, officerLevel: 1 }, nation, 255)).toMatchObject({
fullLeadership: 70,
effectiveLeadership: 70,
});
});
it.each([
['Core scenario name', '강유'],
['Ref stored name', 'ⓝ강유'],
])('uses the full ruler name and Ref nation-type/color RNG order: %s', (_label, name) => {
const rng = makeRng([], ['che_음양가', 19]);
const ai = makeAi({ general: { name }, rng });
ai.aiConst.availableNationTypes = ['che_도적', 'che_음양가'];
expect(do건국(ai)).toMatchObject({
action: 'che_건국',
args: { nationName: '㉿강유', nationType: 'che_음양가', colorType: 19 },
});
});
it('counts a wandering-nation ruler on a neutral city as occupying the uprising radius', () => {
const rng = makeRng([false, true]);
const currentCity = { ...baseCity(), id: 1, nationId: 0, level: 1 };
const rulerCity = { ...baseCity(), id: 2, nationId: 0, level: 5 };
const ruler = { ...baseGeneral(), id: 2, nationId: 9, cityId: 2, officerLevel: 12 };
const ai = {
general: { ...baseGeneral(), nationId: 0, cityId: 1, npcState: 2, meta: {} },
city: currentCity,
map: {
id: 'test',
name: 'test',
defaults: {},
cities: [
{
id: 1,
name: '현재',
level: 1,
region: 1,
position: { x: 0, y: 0 },
connections: [2],
max: {},
initial: {},
},
{
id: 2,
name: '군주',
level: 5,
region: 1,
position: { x: 1, y: 0 },
connections: [1],
max: {},
initial: {},
},
],
},
worldRef: {
listCities: () => [currentCity, rulerCity],
listGenerals: () => [ruler],
getCityById: (id: number) => (id === 1 ? currentCity : id === 2 ? rulerCity : null),
},
generalPolicy: { can: (name: string) => name === '건국' },
rng,
aiConst: { defaultStatNpcMax: 100, chiefStatMin: 70 },
world: { currentYear: 179, meta: { initYear: 179 } },
startYear: 179,
buildGeneralCandidate: (action: string, args: Record<string, unknown>, reason: string) => ({
action,
args,
reason,
}),
} as unknown as GeneralAI;
expect(do거병(ai)).toBeNull();
expect(rng.bools).toEqual([true]);
});
it.each([
[0, 0],
[0, 2],
@@ -386,6 +513,30 @@ describe('legacy NPC AI final-decision parity', () => {
}
);
it('applies legacy personality cost modifiers before halving recruit crew', () => {
const ai = makeAi({
dipState: 2,
general: {
gold: 1_000,
rice: 10_000,
meta: { killturn: 100, fullLeadership: 70, rank_killcrew: 0, rank_deathcrew: 1 },
},
generalActionModules: singleActionModuleStack(
{
eventHandlers: {},
onCalcDomestic: (_context, turnType, varType, value) =>
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
}
),
rng: makeRng([], [0, 0]),
});
expect(do징병(ai)).toMatchObject({
action: 'che_징병',
args: { crewType: 1, amount: 3_500 },
});
});
it.each([
[0, 0],
[0, 2000],
@@ -433,6 +584,68 @@ describe('legacy NPC AI final-decision parity', () => {
expect(agriculture[1]).toBe(420);
});
it('coerces fractional nation tech to an integer before the legacy modulo', () => {
const rng = makeRng([false], [0]);
const ai = makeAi({
dipState: 1,
genType: 6,
general: {
stats: { leadership: 73, strength: 44, intelligence: 98 },
meta: {
killturn: 100,
effectiveLeadership: 73,
effectiveStrength: 69,
effectiveIntelligence: 109,
},
},
city: {
population: 75_098,
populationMax: 108_500,
},
nation: { meta: { tech: 564.87353515625 } },
year: 185,
rng,
});
do전쟁내정(ai);
const weights = rng.weightedPairs.at(-1)!;
const technology = weights.find(([candidate]) => (candidate as Candidate).action === 'che_기술연구')!;
expect(technology[1]).toBeCloseTo(109 / (565 / 3000), 12);
});
it('uses injury-adjusted legacy stats when weighting domestic choices', () => {
const rng = makeRng([], [2]);
const ai = makeAi({
genType: 5,
general: {
stats: { leadership: 68, strength: 71, intelligence: 40 },
meta: {
killturn: 100,
effectiveLeadership: 68,
effectiveStrength: 81,
effectiveIntelligence: 58,
},
},
city: {
population: 100_000,
populationMax: 293_700,
defence: 2000,
defenceMax: 5900,
wall: 2000,
wallMax: 6300,
security: 1000,
securityMax: 4000,
meta: { trust: 50, trade: 100 },
},
rng,
});
expect(do일반내정(ai)?.action).toBe('che_수비강화');
const weights = rng.weightedPairs.at(-1)!;
const defence = weights.find(([candidate]) => (candidate as Candidate).action === 'che_수비강화')!;
expect(defence[1]).toBeCloseTo(238.95);
});
it.each([
[1500, 400, null],
[10_000, 1000, 'che_군량매매'],
@@ -443,6 +656,48 @@ describe('legacy NPC AI final-decision parity', () => {
expect(do금쌀구매(ai)?.action ?? null).toBe(expected);
});
it('uses the global command cap rather than the nation-specific reward cap for trade', () => {
const ai = makeAi({ general: { gold: 355, rice: 3177 } });
ai.maxResourceActionAmount = 1200;
// 명령 parser가 이 원시 수량을 Ref처럼 백 단위 1400으로 반올림한다.
expect(do금쌀구매(ai)?.args).toEqual({ buyRice: false, amount: 1411 });
});
it('uses full leadership and the unit rice price for the trade reserve estimate', () => {
const ai = makeAi({
general: {
gold: 4000,
rice: 2000,
stats: { leadership: 10, strength: 70, intelligence: 70 },
meta: { killturn: 100, fullLeadership: 70 },
},
});
const crewType = ai.unitSet?.crewTypes?.[0];
if (!crewType) throw new Error('missing test crew type');
crewType.cost = 9;
crewType.rice = 20;
expect(do금쌀구매(ai)?.action).toBe('che_군량매매');
});
it('applies domestic action cost modifiers to the trade recruit reserve estimate', () => {
const ai = makeAi({
general: { gold: 900, rice: 100 },
generalActionModules: singleActionModuleStack({
eventHandlers: {},
onCalcDomestic: (_context, turnType, varType, value) =>
turnType === '징병' && varType === 'cost' ? value * 2 : value,
}),
});
const crewType = ai.unitSet?.crewTypes?.[0];
if (!crewType) throw new Error('missing test crew type');
crewType.cost = 9;
crewType.rice = 9;
expect(do금쌀구매(ai)).toBeNull();
});
it('randomly chooses between supply and search when national resources are sufficient', () => {
const ai = makeAi({ rng: makeRng([], [1]) });
expect(do중립(ai)?.action).toBe('che_인재탐색');
@@ -481,6 +736,16 @@ describe('legacy NPC AI final-decision parity', () => {
expect(do국가선택(ai)).toBeNull();
});
it('does not count the Core-only neutral nation as a legacy nation', () => {
const ai = makeAi({
general: { nationId: 0 },
year: 181,
nations: [{ ...baseNation(), id: 0, name: '재야' }],
rng: makeRng([true]),
});
expect(do국가선택(ai)).toBeNull();
});
it.each([
[false, 4, 100, 100, 2000, null],
[true, 3, 100, 100, 2000, null],
@@ -519,6 +784,32 @@ describe('legacy NPC AI final-decision parity', () => {
expect(do후방워프(ai)).toBeNull();
});
it('uses full leadership for the legacy rear-warp recruitment floor', () => {
const ai = makeAi({
dipState: 4,
general: {
crew: 0,
stats: { leadership: 10, strength: 70, intelligence: 70 },
meta: { killturn: 100, fullLeadership: 100 },
},
city: { population: 10_000 },
});
ai.categorizeNationCities = () => {
const candidate = {
...baseCity(),
id: 2,
population: 35_000,
populationMax: 50_000,
dev: 0.7,
important: 0,
};
ai.backupCities = { 2: candidate };
ai.supplyCities = { 2: candidate };
};
expect(do후방워프(ai)).toBeNull();
});
it('categorizes generals before weighting a front-line warp destination', () => {
const ai = makeAi({
dipState: 4,
@@ -537,6 +828,32 @@ describe('legacy NPC AI final-decision parity', () => {
expect(categorizedGenerals).toBe(true);
});
it('keeps the legacy empty city-general counts when weighting a domestic warp', () => {
const ai = makeAi({ rng: makeRng([false, true]) });
ai.categorizeNationCities = () => {
ai.supplyCities = {
1: { ...baseCity(), dev: 1, important: 0, generals: {} },
2: {
...baseCity(),
id: 2,
agriculture: 1000,
commerce: 1000,
security: 1000,
defence: 1000,
wall: 1000,
dev: 0.1,
important: 0,
generals: {},
},
};
};
ai.categorizeNationGeneral = () => {
throw new Error('Ref does not categorize generals in do내정워프');
};
expect(do내정워프(ai)?.action).toBe('che_NPC능동');
});
it('awards a resource-poor civil user general like the legacy nation AI', () => {
const ai = makeAi();
const civilGeneral = {
@@ -553,6 +870,116 @@ describe('legacy NPC AI final-decision parity', () => {
expect(do유저장포상(ai)?.action).toBe('che_포상');
});
it('consumes the legacy reward draw before a selected command fails constraints', () => {
const rng = makeRng();
const ai = makeAi({ rng, blockedActions: ['che_포상'] });
const civilGeneral = {
...baseGeneral(),
id: 2,
npcState: 0,
gold: 0,
rice: 20_000,
meta: { killturn: 100, fullLeadership: 70 },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
ai.userGenerals = { 2: civilGeneral };
ai.userWarGenerals = {};
expect(do유저장포상(ai)).toBeNull();
expect(rng.weightedPairs).toHaveLength(1);
});
it('keeps Ref multiplication order at an exact NPC reward resource boundary', () => {
const rng = makeRng();
const ai = makeAi({
year: 185,
startYear: 180,
nation: { rice: 12_088, meta: { tech: 1_011.416320800781 } },
rng,
});
ai.nationPolicy.reqNationRice = 10_000;
ai.nationPolicy.reqNpcWarRice = 4_000;
ai.maxResourceActionAmount = 2_400;
const candidate = (id: number, leadership: number, rice: number) => ({
...baseGeneral(),
id,
stats: { ...baseGeneral().stats, leadership },
rice,
crewTypeId: 1,
meta: { killturn: 100, fullLeadership: leadership },
});
ai.npcWarGenerals = {
180: candidate(180, 88, 3_005),
743: candidate(743, 80, 3_036),
};
ai.npcCivilGenerals = {};
expect(doNPC포상(ai)).toMatchObject({
action: 'che_포상',
args: { destGeneralId: 180, isGold: false },
});
expect(rng.weightedPairs[0]?.[0]?.[0]).toMatchObject({ destGeneralId: 180 });
expect(rng.weightedPairs[0]).toHaveLength(1);
});
it('excludes no-population recruitment specialists before NPC rear assignment draws RNG', () => {
const rng = makeRng([], [0, 0]);
const specialist = {
...baseGeneral(),
id: 2,
cityId: 2,
crew: 0,
role: { ...baseGeneral().role, specialWar: 'che_징병' },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
const ai = makeAi({
dipState: 4,
rng,
generals: [baseGeneral(), specialist],
generalActionModules: singleActionModuleStack(
{
eventHandlers: {},
onCalcDomestic: (context, turnType, varType, value) =>
context.general.id === 2 && turnType === '징집인구' && varType === 'score' ? 0 : value,
}
),
});
ai.frontCities = { 1: { ...baseCity(), frontState: 3, dev: 1, important: 1 } };
ai.supplyCities = {
2: {
...baseCity(),
id: 2,
population: 40_000,
populationMax: 100_000,
dev: 1,
important: 1,
},
3: { ...baseCity(), id: 3, population: 100_000, dev: 1, important: 1 },
};
ai.backupCities = { 3: ai.supplyCities[3]! };
ai.npcWarGenerals = { 2: specialist };
expect(doNPC후방발령(ai)).toBeNull();
expect(rng.choices).toEqual([0, 0]);
});
it('draws the NPC front-assignment general before the weighted destination city', () => {
const rng = makeRng([], [1, 20]);
const first = { ...baseGeneral(), id: 2, crew: 3000, train: 100, atmos: 100 };
const second = { ...baseGeneral(), id: 3, crew: 3000, train: 100, atmos: 100 };
const ai = makeAi({ dipState: 4, rng });
ai.npcWarGenerals = { 2: first, 3: second };
ai.nationCities = { 1: { ...baseCity(), dev: 1, important: 1 } };
ai.frontCities = {
20: { ...baseCity(), id: 20, frontState: 2, dev: 1, important: 1 },
};
expect(doNPC전방발령(ai)).toMatchObject({
action: 'che_발령',
args: { destGeneralId: 3, destCityId: 20 },
});
});
it('seizes a small war-NPC surplus while the treasury is below 1.5x reserve', () => {
const ai = makeAi({ nation: { gold: 12_000, rice: 100_000 } });
const warGeneral = {
@@ -152,6 +152,49 @@ const makeState = (): TurnWorldState => ({
});
describe('legacy general-turn execution contract', () => {
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(makeGeneral()),
state: makeState(),
schedule,
map,
});
harness.world.updateGeneral(1, {
experience: 10.5,
dedication: 20.49,
gold: 1_000.5,
rice: 1_000.49,
meta: { killturn: 24, dex4: 100.5, intel_exp: 29.5 },
});
expect(harness.world.getGeneralById(1)).toMatchObject({
experience: 11,
dedication: 20,
gold: 1_001,
rice: 1_000,
meta: { killturn: 24, dex4: 101, intel_exp: 30 },
});
});
it('applies inherited domestic stat progression after farming', async () => {
const general = makeGeneral({ meta: { killturn: 24, intel_exp: 29 } });
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_농지개간', args: {} };
await harness.runOneTick();
expect(harness.world.getGeneralById(1)).toMatchObject({
stats: { leadership: 80, strength: 70, intelligence: 61 },
meta: { intel_exp: 0 },
});
});
it('runs injury recovery and troop rice consumption before the reserved command', async () => {
const general = makeGeneral({ injury: 25, crew: 200, rice: 1 });
const harness = await createTurnTestHarness({
@@ -3,6 +3,7 @@ import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type N
import { createIncomeHandler } from '../src/turn/incomeHandler.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js';
import {
createNewYearHandler,
createNoticeToHistoryLogHandler,
@@ -10,6 +11,7 @@ import {
createResetOfficerLockHandler,
} from '../src/turn/monthlyCoreEventAction.js';
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js';
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const map: MapDefinition = {
@@ -318,4 +320,148 @@ describe('core monthly event actions at the real month boundary', () => {
},
});
});
it('keeps fractional income for the legacy salary payout ratio', async () => {
const nation = buildNation();
nation.gold = 0;
nation.meta = { ...nation.meta, rate: 15, rate_tmp: 15, bill: 100 };
const world = buildWorld(
[
{
id: 4,
targetCode: 'month',
priority: 1,
condition: true,
action: [['ProcessIncome', 'gold']],
meta: {},
},
],
(getWorld) => {
const incomeHandler = createIncomeHandler({
getWorld,
scenarioConfig: {
stat: {
total: 300,
min: 10,
max: 100,
npcTotal: 150,
npcMax: 50,
npcMin: 10,
chiefMin: 70,
},
iconPath: '',
map: {},
const: { baseGold: 0, baseRice: 0 },
environment: { mapName: map.id, unitSet: 'default' },
},
nationTraits: new Map(),
});
return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]);
},
{
currentMonth: 12,
generals: [buildGeneral(1, 1, 3)],
nations: [nation],
cities: [buildCity()],
}
);
await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5);
});
it('uses the Ref default nation resource floors when scenario const omits them', async () => {
const nation = buildNation();
nation.rice = 0;
const general = buildGeneral(1, 1, 3);
const world = buildWorld(
[
{
id: 5,
targetCode: 'month',
priority: 1,
condition: true,
action: [['ProcessIncome', 'rice']],
meta: {},
},
],
(getWorld) => {
const incomeHandler = createIncomeHandler({
getWorld,
scenarioConfig: {
stat: {
total: 300,
min: 10,
max: 100,
npcTotal: 150,
npcMax: 50,
npcMin: 10,
chiefMin: 70,
},
iconPath: '',
map: {},
const: {},
environment: { mapName: map.id, unitSet: 'default' },
},
nationTraits: new Map(),
});
return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]);
},
{
currentMonth: 6,
generals: [general],
nations: [nation],
cities: [buildCity()],
}
);
await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
expect(world.getNationById(1)?.rice).toBe(2_000);
expect(world.getGeneralById(1)?.rice).toBe(general.rice);
});
it('excludes the NPC ruler stipend when choosing the nation bill like Ref', () => {
const config: TurnWorldSnapshot['scenarioConfig'] = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: { baseGold: 0, baseRice: 0 },
environment: { mapName: map.id, unitSet: 'default' },
};
const buildFinanceWorld = (chiefDedication: number) => {
const chief = buildGeneral(1, 1, 3);
chief.npcState = 2;
chief.officerLevel = 12;
chief.dedication = chiefDedication;
const subordinate = buildGeneral(2, 1, 3);
subordinate.npcState = 2;
return buildWorld([], () => new Map(), {
currentMonth: 12,
generals: [chief, subordinate],
nations: [buildNation()],
cities: [buildCity()],
});
};
const options = { scenarioConfig: config, commandEnv: buildCommandEnv(config) };
const lowChiefWorld = buildFinanceWorld(1);
const highChiefWorld = buildFinanceWorld(100_000);
const lowChiefBill = calculateNpcNationFinance(
lowChiefWorld,
lowChiefWorld.getNationById(1)!,
12,
options
)?.bill;
const highChiefBill = calculateNpcNationFinance(
highChiefWorld,
highChiefWorld.getNationById(1)!,
12,
options
)?.bill;
expect(lowChiefBill).toBeTypeOf('number');
expect(highChiefBill).toBe(lowChiefBill);
});
});
@@ -140,7 +140,9 @@ describe('monthly event pipeline', () => {
[
'Trace',
(args, environment) => {
trace.push(`${String(args[0])}:${environment.year}-${environment.month}`);
trace.push(
`${String(args[0])}:${environment.year}-${environment.month}:${environment.turnTime.toISOString()}`
);
},
],
]);
@@ -176,7 +178,11 @@ describe('monthly event pipeline', () => {
await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']);
expect(trace).toEqual([
'pre:189-12:0189-12-31T23:50:00.000Z',
'month-high:190-1:0189-12-31T23:50:00.000Z',
'month-low:190-1:0189-12-31T23:50:00.000Z',
]);
});
it('supports logic conditions and persists DeleteEvent through dirty state', async () => {
@@ -236,7 +242,7 @@ describe('monthly event pipeline', () => {
},
],
actions,
[buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7), buildCity(6, 8)]
[buildCity(6, 8), buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7)]
);
actions.set(
'RandomizeCityTradeRate',
@@ -254,12 +260,12 @@ describe('monthly event pipeline', () => {
marker: city.meta.marker,
}))
).toEqual([
{ id: 6, trade: 102, marker: 6 },
{ id: 1, trade: null, marker: 1 },
{ id: 2, trade: null, marker: 2 },
{ id: 3, trade: 101, marker: 3 },
{ id: 4, trade: 100, marker: 4 },
{ id: 5, trade: 105, marker: 5 },
{ id: 6, trade: 102, marker: 6 },
]);
expect(world.peekDirtyState().cities.map((city) => city.id)).toEqual([1, 2, 3, 4, 5, 6]);
});
@@ -386,7 +392,7 @@ describe('monthly event pipeline', () => {
wall: 81,
meta: { trade: 100, marker: 1 },
});
expect(damagedCity?.meta.trust).toBeCloseTo(79.6);
expect(damagedCity?.meta.trust).toBe(Math.fround(79.6));
expect(world.getGeneralById(1)).toMatchObject({ injury: 0, crew: 99, atmos: 50, train: 51 });
expect(world.getGeneralById(2)).toMatchObject({ injury: 7, crew: 97, atmos: 49, train: 50 });
expect(world.getGeneralById(3)).toMatchObject({ injury: 80, crew: 97, atmos: 49, train: 50 });
@@ -50,7 +50,7 @@ const buildCity = (id: number, nationId = 1, level = 4): City => ({
meta: {},
});
const buildNation = (level: number): Nation => ({
const buildNation = (level: number, patch: Partial<Nation> = {}): Nation => ({
id: 1,
name: '위',
color: '#000000',
@@ -62,6 +62,7 @@ const buildNation = (level: number): Nation => ({
level,
typeCode: 'che_중립',
meta: { marker: 1 },
...patch,
});
const buildGeneral = (id: number, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
@@ -113,6 +114,7 @@ const buildHarness = async (
itemModules?: ItemModule[];
configConst?: Record<string, unknown>;
additionalOccupiedCounts?: Map<string, number>;
neutralCityCount?: number;
} = {}
) => {
const state: TurnWorldState = {
@@ -143,8 +145,27 @@ const buildHarness = async (
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })],
cities: Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
nations: [buildNation(nationLevel)],
cities: [
...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) =>
buildCity(cityCount + index + 1, 0)
),
],
nations: [
...(options.neutralCityCount
? [
buildNation(0, {
id: 0,
name: '재야',
capitalCityId: null,
chiefGeneralId: null,
gold: 0,
rice: 0,
}),
]
: []),
buildNation(nationLevel),
],
troops: [],
diplomacy: [],
events: [event],
@@ -236,6 +257,15 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.peekDirtyState().logs).toEqual([]);
});
it('never promotes the Core-only neutral nation sentinel from neutral cities', async () => {
const { world, handler } = await buildHarness(0, 0, { neutralCityCount: 21 });
await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event);
expect(world.getNationById(0)).toMatchObject({ level: 0, gold: 0, rice: 0 });
expect(world.peekDirtyState().logs).toEqual([]);
});
it('sets both rename permissions only on promotion to emperor', async () => {
const { world, handler } = await buildHarness(6, 21);
@@ -8,6 +8,7 @@ import {
createMonthlyNationCountHandler,
createMonthlyNationStatsHandler,
createMonthlyWarSettingHandler,
roundLegacyNationPowerValue,
} from '../src/turn/monthlyNationStatsHandler.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
@@ -107,6 +108,10 @@ const buildNation = (
});
describe('monthly nation statistics boundary', () => {
it('stabilizes MariaDB decimal half boundaries before rounding', () => {
expect(roundLegacyNationPowerValue(342.49999999999994)).toBe(343);
});
it('matches the fixed legacy power, maxima, war-setting count, and final general cache', async () => {
const state: TurnWorldState = {
id: 1,
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import type { City, Nation, NationTraitModule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createProcessSemiAnnualHandler } from '../src/turn/monthlySemiAnnualAction.js';
import {
createProcessSemiAnnualHandler,
storeLegacySemiAnnualTrust,
} from '../src/turn/monthlySemiAnnualAction.js';
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const buildCity = (id: number, patch: Partial<City> = {}): City => ({
@@ -151,6 +154,11 @@ const environment = {
};
describe('ProcessSemiAnnual monthly action', () => {
it('stores the adjusted trust at the MariaDB FLOAT boundary', () => {
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).toBe(Math.fround(98.30675));
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).not.toBe(98.30675);
});
it('preserves the global popIncrease order, neutral double decay, supplied filtering, and nation trait', async () => {
const { world, handler } = buildHarness({
configConst: { basePopIncreaseAmount: 15_000 },
@@ -241,6 +249,17 @@ describe('ProcessSemiAnnual monthly action', () => {
});
});
it('rounds decimal half boundaries like MariaDB ROUND', async () => {
const { world, handler } = buildHarness({
cities: [buildCity(1, { defence: 2_000, wall: 2_000 })],
nations: [buildNation(1, { meta: { rate: 15 } })],
});
await handler(['gold'], environment, event);
expect(world.getCityById(1)).toMatchObject({ defence: 2_030, wall: 2_030 });
});
it('applies strict legacy resource thresholds to generals and nations for either resource', async () => {
const amounts = [1_000, 1_001, 10_000, 10_001, 100_000, 100_001];
const { world, handler } = buildHarness({
+2 -1
View File
@@ -120,7 +120,7 @@ describeDb('scenario database seed', () => {
expect(worldState?.config).toMatchObject({ tournamentTrig: true });
expect(generalCount).toBeGreaterThan(0);
const seededGeneral = await prisma.general.findFirst();
expect(seededGeneral?.startAge).toBe(seededGeneral?.age);
expect(seededGeneral?.startAge).toBe(20);
expect(seededGeneral?.meta).toEqual(
expect.objectContaining({
specage: expect.any(Number),
@@ -239,6 +239,7 @@ describeDb('scenario database seed', () => {
expect(config.tournamentTrig).toBe(false);
const meta = (worldState.meta ?? {}) as Record<string, unknown>;
expect(meta.develcost).toBe((worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2);
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
expect(autorunOptions.develop).toBe(true);
@@ -83,7 +83,7 @@ describe('monthly tournament auto start', () => {
auto: true,
openYear: 193,
openMonth: 2,
termSeconds: 600,
termSeconds: 10,
nextAt: '2026-07-25T00:10:00.000Z',
});
expect(world.getState().meta.tournamentPattern).toEqual([0, 1, 2]);
@@ -218,8 +218,8 @@ describe('monthly tournament auto start', () => {
const second = await run();
expect(second).toEqual(first);
expect(first).toEqual({
tournamentState: expect.objectContaining({ type: 1 }),
remainingPattern: [2, 0, 0, 3],
tournamentState: expect.objectContaining({ type: 0, bettingId: 1 }),
remainingPattern: [1, 3, 2, 0],
});
} finally {
random.mockRestore();
@@ -280,12 +280,11 @@ describe('TurnDaemonLifecycle', () => {
await loop;
});
it('runs scheduled turn based on queue front and checkpoint context', async () => {
it('catches up through the observed clock with queue and checkpoint context', async () => {
const turnTermMinutes = 10;
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
const generalTurnQueue = [addMinutes(lastTurnTime, 5), addMinutes(lastTurnTime, 20)];
const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes);
const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime());
const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime();
const checkpoint = {
turnTime: lastTurnTime.toISOString(),
generalId: 101,
@@ -358,12 +357,11 @@ describe('TurnDaemonLifecycle', () => {
await loop;
});
it('runs scheduled turn when tick boundary arrives before queue front', async () => {
it('catches up through the observed clock when tick boundary precedes the queue front', async () => {
const turnTermMinutes = 10;
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
const generalTurnQueue = [addMinutes(lastTurnTime, 15), addMinutes(lastTurnTime, 30)];
const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes);
const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime());
const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime();
const checkpoint = {
turnTime: lastTurnTime.toISOString(),
generalId: 102,
+13 -3
View File
@@ -150,13 +150,23 @@ describe('InMemoryTurnProcessor ordering', () => {
},
});
await processor.run(addMinutes(baseTime, 30), {
const budget = {
budgetMs: 1000,
maxGenerals: 10,
catchUpCap: 1,
});
};
expect(executed).toEqual([2, 3, 1]);
const boundaryResult = await processor.run(addMinutes(baseTime, 10), budget);
expect(boundaryResult.processedTurns).toBe(1);
expect(executed).toEqual([]);
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
expect(tiedGeneralResult.processedTurns).toBe(0);
expect(executed).toEqual([2, 3]);
await processor.run(addMinutes(baseTime, 30), budget);
expect(executed).toEqual([2, 3, 1, 2, 3]);
expect(world.getNextGeneralId()).toBe(4);
expect(world.getNextGeneralId()).toBe(5);
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });