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
@@ -27,11 +27,6 @@ import {
} from './che_상업투자.js';
import { JosaUtil } from '@sammo-ts/common';
import { clamp } from 'es-toolkit';
import {
addLegacyStoredFloat,
readLegacyStoredFloat,
toLegacyStoredFloat,
} from '@sammo-ts/logic/compat/legacyFloat.js';
export interface TechResearchArgs {}
@@ -59,18 +54,7 @@ const readTech = (nation: Nation): number => {
return typeof tech === 'number' && Number.isFinite(tech) ? tech : 0;
};
// 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다.
// Ref stores tech in a MariaDB FLOAT column. FLOAT applies binary32
// quantization on write; its six-significant-digit text rendering happens
// only when the value is read, not on every update.
export const toLegacyStoredTech = toLegacyStoredFloat;
// mysqli renders a MariaDB FLOAT with six significant decimal digits before
// PHP performs the next command's arithmetic. Model that read boundary, then
// model the binary32 write boundary separately.
export const readLegacyStoredTech = readLegacyStoredFloat;
export const addLegacyStoredTech = addLegacyStoredFloat;
export const addTech = (current: number, delta: number): number => current + delta;
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -143,7 +127,7 @@ export class ActionDefinition<
context.nation.meta = {
...context.nation.meta,
tech: addLegacyStoredTech(currentTech, techScore / generalCount),
tech: addTech(currentTech, techScore / generalCount),
};
context.general.gold = Math.max(0, context.general.gold - result.costGold);
context.general.experience += result.exp;
@@ -26,9 +26,7 @@ interface ProcureContext<
const ACTION_NAME = '물자조달';
const ACTION_KEY = 'che_물자조달';
// REF-COMPAT:BEGIN ref-int-column-write-rounding
export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
// REF-COMPAT:END ref-int-column-write-rounding
export const roundAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
export const resolveLegacyExperienceLevel = (experience: number): number =>
Math.max(
@@ -123,8 +121,8 @@ export class ActionResolver<
// accumulated binary value is exactly 4564.5 and persists as 4565.
const rawNextExp = general.experience + exp;
const rawNextDed = general.dedication + ded;
const nextExp = Math.round(rawNextExp);
const nextDed = Math.round(rawNextDed);
const nextExp = roundAccumulatedInteger(general.experience, exp);
const nextDed = roundAccumulatedInteger(general.dedication, ded);
let appliedScore = score;
if (context.city && [1, 3].includes(context.city.frontState)) {
@@ -5,7 +5,7 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
import { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
import {
STRATEGY_ARGS_SCHEMA,
StrategyActionDefinition,
@@ -43,9 +43,8 @@ export class ActionResolver<
result: StrategyResult<TriggerState>,
effects: GeneralActionEffect<TriggerState>[]
): void {
const currentTrust =
typeof context.destCity.meta.trust === 'number' ? readLegacyCityTrust(context.destCity.meta.trust) : 50;
const nextTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.secondaryAmount));
const currentTrust = resolveCityTrustValue(context.destCity.meta.trust);
const nextTrust = adjustCityTrust(currentTrust, -result.secondaryAmount);
effects.push(
createCityPatchEffect(
@@ -22,8 +22,7 @@ import {
updateDomesticCriticalMeta,
} from './che_상업투자.js';
import { JosaUtil } from '@sammo-ts/common';
import { clamp } from 'es-toolkit';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
import { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
export interface TrustActionArgs {}
@@ -45,8 +44,7 @@ const CONFIG: InvestmentConfig = {
};
const readTrust = (city: City): number => {
const trust = city.meta.trust;
return typeof trust === 'number' && Number.isFinite(trust) ? readLegacyCityTrust(trust) : DEFAULT_TRUST;
return resolveCityTrustValue(city.meta.trust, DEFAULT_TRUST);
};
const remainCityTrust = (): Constraint => ({
@@ -105,7 +103,7 @@ export class ActionDefinition<
const trustDelta = result.score / 10;
context.city.meta = {
...context.city.meta,
trust: storeLegacyCityTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)),
trust: adjustCityTrust(readTrust(context.city), trustDelta),
};
context.general.rice = Math.max(0, context.general.rice - result.costGold);
context.general.experience += result.exp;
@@ -30,7 +30,7 @@ import {
isCrewTypeAvailable,
} from '@sammo-ts/logic/world/unitSet.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
import { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
export interface RecruitEnvironment {
@@ -61,14 +61,7 @@ const DEFAULT_MIN_POP = 30000;
const DEFAULT_TRUST = 50;
const MIN_CREW = 100;
// REF-COMPAT:BEGIN ref-php-half-rounding
// PHP round() compensates for the small binary drift around a half boundary.
// Ref then converts the result to int through Util::round().
export const roundLegacyRecruitCost = (value: number): number => {
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);
};
// REF-COMPAT:END ref-php-half-rounding
export const roundRecruitCost = (value: number): number => Math.round(value);
export const ARGS_SCHEMA = z.preprocess(
(raw) => {
@@ -330,7 +323,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
crewType ? { armType: crewType.armType } : undefined
);
return {
gold: roundLegacyRecruitCost(adjustedGold * costOffset),
gold: roundRecruitCost(adjustedGold * costOffset),
rice: Math.round(adjustedRice),
applied: plan.applied,
requested: plan.requested,
@@ -444,9 +437,9 @@ export class ActionResolver<
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
const recruitPop = this.command.getRecruitPopulation(context, appliedCrew);
const nextPopulation = Math.max(city.population - recruitPop, 0);
const baseTrust = readLegacyCityTrust(readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST));
const baseTrust = resolveCityTrustValue(readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST));
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
const nextTrust = storeLegacyCityTrust(Math.max(baseTrust - trustLoss, 0));
const nextTrust = adjustCityTrust(baseTrust, -trustLoss);
const actionName = this.env.actionName ?? ACTION_NAME;
const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] =
@@ -0,0 +1,6 @@
import { clamp } from 'es-toolkit';
export const resolveCityTrustValue = (value: unknown, fallback = 50): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
export const adjustCityTrust = (current: number, delta: number): number => clamp(current + delta, 0, 100);
@@ -198,4 +198,4 @@ export const loadGeneralTurnCommandSpecs = async (
return specs;
};
export { readLegacyCityTrust } from './legacyCityTrust.js';
export { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
@@ -1,12 +0,0 @@
/**
* MariaDB stores city.trust as FLOAT (binary32), while the PHP driver exposes
* the value rounded to six significant decimal digits on the next read.
* Keep those boundaries separate so later SQL expressions use binary32 state.
*/
import { readLegacyStoredFloat, toLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
export const storeLegacyCityTrust = (value: number): number => toLegacyStoredFloat(value);
export const readLegacyCityTrust = (value: number): number => readLegacyStoredFloat(value);
// REF-COMPAT:END ref-mariadb-float-boundary
@@ -1,5 +1,3 @@
// REF-COMPAT:BEGIN ref-stat-injury-truncation
export const applyLegacyInjury = (value: number, injury: number): number => value * ((100 - injury) / 100);
export const finalizeLegacyStat = (value: number): number => Math.trunc(value);
// REF-COMPAT:END ref-stat-injury-truncation
-31
View File
@@ -1,31 +0,0 @@
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
// MariaDB FLOAT stores binary32, but its text protocol exposes only six
// significant decimal digits. Ref reads that text into PHP before every
// command/battle update, so both boundaries are part of the game state.
export const toLegacyStoredFloat = (value: number): number => Math.fround(value);
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);
// REF-COMPAT:END ref-mariadb-float-boundary
+1 -7
View File
@@ -89,14 +89,12 @@ const isAssignedToOfficerCity = <TriggerState extends GeneralTriggerState>(
(key) => getMetaNumber(general.meta, key, Number.NaN) === cityId
);
// REF-COMPAT:BEGIN ref-dead-split-int-binding
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);
};
// REF-COMPAT:END ref-dead-split-int-binding
const isSupplyCity = (city: City): boolean => {
const raw = city.meta.supply;
@@ -168,11 +166,7 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
const divisor = Math.max(config.initialNationGenLimit, total);
const currentTech = getMetaNumber(nation.meta, 'tech', 0);
const delta = gain / divisor;
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
// 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);
// REF-COMPAT:END ref-mariadb-float-boundary
nation.meta.tech = currentTech + delta;
if (input.trace?.isEnabled('WAR_TECH_TRACE', { nationIds: [nation.id] })) {
input.trace.write('WAR_TECH_TRACE', {
engine: 'core',
+1 -6
View File
@@ -402,12 +402,7 @@ export class WarUnitGeneral<
nextExp *= 0.9;
}
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', nextExp, { armType });
// REF-COMPAT:BEGIN ref-meekrodb-sql-precision
// 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));
// REF-COMPAT:END ref-meekrodb-sql-precision
this.general.meta[key] = base + adjustedExp;
}
public calcRiceConsumption(damage: number): number {
+1 -19
View File
@@ -9,25 +9,7 @@ export const clamp = (value: number, min: number, max: number): number => Math.m
export const clampMin = (value: number, min: number): number => (value < min ? min : value);
// REF-COMPAT:BEGIN ref-php-half-rounding
// 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);
}
// PHP 8.3's round() uses a wider half-boundary fuzz than one JavaScript
// ulp at four-digit battle totals. Accumulating several fractional battle
// rewards can land about three ulps below .5 (for example
// 8719.4999999999945), which PHP still rounds upward.
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)) * 4;
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
};
// REF-COMPAT:END ref-php-half-rounding
export const round = (value: number): number => Math.round(value);
export const getMetaNumber = (meta: Record<string, TriggerValue>, key: string, fallback = 0): number => {
const value = meta[key];
@@ -5,7 +5,7 @@ import type { City, General, Nation } from '../../src/domain/entities.js';
import type { WorldSnapshot } from '../../src/world/types.js';
import {
commandSpec as procureSpec,
roundLegacyAccumulatedInteger,
roundAccumulatedInteger,
resolveLegacyDedicationLevel,
resolveLegacyExperienceLevel,
} from '../../src/actions/turn/general/che_물자조달.js';
@@ -35,13 +35,9 @@ import {
normalizeLegacyGeneratedDex,
resolveLegacySpecialityAge,
} from '../../src/actions/turn/general/che_인재탐색.js';
import {
addLegacyStoredTech,
readLegacyStoredTech,
toLegacyStoredTech,
} from '../../src/actions/turn/general/che_기술연구.js';
import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js';
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
import { addTech } from '../../src/actions/turn/general/che_기술연구.js';
import { adjustCityTrust, resolveCityTrustValue } from '../../src/actions/turn/general/cityTrust.js';
import { roundRecruitCost } from '../../src/actions/turn/general/che_징병.js';
import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js';
import { traitModule as ambitiousPersonality } from '../../src/actionModules/traits/personality/che_출세.js';
@@ -54,7 +50,7 @@ describe('General Commands New Scenario', () => {
const delta = (45 * 0.7) / 3;
expect(delta).toBe(10.499999999999998);
expect(Math.round(delta)).toBe(10);
expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565);
expect(roundAccumulatedInteger(4554, delta)).toBe(4565);
});
it('calculates procurement levels before MariaDB rounds the INT columns', () => {
@@ -71,32 +67,28 @@ describe('General Commands New Scenario', () => {
expect(resolveLegacySpecialityAge(80, 24, 12)).toBe(29);
});
it('stores technology as binary32 without per-update decimal quantization', () => {
const value = 433.51797;
expect(toLegacyStoredTech(value)).toBe(Math.fround(value));
expect(toLegacyStoredTech(value)).not.toBe(Number(Math.fround(value).toPrecision(6)));
expect(readLegacyStoredTech(624.0966796875)).toBe(624.097);
expect(addLegacyStoredTech(624.0966796875, 22.9)).toBe(Math.fround(624.097 + 22.9));
expect(readLegacyStoredTech(533.3125)).toBe(533.312);
expect(readLegacyStoredTech(533.4375)).toBe(533.438);
it('keeps full precision while accumulating technology', () => {
const current = 624.0966796875;
const updated = addTech(current, 22.9);
expect(updated).toBe(current + 22.9);
expect(updated).not.toBe(Math.fround(current + 22.9));
expect(updated).not.toBe(Number((current + 22.9).toPrecision(6)));
});
it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => {
const stored = storeLegacyCityTrust(88.306755);
it('keeps full precision while adjusting city trust', () => {
const current = resolveCityTrustValue(88.306755);
expect(stored).toBe(Math.fround(88.306755));
expect(stored).not.toBe(readLegacyCityTrust(stored));
expect(readLegacyCityTrust(stored)).toBe(88.3068);
expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068);
expect(readLegacyCityTrust(storeLegacyCityTrust(93.40625))).toBe(93.4062);
expect(adjustCityTrust(current, 10)).toBe(98.306755);
expect(adjustCityTrust(current, 20)).toBe(100);
expect(adjustCityTrust(current, -100)).toBe(0);
});
it('rounds recruitment cost across the PHP half boundary', () => {
it('uses the native integer boundary for recruitment cost', () => {
const cavalryCost = (11 * 1.15 * 7000) / 100;
expect(cavalryCost).toBe(885.4999999999999);
expect(Math.round(cavalryCost)).toBe(885);
expect(roundLegacyRecruitCost(cavalryCost)).toBe(886);
expect(roundRecruitCost(cavalryCost)).toBe(885);
});
it('uses the legacy minimum trust for domestic calculations', () => {
+2 -2
View File
@@ -278,8 +278,8 @@ describe('war aftermath', () => {
messageTime: MESSAGE_TIME,
});
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
expect(defenderNation.meta.tech).toBe(Math.fround(1000.9));
expect(attackerNation.meta.tech).toBe(1000.6);
expect(defenderNation.meta.tech).toBe(1000.9);
expect(outcome.diplomacyDeltas).toHaveLength(2);
expect(attackerCity.meta.dead).toBe(60);
expect(defenderCity.meta.dead).toBe(90);
+2 -2
View File
@@ -293,7 +293,7 @@ describe('war triggers', () => {
expect(unit.getComputedAttack()).toBeCloseTo(608, 12);
});
it('normalizes accumulated dexterity to the PHP SQL float precision', () => {
it('keeps accumulated dexterity at full precision', () => {
const general = buildGeneral(80);
general.meta.dex4 = 14_677.199999999997;
const wizard = new WarCrewType({
@@ -316,7 +316,7 @@ describe('war triggers', () => {
unit.addDex(wizard, 2047);
expect(general.meta.dex4).toBe(16_519.5);
expect(general.meta.dex4).toBe(16_519.499999999996);
});
it('preserves the legacy fractional morale gain after a win', () => {
+11 -6
View File
@@ -2,16 +2,21 @@ import { describe, expect, it } from 'vitest';
import { round } from '../src/war/utils.js';
describe('legacy war rounding', () => {
it('matches PHP round() at drifted positive and negative half boundaries', () => {
expect(round(4159.499999999999)).toBe(4160);
expect(round(-4159.499999999999)).toBe(-4160);
expect(round(8719.4999999999945)).toBe(8720);
expect(round(-8719.4999999999945)).toBe(-8720);
describe('war rounding', () => {
it('uses the native number boundary without half-point correction', () => {
expect(round(4159.499999999999)).toBe(4159);
expect(round(-4159.499999999999)).toBe(-4159);
expect(round(8719.4999999999945)).toBe(8719);
expect(round(-8719.4999999999945)).toBe(-8719);
});
it('keeps values meaningfully below a half boundary on the lower integer', () => {
expect(round(4159.499999)).toBe(4159);
expect(round(-4159.499999)).toBe(-4159);
});
it('uses JavaScript semantics at exact half points', () => {
expect(round(1.5)).toBe(2);
expect(round(-1.5)).toBe(-1);
});
});