fix: match scenario 2601 monthly seed progression
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user