merge: 최신 main의 수치 상태 정책을 통합한다

This commit is contained in:
2026-08-24 21:27:26 +00:00
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];