refactor(logic): 과도한 수치 호환 보정을 제거한다
Core 수치 상태는 JavaScript와 PostgreSQL의 자연 정밀도를 유지한다. 정수 상태 경계와 절삭, 수입 배분 및 사망자 분할 순서는 보존한다.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user