refactor(logic): 과도한 수치 호환 보정을 제거한다
Core 수치 상태는 JavaScript와 PostgreSQL의 자연 정밀도를 유지한다. 정수 상태 경계와 절삭, 수입 배분 및 사망자 분할 순서는 보존한다.
This commit is contained in:
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
||||
|
||||
import { createIncomeHandler, resolveLegacyIncomeCityTrust } from '../src/turn/incomeHandler.js';
|
||||
import { createIncomeHandler, resolveIncomeCityTrust } from '../src/turn/incomeHandler.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js';
|
||||
import {
|
||||
@@ -146,8 +146,8 @@ const buildWorld = (
|
||||
};
|
||||
|
||||
describe('core monthly event actions at the real month boundary', () => {
|
||||
it('reads income trust through the PHP six-significant-digit FLOAT representation', () => {
|
||||
expect(resolveLegacyIncomeCityTrust(98.12674)).toBe(98.1267);
|
||||
it('uses the full-precision trust value for income', () => {
|
||||
expect(resolveIncomeCityTrust(98.12674)).toBe(98.12674);
|
||||
});
|
||||
|
||||
it('preserves notice format, NewYear month log, age/belong, and officer lock reset', async () => {
|
||||
|
||||
@@ -392,7 +392,8 @@ describe('monthly event pipeline', () => {
|
||||
wall: 81,
|
||||
meta: { trade: 100, marker: 1 },
|
||||
});
|
||||
expect(damagedCity?.meta.trust).toBe(Math.fround(79.6));
|
||||
expect(damagedCity?.meta.trust).toBe(79.60000000000001);
|
||||
expect(damagedCity?.meta.trust).not.toBe(Math.fround(79.60000000000001));
|
||||
expect(world.getGeneralById(1)).toMatchObject({ injury: 0, crew: 99, atmos: 50, train: 51 });
|
||||
expect(world.getGeneralById(2)).toMatchObject({ injury: 7, crew: 97, atmos: 49, train: 50 });
|
||||
expect(world.getGeneralById(3)).toMatchObject({ injury: 80, crew: 97, atmos: 49, train: 50 });
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createMonthlyNationCountHandler,
|
||||
createMonthlyNationStatsHandler,
|
||||
createMonthlyWarSettingHandler,
|
||||
roundLegacyNationPowerValue,
|
||||
roundNationPowerValue,
|
||||
} from '../src/turn/monthlyNationStatsHandler.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
@@ -108,8 +108,8 @@ const buildNation = (
|
||||
});
|
||||
|
||||
describe('monthly nation statistics boundary', () => {
|
||||
it('stabilizes MariaDB decimal half boundaries before rounding', () => {
|
||||
expect(roundLegacyNationPowerValue(342.49999999999994)).toBe(343);
|
||||
it('uses the native integer boundary for nation power', () => {
|
||||
expect(roundNationPowerValue(342.49999999999994)).toBe(342);
|
||||
});
|
||||
|
||||
it('matches the fixed legacy power, maxima, war-setting count, and final general cache', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation, NationTraitModule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createProcessSemiAnnualHandler, storeLegacySemiAnnualTrust } from '../src/turn/monthlySemiAnnualAction.js';
|
||||
import { clampSemiAnnualTrust, createProcessSemiAnnualHandler } from '../src/turn/monthlySemiAnnualAction.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const buildCity = (id: number, patch: Partial<City> = {}): City => ({
|
||||
@@ -151,9 +151,8 @@ const environment = {
|
||||
};
|
||||
|
||||
describe('ProcessSemiAnnual monthly action', () => {
|
||||
it('stores the adjusted trust at the MariaDB FLOAT boundary', () => {
|
||||
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).toBe(Math.fround(98.30675));
|
||||
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).not.toBe(98.30675);
|
||||
it('keeps adjusted trust at full precision', () => {
|
||||
expect(clampSemiAnnualTrust(88.30675 + 10)).toBe(98.30675);
|
||||
});
|
||||
|
||||
it('preserves the global popIncrease order, neutral double decay, supplied filtering, and nation trait', async () => {
|
||||
@@ -254,7 +253,7 @@ describe('ProcessSemiAnnual monthly action', () => {
|
||||
|
||||
await handler(['gold'], environment, event);
|
||||
|
||||
expect(world.getCityById(1)).toMatchObject({ defence: 2_030, wall: 2_030 });
|
||||
expect(world.getCityById(1)).toMatchObject({ defence: 2_029, wall: 2_029 });
|
||||
});
|
||||
|
||||
it('applies strict legacy resource thresholds to generals and nations for either resource', async () => {
|
||||
|
||||
Reference in New Issue
Block a user