refactor(logic): 과도한 수치 호환 보정을 제거한다

Core 수치 상태는 JavaScript와 PostgreSQL의 자연 정밀도를 유지한다. 정수 상태 경계와 절삭, 수입 배분 및 사망자 분할 순서는 보존한다.
This commit is contained in:
2026-08-24 19:17:06 +00:00
parent fc9fa0c9a6
commit 60dcf815ea
32 changed files with 148 additions and 522 deletions
+4 -9
View File
@@ -8,7 +8,7 @@ import {
getOutcome,
getRiceIncome,
getWallIncome,
readLegacyCityTrust,
resolveCityTrustValue,
type CityIncomeSource,
type Nation,
type NationIncomeContext,
@@ -37,6 +37,8 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
const resolveNationBill = (nation: Nation): number => asNumber(nation.meta.bill, 100);
export const resolveIncomeCityTrust = (trust: number): number => resolveCityTrustValue(trust);
const resolveOfficerCity = (meta: Record<string, unknown>): number => {
const camel = asNumber(meta.officerCity, 0);
if (camel > 0) {
@@ -45,13 +47,8 @@ const resolveOfficerCity = (meta: Record<string, unknown>): number => {
return asNumber(meta.officer_city, 0);
};
export const resolveLegacyIncomeCityTrust = (trust: number): number => readLegacyCityTrust(trust);
const resolveCityTrust = (meta: Record<string, unknown>): number => {
const trust = asNumber(meta.trust, 50);
// Income is calculated in PHP after PDO exposes MariaDB FLOAT using a
// six-significant-digit decimal representation.
return resolveLegacyIncomeCityTrust(trust);
return resolveIncomeCityTrust(asNumber(meta.trust, 50));
};
const toIncomeCity = (city: ReturnType<InMemoryTurnWorld['listCities']>[number]): CityIncomeSource => ({
@@ -150,13 +147,11 @@ const processIncomeForNation = (
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
// REF-COMPAT:BEGIN ref-income-preflush-fraction
// 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;
// REF-COMPAT:END ref-income-preflush-fraction
const originOutcome = getOutcome(100, nationGenerals);
const bill = resolveNationBill(nation);
const outcome = Math.round((bill / 100) * originOutcome);
@@ -4,9 +4,7 @@ import { LogCategory, LogFormat, LogScope, type MapDefinition } from '@sammo-ts/
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { MonthlyEventActionHandler } from './monthlyEventHandler.js';
// REF-COMPAT:BEGIN ref-int-column-write-rounding
const roundLegacyIntegerColumn = (value: number): number => Math.round(value);
// REF-COMPAT:END ref-int-column-write-rounding
const roundIntegerState = (value: number): number => Math.round(value);
const resolveOfficerCity = (meta: Record<string, unknown>): number => {
const camel = meta.officerCity;
@@ -76,19 +74,15 @@ export const createUpdateCitySupplyHandler = (options: {
const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0;
const damaged = world.updateCity(city.id, {
supplyState: 0,
population: roundLegacyIntegerColumn(city.population * 0.9),
agriculture: roundLegacyIntegerColumn(city.agriculture * 0.9),
commerce: roundLegacyIntegerColumn(city.commerce * 0.9),
security: roundLegacyIntegerColumn(city.security * 0.9),
defence: roundLegacyIntegerColumn(city.defence * 0.9),
wall: roundLegacyIntegerColumn(city.wall * 0.9),
population: roundIntegerState(city.population * 0.9),
agriculture: roundIntegerState(city.agriculture * 0.9),
commerce: roundIntegerState(city.commerce * 0.9),
security: roundIntegerState(city.security * 0.9),
defence: roundIntegerState(city.defence * 0.9),
wall: roundIntegerState(city.wall * 0.9),
meta: {
...city.meta,
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
// 레거시 FLOAT column에 SQL 곱셈 결과가 먼저 저장된 뒤
// 민심 30 threshold를 판정하므로 float32 양자화를 보존한다.
trust: Math.fround(trust * 0.9),
// REF-COMPAT:END ref-mariadb-float-boundary
trust: trust * 0.9,
},
});
if (damaged) {
@@ -103,9 +97,9 @@ export const createUpdateCitySupplyHandler = (options: {
continue;
}
world.updateGeneral(general.id, {
crew: roundLegacyIntegerColumn(general.crew * 0.95),
atmos: roundLegacyIntegerColumn(general.atmos * 0.95),
train: roundLegacyIntegerColumn(general.train * 0.95),
crew: roundIntegerState(general.crew * 0.95),
atmos: roundIntegerState(general.atmos * 0.95),
train: roundIntegerState(general.train * 0.95),
});
}
@@ -67,9 +67,7 @@ const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => {
return typeof rawSeed === 'string' || typeof rawSeed === 'number' ? rawSeed : String(rawSeed);
};
// REF-COMPAT:BEGIN ref-int-column-write-rounding
const roundLegacyIntegerColumn = (value: number): number => Math.round(value);
// REF-COMPAT:END ref-int-column-write-rounding
const roundIntegerState = (value: number): number => Math.round(value);
export const createRaiseDisasterHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
@@ -136,35 +134,31 @@ 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;
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
const storedTrust = Math.fround(isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio);
// REF-COMPAT:END ref-mariadb-float-boundary
const storedTrust = isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio;
world.updateCity(city.id, {
state: picked.stateCode,
population: roundLegacyIntegerColumn(
population: roundIntegerState(
isGood ? Math.min(city.population * affectRatio, city.populationMax) : city.population * affectRatio
),
agriculture: roundLegacyIntegerColumn(
agriculture: roundIntegerState(
isGood
? Math.min(city.agriculture * affectRatio, city.agricultureMax)
: city.agriculture * affectRatio
),
commerce: roundLegacyIntegerColumn(
commerce: roundIntegerState(
isGood ? Math.min(city.commerce * affectRatio, city.commerceMax) : city.commerce * affectRatio
),
security: roundLegacyIntegerColumn(
security: roundIntegerState(
isGood ? Math.min(city.security * affectRatio, city.securityMax) : city.security * affectRatio
),
defence: roundLegacyIntegerColumn(
defence: roundIntegerState(
isGood ? Math.min(city.defence * affectRatio, city.defenceMax) : city.defence * affectRatio
),
wall: roundLegacyIntegerColumn(
wall: roundIntegerState(
isGood ? Math.min(city.wall * affectRatio, city.wallMax) : city.wall * affectRatio
),
meta: {
...city.meta,
// Ref assigns the SQL expression to a MariaDB FLOAT
// column at each disaster event boundary.
trust: storedTrust,
},
});
@@ -209,9 +203,9 @@ export const createRaiseDisasterHandler = (options: {
});
world.updateGeneral(general.id, {
injury: clamp(general.injury + rng.nextRangeInt(1, 16), 0, 80),
crew: roundLegacyIntegerColumn(general.crew * 0.98),
atmos: roundLegacyIntegerColumn(general.atmos * 0.98),
train: roundLegacyIntegerColumn(general.train * 0.98),
crew: roundIntegerState(general.crew * 0.98),
atmos: roundIntegerState(general.atmos * 0.98),
train: roundIntegerState(general.train * 0.98),
});
}
}
@@ -7,12 +7,7 @@ import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'
const MAX_AVAILABLE_WAR_SETTING_COUNT = 10;
const MONTHLY_AVAILABLE_WAR_SETTING_INCREMENT = 2;
// REF-COMPAT:BEGIN ref-decimal-half-stabilization
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);
};
// REF-COMPAT:END ref-decimal-half-stabilization
export const roundNationPowerValue = (value: number): number => Math.round(value);
const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -48,7 +43,7 @@ const calculateNationPower = (
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 = roundLegacyNationPowerValue((nation.gold + nation.rice + generalResources) / 100);
const resourcePower = roundNationPowerValue((nation.gold + nation.rice + generalResources) / 100);
const techPower = readNumber(asRecord(nation.meta).tech);
let cityPower = 0;
@@ -70,7 +65,7 @@ const calculateNationPower = (
city.defenceMax,
0
);
cityPower = maximum > 0 ? roundLegacyNationPowerValue((population * current) / maximum / 100) : 0;
cityPower = maximum > 0 ? roundNationPowerValue((population * current) / maximum / 100) : 0;
}
let generalPower = 0;
@@ -103,13 +98,13 @@ const calculateNationPower = (
totalCrew += general.crew;
}
const power = roundLegacyNationPowerValue(
const power = roundNationPowerValue(
(resourcePower +
techPower +
cityPower +
generalPower +
roundLegacyNationPowerValue(dexterityPower / 1000) +
roundLegacyNationPowerValue(experiencePower / 100)) /
roundNationPowerValue(dexterityPower / 1000) +
roundNationPowerValue(experiencePower / 100)) /
10
);
return {
@@ -121,9 +116,9 @@ const calculateNationPower = (
cityPower,
generalPower,
dexterityPowerRaw: dexterityPower / 1000,
dexterityPower: roundLegacyNationPowerValue(dexterityPower / 1000),
dexterityPower: roundNationPowerValue(dexterityPower / 1000),
experiencePowerRaw: experiencePower / 100,
experiencePower: roundLegacyNationPowerValue(experiencePower / 100),
experiencePower: roundNationPowerValue(experiencePower / 100),
},
};
};
@@ -145,7 +140,7 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => {
for (const nation of nations) {
const calculated = calculateNationPower(world, nation.id);
const multiplier = rng.nextRange(0.95, 1.05);
const power = roundLegacyNationPowerValue(calculated.power * multiplier);
const power = roundNationPowerValue(calculated.power * multiplier);
const traceIds = (process.env.SEED_PARITY_TRACE_NATION_POWER_IDS ?? '')
.split(',')
.map((value) => Number(value.trim()))
@@ -7,14 +7,7 @@ import { resolveAppliedNationRate } from './nationTaxRate.js';
type SemiAnnualResource = 'gold' | 'rice';
// REF-COMPAT:BEGIN ref-decimal-half-stabilization
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);
};
// REF-COMPAT:END ref-decimal-half-stabilization
const roundIntegerState = (value: number): number => Math.round(value);
const parseResource = (args: readonly unknown[]): SemiAnnualResource => {
const resource = args[0];
@@ -29,11 +22,9 @@ const resolveBasePopulationIncrease = (world: InMemoryTurnWorld): number => {
return typeof value === 'number' && Number.isFinite(value) ? value : 5_000;
};
const decayDomesticValue = (value: number): number => roundLegacyIntegerColumn(value * 0.99);
const decayDomesticValue = (value: number): number => roundIntegerState(value * 0.99);
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
export const storeLegacySemiAnnualTrust = (value: number): number => Math.fround(Math.max(0, Math.min(100, value)));
// REF-COMPAT:END ref-mariadb-float-boundary
export const clampSemiAnnualTrust = (value: number): number => Math.max(0, Math.min(100, value));
const applyResourceMaintenance = (value: number, ratios: readonly [number, number][]): number => {
if (value <= 1_000) {
@@ -41,10 +32,10 @@ const applyResourceMaintenance = (value: number, ratios: readonly [number, numbe
}
for (const [threshold, ratio] of ratios) {
if (value > threshold) {
return roundLegacyIntegerColumn(value * ratio);
return roundIntegerState(value * ratio);
}
}
return roundLegacyIntegerColumn(value * 0.99);
return roundIntegerState(value * 0.99);
};
export const createProcessSemiAnnualHandler = (options: {
@@ -117,22 +108,19 @@ export const createProcessSemiAnnualHandler = (options: {
: 1 + populationRatio * (1 - securityRatio);
const trust = asNumber(city.meta.trust, 50);
world.updateCity(city.id, {
population: roundLegacyIntegerColumn(
population: roundIntegerState(
Math.min(city.populationMax, basePopulationIncrease + city.population * populationFactor)
),
agriculture: roundLegacyIntegerColumn(
agriculture: roundIntegerState(
Math.min(city.agricultureMax, city.agriculture * (1 + genericRatio))
),
commerce: roundLegacyIntegerColumn(Math.min(city.commerceMax, city.commerce * (1 + genericRatio))),
security: roundLegacyIntegerColumn(Math.min(city.securityMax, city.security * (1 + genericRatio))),
defence: roundLegacyIntegerColumn(Math.min(city.defenceMax, city.defence * (1 + genericRatio))),
wall: roundLegacyIntegerColumn(Math.min(city.wallMax, city.wall * (1 + genericRatio))),
commerce: roundIntegerState(Math.min(city.commerceMax, city.commerce * (1 + genericRatio))),
security: roundIntegerState(Math.min(city.securityMax, city.security * (1 + genericRatio))),
defence: roundIntegerState(Math.min(city.defenceMax, city.defence * (1 + genericRatio))),
wall: roundIntegerState(Math.min(city.wallMax, city.wall * (1 + genericRatio))),
meta: {
...city.meta,
// 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),
trust: clampSemiAnnualTrust(trust + trustDiff),
},
});
}