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
@@ -157,6 +157,7 @@ const DEFAULT_WAR_CONFIG = {
const DEFAULT_AFTER_CONFIG = {
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
maxTechLevel: 12,
defaultCityWall: 1000,
baseGold: 0,
baseRice: 2000,
@@ -235,7 +236,7 @@ export const buildWarAftermathConfig = (
['initialAllowedTechLevel'],
DEFAULT_AFTER_CONFIG.initialAllowedTechLevel
),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_AFTER_CONFIG.maxTechLevel),
defaultCityWall: resolveNumber(constValues, ['defaultCityWall'], DEFAULT_AFTER_CONFIG.defaultCityWall),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_AFTER_CONFIG.baseGold),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_AFTER_CONFIG.baseRice),
@@ -23,6 +23,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
const ACTION_NAME = '임관';
const ARGS_SCHEMA = z.object({
@@ -43,7 +44,11 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, AppointmentArgs> {
public readonly key = 'che_임관';
public readonly name = ACTION_NAME;
constructor(private readonly env: TurnCommandEnv) {}
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(private readonly env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
getInheritanceActiveActionAmount(): number {
return 1;
@@ -116,7 +121,11 @@ export class ActionDefinition<
troopId: 0,
experience:
context.general.experience +
(context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100),
this.pipeline.onCalcStat(
context,
'experience',
context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100
),
meta: {
...context.general.meta,
officer_city: 0,
@@ -64,6 +64,10 @@ export interface DispatchResolveContext<
aftermathConfig: WarAftermathConfig;
}
export const orderDefenderGenerals = <TriggerState extends GeneralTriggerState>(
generals: General<TriggerState>[]
): General<TriggerState>[] => [...generals].sort((left, right) => left.id - right.id);
const ACTION_NAME = '출병';
const ARGS_SCHEMA = z.object({
destCityId: z.number(),
@@ -86,7 +90,12 @@ const fixtureNumber = (value: unknown, fallback = 0): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
const formatFixtureDate = (value: Date | undefined): string =>
value ? value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, '') : '1970-01-01 00:00:00';
value
? value
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/u, '')
: '1970-01-01 00:00:00';
const buildBattleGeneralFixture = <TriggerState extends GeneralTriggerState>(general: General<TriggerState>) => {
const meta = general.meta;
@@ -105,9 +114,7 @@ const buildBattleGeneralFixture = <TriggerState extends GeneralTriggerState>(gen
inheritBuff = parsed;
} else if (typeof parsed === 'object' && parsed !== null) {
inheritBuff = Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, number] => typeof entry[1] === 'number'
)
Object.entries(parsed).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
);
}
} catch {
@@ -556,12 +563,14 @@ export class ActionDefinition<
defenderCity.meta.term = 3;
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
const defenderGenerals = generals.filter(
(general) =>
general.cityId === defenderCity.id &&
general.nationId === defenderCity.nationId &&
general.crew > 0 &&
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
const defenderGenerals = orderDefenderGenerals(
generals.filter(
(general) =>
general.cityId === defenderCity.id &&
general.nationId === defenderCity.nationId &&
general.crew > 0 &&
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
)
);
const traceGeneralIds = new Set(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []);
const shouldTraceWar =
@@ -647,9 +656,7 @@ export class ActionDefinition<
// to deploy. Preserve that ordering before snapshotting city effects.
let frontStatePatches: Array<{ id: number; frontState: number }> = [];
if (battle.conquered && context.map && context.diplomacy) {
const connections = new Map(
context.map.cities.map((city) => [city.id, city.connections ?? []] as const)
);
const connections = new Map(context.map.cities.map((city) => [city.id, city.connections ?? []] as const));
const nearbyCityIds = new Set([defenderCity.id, ...(connections.get(defenderCity.id) ?? [])]);
const nearbyNationIds = new Set<number>([aftermath.conquest?.conquerNationId ?? attackerNation.id]);
for (const city of cities) {
@@ -3,6 +3,8 @@
* the value rounded to six significant decimal digits on the next read.
* Keep those boundaries separate so later SQL expressions use binary32 state.
*/
export const storeLegacyCityTrust = (value: number): number => Math.fround(value);
import { readLegacyStoredFloat, toLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
export const readLegacyCityTrust = (value: number): number => Number(Math.fround(value).toPrecision(6));
export const storeLegacyCityTrust = (value: number): number => toLegacyStoredFloat(value);
export const readLegacyCityTrust = (value: number): number => readLegacyStoredFloat(value);
+21 -2
View File
@@ -3,8 +3,27 @@
// command/battle update, so both boundaries are part of the game state.
export const toLegacyStoredFloat = (value: number): number => Math.fround(value);
export const readLegacyStoredFloat = (value: number): number =>
Number(Math.fround(value).toPrecision(6));
const roundHalfEven = (value: number): number => {
const lower = Math.floor(value);
const fraction = value - lower;
const tolerance = Number.EPSILON * Math.max(1, Math.abs(value)) * 4;
if (Math.abs(fraction - 0.5) <= tolerance) {
return lower % 2 === 0 ? lower : lower + 1;
}
return Math.round(value);
};
export const readLegacyStoredFloat = (value: number): number => {
const stored = Math.fround(value);
if (!Number.isFinite(stored) || stored === 0) {
return stored;
}
const sign = stored < 0 ? -1 : 1;
const absolute = Math.abs(stored);
const exponent = Math.floor(Math.log10(absolute));
const scale = 10 ** (5 - exponent);
return sign * (roundHalfEven(absolute * scale) / scale);
};
export const addLegacyStoredFloat = (current: number, delta: number): number =>
toLegacyStoredFloat(readLegacyStoredFloat(current) + delta);
+2
View File
@@ -101,7 +101,9 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
meta: GeneralMeta;
lastTurn?: GeneralLastTurn;
turnTime?: Date;
turnTick?: number;
recentWarTime?: Date | null;
recentWarTick?: number | null;
}
export interface City {
+5 -1
View File
@@ -494,7 +494,11 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
const defenderNation = input.defenderNation;
const cityKilled = cityReport?.killed ?? 0;
if ((cityReport?.dead ?? 0) > 0) {
// Ref branches on WarUnitCity::getPhase(), not accumulated city
// casualties. A city can retain dead casualties from earlier battles
// while being conquered before its wall receives a phase.
const cityPhase = cityReport?.phase ?? ((cityReport?.dead ?? 0) > 0 ? 1 : 0);
if (cityPhase > 0) {
const crewTypeIndex = buildCrewTypeIndex(input.unitSet);
const crewType = crewTypeIndex.get(input.config.castleCrewTypeId);
const riceCoef = crewType?.rice ?? 1;
+3
View File
@@ -165,6 +165,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
isAttacker: unit.isAttacker(),
killed: unit.getKilled(),
dead: unit.getDead(),
phase: unit.getPhase(),
};
}
@@ -176,6 +177,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
isAttacker: unit.isAttacker(),
killed: unit.getKilled(),
dead: unit.getDead(),
phase: unit.getPhase(),
};
}
@@ -186,6 +188,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
isAttacker: unit.isAttacker(),
killed: unit.getKilled(),
dead: unit.getDead(),
phase: unit.getPhase(),
};
};
+2
View File
@@ -112,6 +112,8 @@ export interface WarUnitReport {
isAttacker: boolean;
killed: number;
dead: number;
/** Number of battle phases consumed by this unit. */
phase?: number;
}
export interface WarBattleMetrics {
+10 -4
View File
@@ -112,10 +112,6 @@ export class WarUnitGeneral<
super.setOppose(oppose);
increaseMetaNumber(this.general.meta, RANK_WARNUM, 1);
if (!oppose) {
return;
}
const baseTurnTime = this.isAttacker()
? this.general.turnTime
: oppose instanceof WarUnitGeneral
@@ -126,6 +122,16 @@ export class WarUnitGeneral<
}
const phase = clamp(this.getRealPhase(), 0, 99);
this.general.recentWarTime = new Date(baseTurnTime.getTime());
const baseTurnTick = this.isAttacker()
? this.general.turnTick
: oppose instanceof WarUnitGeneral
? oppose.general.turnTick
: this.general.turnTick;
if (baseTurnTick !== undefined) {
this.general.recentWarTick = baseTurnTick - (baseTurnTick % 100) + phase;
} else {
delete this.general.recentWarTick;
}
this.general.meta.recent_war_phase = phase;
}