fix: align scenario 2400 monthly parity

This commit is contained in:
2026-08-05 13:25:39 +00:00
parent f731f1f38e
commit 5d6923f11a
26 changed files with 564 additions and 89 deletions
+122 -19
View File
@@ -10,10 +10,11 @@ import type {
} from '@sammo-ts/logic';
import { evaluateConstraints } from '@sammo-ts/logic';
import type { ConstraintContext } from '@sammo-ts/logic';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, 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 { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
import type { TurnGeneral, TurnWorldState } from '../../types.js';
@@ -50,6 +51,19 @@ const d징병 = 2;
const d직전 = 3;
const d전쟁 = 4;
export const calculateRecentWarTurn = (general: TurnGeneral, turnTermMinutes: number): number => {
if (general.recentWarTick !== null && general.recentWarTick !== undefined && general.turnTick !== undefined) {
const tickDiff = general.turnTick - general.recentWarTick;
return tickDiff <= 0 ? 0 : Math.floor(tickDiff / GAME_TICKS_PER_TURN);
}
const recent = general.recentWarTime;
if (!recent) return 12000;
const diffMs = general.turnTime.getTime() - recent.getTime();
if (diffMs <= 0) return 0;
const turnMs = turnTermMinutes * 60 * 1000;
return turnMs > 0 ? Math.floor(diffMs / turnMs) : 12000;
};
export const selectNpcMessageForTurn = (
message: unknown,
rng: Pick<RandUtil, 'nextBool'>,
@@ -86,6 +100,66 @@ export const resolveLegacyAiStats = (
};
};
export const resolveLegacyAiStatsWithModules = (
general: TurnGeneral,
nation: Nation | null | undefined,
maxStatLevel: number,
modules: TurnCommandEnv['generalActionModules'],
worldRef: AiWorldView | null,
world: TurnWorldState,
startYear: number
) => {
const maxLevel = Math.max(1, maxStatLevel);
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
const pipeline = new GeneralActionPipeline(modules ?? []);
const context = {
general,
nation,
...(worldRef
? {
worldView: {
listGenerals: () => worldRef.listGenerals(),
listGeneralsByCity: (cityId: number) =>
worldRef.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => worldRef.listNations(),
},
}
: {}),
time: {
year: world.currentYear,
month: world.currentMonth,
startYear,
},
};
const rawStat = (statName: 'leadership' | 'strength' | 'intelligence'): number => general.stats[statName];
const calculate = (
statName: 'leadership' | 'strength' | 'intelligence',
withInjury: boolean,
withStatAdjust: boolean,
truncate: boolean
): number => {
const injuryRatio = withInjury ? (100 - Math.max(0, Math.min(general.injury, 100))) / 100 : 1;
let value = rawStat(statName) * injuryRatio;
if (withStatAdjust && statName === 'strength') {
value += Math.round(calculate('intelligence', withInjury, false, false) / 4);
} else if (withStatAdjust && statName === 'intelligence') {
value += Math.round(calculate('strength', withInjury, false, false) / 4);
}
value = clampStat(value);
value = clampStat(Number(pipeline.onCalcStat(context, statName, value)));
return truncate ? Math.trunc(value) : value;
};
return {
fullLeadership: calculate('leadership', false, true, true),
fullStrength: calculate('strength', false, true, true),
fullIntelligence: calculate('intelligence', false, true, true),
effectiveLeadership: calculate('leadership', true, true, true),
effectiveStrength: calculate('strength', true, true, true),
effectiveIntelligence: calculate('intelligence', true, true, true),
};
};
export class GeneralAI {
public general: TurnGeneral;
public city?: City;
@@ -560,6 +634,28 @@ export class GeneralAI {
return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason);
}
getLastNationTurn(): Record<string, unknown> {
return asRecord(asRecord(this.nation?.meta)[`turn_last_${this.general.officerLevel}`]);
}
getLastCapitalMoveTrial(): [number, number] | null {
const raw = asRecord(this.nation?.meta).lastCapitalMoveTrial;
if (!Array.isArray(raw) || raw.length < 2) return null;
const officerLevel = Number(raw[0]);
const turnTick = Number(raw[1]);
return Number.isFinite(officerLevel) && Number.isFinite(turnTick) ? [officerLevel, turnTick] : null;
}
markCapitalMoveTrial(): void {
if (!this.nation || this.general.turnTick === undefined) return;
const nextMeta = {
...(this.promotionNationMeta ?? this.nation.meta),
lastCapitalMoveTrial: [this.general.officerLevel, this.general.turnTick],
};
this.nation = { ...this.nation, meta: nextMeta as Nation['meta'] };
this.promotionNationMeta = nextMeta;
}
getReservedTurn(generalId: number): ReservedTurnEntry {
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
}
@@ -930,11 +1026,21 @@ export class GeneralAI {
}
private refreshLegacyFullStats(): void {
const stats = resolveLegacyAiStats(
this.general,
this.nation,
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
);
const stats = this.commandEnv.generalActionModules
? resolveLegacyAiStatsWithModules(
this.general,
this.nation,
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max,
this.commandEnv.generalActionModules,
this.worldRef,
this.world,
this.startYear
)
: resolveLegacyAiStats(
this.general,
this.nation,
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
);
this.general.meta = {
...this.general.meta,
...stats,
@@ -1129,6 +1235,15 @@ export class GeneralAI {
lastAttackable = yearMonth;
worldLastAttackable.set(nationId, yearMonth);
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
// Ref writes nation_env.last_attackable while constructing each
// GeneralAI instance. Carry that side effect through the existing
// nation-meta patch channel even when no promotion was selected.
// Promotion runs first in quarter months, so preserve a chief_set
// patch already accumulated by choose*Promotion().
this.promotionNationMeta = {
...(this.promotionNationMeta ?? this.nation!.meta),
last_attackable: yearMonth,
};
};
if (minWarTerm === null) {
@@ -1163,19 +1278,7 @@ export class GeneralAI {
}
private calcRecentWarTurn(general: TurnGeneral): number {
const recent = general.recentWarTime;
if (!recent) {
return 12000;
}
const diffMs = general.turnTime.getTime() - recent.getTime();
if (diffMs <= 0) {
return 0;
}
const turnMs = this.turnTermMinutes * 60 * 1000;
if (turnMs <= 0) {
return 12000;
}
return Math.floor(diffMs / turnMs);
return calculateRecentWarTurn(general, this.turnTermMinutes);
}
}
@@ -121,12 +121,17 @@ export const doNPC구출발령 = (ai: GeneralAI) => {
if (lostCandidates.length === 0) {
return null;
}
const destCityId = pickRandomCityId(ai, ai.supplyCities);
if (destCityId === null) {
const candidates = lostCandidates.flatMap((general) => {
const destCityId = pickRandomCityId(ai, ai.supplyCities);
return destCityId === null ? [] : [{ general, destCityId }];
});
if (candidates.length === 0) {
return null;
}
const destGeneral = ai.rng.choice(lostCandidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령');
// Ref draws one destination city for every lost general, then chooses one
// completed (general, city) pair. The unused pairs still consume RNG.
const picked = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, picked.general.id, picked.destCityId, 'NPC구출발령');
};
export const doNPC전방발령 = (ai: GeneralAI) => {
@@ -1,4 +1,5 @@
import type { GeneralAI } from '../core.js';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { calcCityDevRatio } from '../../aiUtils.js';
import { searchAllDistanceByCityList } from '../../distance.js';
@@ -9,6 +10,33 @@ export const do천도 = (ai: GeneralAI) => {
if (!ai.map) {
return null;
}
const lastTurn = ai.getLastNationTurn();
const lastArgs =
lastTurn.arg && typeof lastTurn.arg === 'object' ? (lastTurn.arg as Record<string, unknown>) : null;
const lastDestination = Number(lastArgs?.destCityID ?? lastArgs?.destCityId);
if (
lastTurn.command === '천도' &&
Number.isFinite(lastDestination) &&
lastDestination !== ai.nation.capitalCityId
) {
const continuing = ai.buildNationCandidate('che_천도', { destCityID: lastDestination }, '천도');
if (continuing) {
ai.markCapitalMoveTrial();
return continuing;
}
}
const lastTrial = ai.getLastCapitalMoveTrial();
const currentTurnTick = ai.general.turnTick;
if (
lastTrial &&
currentTurnTick !== undefined &&
Math.abs(currentTurnTick - lastTrial[1]) < Math.floor(GAME_TICKS_PER_TURN / 2) &&
lastTrial[0] !== ai.general.officerLevel
) {
return null;
}
const nationCities = Object.values(ai.nationCities);
if (nationCities.length <= 1) {
return null;
@@ -80,5 +108,7 @@ export const do천도 = (ai: GeneralAI) => {
}
}
return ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
const candidate = ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
if (candidate) ai.markCapitalMoveTrial();
return candidate;
};