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
+46 -12
View File
@@ -50,8 +50,11 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport)
const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0);
const setDeadCounter = (city: City, value: number): void => {
city.meta[META_DEAD] = round(value);
const increaseDeadCounter = (city: City, delta: number): void => {
// Ref binds each `dead + %i` increment as an integer before MariaDB adds
// it. Truncate each 40/60 percent split independently; rounding the
// accumulated counter changes monthly recovery and war income.
city.meta[META_DEAD] = getDeadCounter(city) + Math.trunc(delta);
};
const isSupplyCity = (city: City): boolean => {
@@ -122,11 +125,16 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
}
const divisor = Math.max(config.initialNationGenLimit, total);
const tech = getMetaNumber(nation.meta, 'tech', 0) + gain / divisor;
// Legacy MySQL FLOAT values are read back at the command boundary with
// two-decimal precision. Preserve fractional accumulation without
// converting the gain to an integer.
nation.meta.tech = Math.round(tech * 100) / 100;
const currentTech = getMetaNumber(nation.meta, 'tech', 0);
const delta = gain / divisor;
// Ref executes `tech + delta` inside MariaDB for battle gains, so the
// arithmetic starts from the stored binary32 value without a PHP text read.
nation.meta.tech = Math.fround(currentTech + delta);
if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) {
process.stdout.write(
`WAR_TECH_TRACE ${JSON.stringify({ engine: 'core', nationId: nation.id, side: context.side, currentTech, baseGain, gain, total, effective, divisor, delta, storedTech: nation.meta.tech, attackerGeneralId: context.attackerReport.id })}\n`
);
}
};
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
@@ -247,10 +255,22 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
let collapseRewardGold = 0;
let collapseRewardRice = 0;
const ruinedNpcJoinPlans: ConquerCityOutcome<TriggerState>['ruinedNpcJoinPlans'] = [];
// 국가 붕괴 시 자원 손실과 포상 정산.
if (nationCollapsed && defenderNation) {
const defenderGenerals = generals.filter((general) => general.nationId === defenderNationId);
const defenderGenerals = generals
.filter((general) => general.nationId === defenderNationId)
.sort((lhs, rhs) => {
// deleteNation() reads the non-lord rows in primary-key order,
// then appends the lord object to the returned PHP array.
const lhsIsLord = lhs.id === defenderNation.chiefGeneralId;
const rhsIsLord = rhs.id === defenderNation.chiefGeneralId;
if (lhsIsLord !== rhsIsLord) {
return lhsIsLord ? 1 : -1;
}
return lhs.id - rhs.id;
});
let totalGoldLoss = 0;
let totalRiceLoss = 0;
@@ -290,6 +310,21 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
);
pushLoggers([generalLogger], logs);
affectedGenerals.add(general);
if (config.joinMode !== 'onlyRandom') {
// Ref attempts to build/send a scout message after every loss.
// Message availability does not affect this draw.
rng.nextBool(0.5);
const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5;
if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) {
ruinedNpcJoinPlans.push({
generalId: general.id,
destNationId: attackerNation.id,
joinTurn: rng.nextRangeInt(0, 12),
});
}
}
}
collapseRewardGold = Math.floor((Math.max(0, defenderNation.gold - config.baseGold) + totalGoldLoss) / 2);
@@ -426,6 +461,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
nations: Array.from(affectedNations),
cities: Array.from(affectedCities),
generals: Array.from(affectedGenerals),
ruinedNpcJoinPlans,
};
};
@@ -447,10 +483,8 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
// 전투 사망자 누적: 공격/수비 도시로 분배.
if (totalDead > 0) {
const attackerCityDead = getDeadCounter(input.attackerCity) + totalDead * 0.4;
const defenderCityDead = getDeadCounter(input.defenderCity) + totalDead * 0.6;
setDeadCounter(input.attackerCity, attackerCityDead);
setDeadCounter(input.defenderCity, defenderCityDead);
increaseDeadCounter(input.attackerCity, totalDead * 0.4);
increaseDeadCounter(input.defenderCity, totalDead * 0.6);
affectedCities.add(input.attackerCity);
affectedCities.add(input.defenderCity);
}
+27 -21
View File
@@ -1,6 +1,6 @@
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
@@ -19,10 +19,7 @@ import type {
import { getMetaNumber } from './utils.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js';
const META_FULL_LEADERSHIP = 'fullLeadership';
const META_FULL_STRENGTH = 'fullStrength';
const META_FULL_INTELLIGENCE = 'fullIntelligence';
const META_DEFENCE_TRAIN = 'defenceTrain';
const META_DEFENCE_TRAIN = 'defence_train';
const defaultLoggerFactory = (options: { generalId?: number; nationId?: number }): ActionLogger =>
new ActionLogger(options);
@@ -101,18 +98,6 @@ const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry):
return caller;
};
const resolveFullStats = (
general: General
): {
leadership: number;
strength: number;
intelligence: number;
} => ({
leadership: getMetaNumber(general.meta, META_FULL_LEADERSHIP, general.stats.leadership),
strength: getMetaNumber(general.meta, META_FULL_STRENGTH, general.stats.strength),
intelligence: getMetaNumber(general.meta, META_FULL_INTELLIGENCE, general.stats.intelligence),
});
const isSupplyCity = (city: City): boolean => {
const supply = city.meta.supply;
if (typeof supply === 'boolean') {
@@ -153,9 +138,17 @@ export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
return 0;
}
const realStat = general.stats.leadership + general.stats.strength + general.stats.intelligence;
const fullStats = resolveFullStats(general);
const fullStat = fullStats.leadership + fullStats.strength + fullStats.intelligence;
// Ref calls General::getLeadership/Strength/Intel() for every defender at
// battle time. That applies injury, stat cross-adjustment, officer/items/
// traits and integer conversion through the defender's action pipeline.
const realStat =
defender.getComputedStat('leadership', general.stats.leadership) +
defender.getComputedStat('strength', general.stats.strength) +
defender.getComputedStat('intelligence', general.stats.intelligence);
const fullStat =
defender.getComputedStat('leadership', general.stats.leadership, { withInjury: false }) +
defender.getComputedStat('strength', general.stats.strength, { withInjury: false }) +
defender.getComputedStat('intelligence', general.stats.intelligence, { withInjury: false });
const totalStat = (realStat + fullStat) / 2;
const totalCrew = (general.crew / 1_000_000) * Math.pow(general.train * general.atmos, 1.5);
@@ -330,6 +323,12 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
defenderUnits.push(cityUnit);
}
const summarizeDefenderOrder = (unit: WarUnit<TriggerState>) => ({
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : 0,
order: computeBattleOrder<TriggerState>(unit, attackerUnit),
});
const defenderOrderBeforeSort = defenderUnits.map(summarizeDefenderOrder);
defenderUnits.sort(
(lhs, rhs) =>
computeBattleOrder<TriggerState>(rhs, attackerUnit) - computeBattleOrder<TriggerState>(lhs, attackerUnit)
@@ -373,6 +372,10 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
details,
});
};
emitTrace('defender_order', defender, {
before: defenderOrderBeforeSort,
after: defenderUnits.map(summarizeDefenderOrder),
});
emitTrace('battle_start', defender, { seed: input.seed ?? '' });
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
@@ -399,7 +402,10 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
defender = cityUnit;
cityUnit.setSiege();
const defenderRice = input.defenderNation?.rice ?? 0;
// Ref builds a virtual neutral nation with 10,000 rice. Treating a
// neutral city's missing Nation row as zero skips the entire siege
// through the supply-retreat branch and changes every later war.
const defenderRice = input.defenderNation?.rice ?? 10_000;
if (isSupplyCity(input.defenderCity) && defenderRice <= 0) {
attackerUnit.setOppose(defender);
defender.setOppose(attackerUnit);
+12
View File
@@ -25,6 +25,7 @@ export interface WarEngineConfig {
maxTrainByWar: number;
maxAtmosByWar: number;
maxGeneralStat?: number;
statUpgradeLimit?: number;
castleCrewTypeId: number;
armTypes: WarArmTypes;
}
@@ -138,6 +139,16 @@ export interface WarAftermathConfig {
baseGold: number;
baseRice: number;
castleCrewTypeId: number;
/** Legacy admin join mode. Ruined-nation scout/join draws are skipped in onlyRandom mode. */
joinMode?: 'full' | 'onlyRandom';
/** Probability that an eligible ruined NPC reserves a future appointment. */
joinRuinedNpcProbability?: number;
}
export interface RuinedNpcJoinPlan {
generalId: number;
destNationId: number;
joinTurn: number;
}
export interface WarAftermathTechContext {
@@ -162,6 +173,7 @@ export interface ConquerCityOutcome<TriggerState extends GeneralTriggerState = G
nations: Nation[];
cities: City[];
generals: General<TriggerState>[];
ruinedNpcJoinPlans: RuinedNpcJoinPlan[];
}
export interface WarAftermathInput<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
+31 -2
View File
@@ -322,7 +322,7 @@ export class WarUnitGeneral<
}
const atmosMultiplier = this.isAttacker() ? 1.1 : 1.05;
this.general.atmos = clamp(Math.round(this.general.atmos * atmosMultiplier), 0, this.config.maxAtmosByWar);
this.general.atmos = clamp(this.general.atmos * atmosMultiplier, 0, this.config.maxAtmosByWar);
this.addStatExp(1);
}
@@ -384,7 +384,10 @@ export class WarUnitGeneral<
nextExp *= 0.9;
}
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', nextExp, { armType });
this.general.meta[key] = base + adjustedExp;
// PHP interpolates floats into MeekroDB SQL with precision=14 before
// MariaDB rounds the integer dex column. Normalize this accumulated
// battle value at the same boundary (for example ...499999999996 -> .5).
this.general.meta[key] = Number((base + adjustedExp).toPrecision(14));
}
public calcRiceConsumption(damage: number): number {
@@ -529,5 +532,31 @@ export class WarUnitGeneral<
this.general.rice = round(this.general.rice);
this.general.experience = round(this.general.experience);
this.general.dedication = round(this.general.dedication);
// Ref WarUnitGeneral::finishBattle() runs General::checkStatChange()
// before persisting the participant. A battle can therefore consume
// one accumulated stat-exp threshold even though che_출병 itself does
// not have the generic command progression tail.
const limit = this.config.statUpgradeLimit ?? 30;
const maxStat = this.config.maxGeneralStat ?? 255;
const entries = [
['leadership', META_LEADERSHIP_EXP, '통솔'],
['strength', META_STRENGTH_EXP, '무력'],
['intelligence', META_INTEL_EXP, '지력'],
] as const;
for (const [statKey, expKey, label] of entries) {
const statExp = getMetaNumber(this.general.meta, expKey);
if (statExp < 0) {
this.general.meta[expKey] = statExp + limit;
this.general.stats[statKey] -= 1;
this.logger.pushGeneralActionLog(`<R>${label}</>이 <C>1</> 떨어졌습니다!`, LogFormat.PLAIN);
} else if (statExp >= limit) {
if (this.general.stats[statKey] < maxStat) {
this.general.stats[statKey] += 1;
this.logger.pushGeneralActionLog(`<S>${label}</>이 <C>1</> 올랐습니다!`, LogFormat.PLAIN);
}
this.general.meta[expKey] = statExp - limit;
}
}
}
}
+13 -1
View File
@@ -11,7 +11,19 @@ export const clampMin = (value: number, min: number): number => (value < min ? m
export const clampMax = (value: number, max: number): number => (value > max ? max : value);
export const round = (value: number): number => Math.round(value);
// PHP's round() compensates for small binary floating-point drift around a
// half boundary and rounds halves away from zero. War state is persisted to
// integer columns through legacy Util::round(), so Math.round() is not enough:
// e.g. accumulated siege damage can produce 4159.499999999999, which PHP
// rounds to 4160 while Math.round() returns 4159.
export const round = (value: number): number => {
if (!Number.isFinite(value)) {
return Math.round(value);
}
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value));
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
};
export const getMetaNumber = (meta: Record<string, TriggerValue>, key: string, fallback = 0): number => {
const value = meta[key];