diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index fe3252fd..197b6d99 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -8,7 +8,7 @@ import { getOutcome, getRiceIncome, getWallIncome, - readLegacyCityTrust, + resolveCityTrustValue, type CityIncomeSource, type Nation, type NationIncomeContext, @@ -37,6 +37,8 @@ const resolveNumber = (source: Record, 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): number => { const camel = asNumber(meta.officerCity, 0); if (camel > 0) { @@ -45,13 +47,8 @@ const resolveOfficerCity = (meta: Record): number => { return asNumber(meta.officer_city, 0); }; -export const resolveLegacyIncomeCityTrust = (trust: number): number => readLegacyCityTrust(trust); - const resolveCityTrust = (meta: Record): 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[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); diff --git a/app/game-engine/src/turn/monthlyCitySupplyAction.ts b/app/game-engine/src/turn/monthlyCitySupplyAction.ts index 4f90465c..cc6e87d8 100644 --- a/app/game-engine/src/turn/monthlyCitySupplyAction.ts +++ b/app/game-engine/src/turn/monthlyCitySupplyAction.ts @@ -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): 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), }); } diff --git a/app/game-engine/src/turn/monthlyDisasterAction.ts b/app/game-engine/src/turn/monthlyDisasterAction.ts index 461f7c90..72f5b713 100644 --- a/app/game-engine/src/turn/monthlyDisasterAction.ts +++ b/app/game-engine/src/turn/monthlyDisasterAction.ts @@ -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), }); } } diff --git a/app/game-engine/src/turn/monthlyNationStatsHandler.ts b/app/game-engine/src/turn/monthlyNationStatsHandler.ts index c2a18e26..390441e3 100644 --- a/app/game-engine/src/turn/monthlyNationStatsHandler.ts +++ b/app/game-engine/src/turn/monthlyNationStatsHandler.ts @@ -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())) diff --git a/app/game-engine/src/turn/monthlySemiAnnualAction.ts b/app/game-engine/src/turn/monthlySemiAnnualAction.ts index fd676044..aff42974 100644 --- a/app/game-engine/src/turn/monthlySemiAnnualAction.ts +++ b/app/game-engine/src/turn/monthlySemiAnnualAction.ts @@ -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), }, }); } diff --git a/app/game-engine/test/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts index 0b1532db..9a6f6f37 100644 --- a/app/game-engine/test/monthlyCoreEventAction.test.ts +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -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 () => { diff --git a/app/game-engine/test/monthlyEventHandler.test.ts b/app/game-engine/test/monthlyEventHandler.test.ts index 8af73e09..111688fb 100644 --- a/app/game-engine/test/monthlyEventHandler.test.ts +++ b/app/game-engine/test/monthlyEventHandler.test.ts @@ -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 }); diff --git a/app/game-engine/test/monthlyNationStatsHandler.test.ts b/app/game-engine/test/monthlyNationStatsHandler.test.ts index 7f8f746b..1cda7bc4 100644 --- a/app/game-engine/test/monthlyNationStatsHandler.test.ts +++ b/app/game-engine/test/monthlyNationStatsHandler.test.ts @@ -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 () => { diff --git a/app/game-engine/test/monthlySemiAnnualAction.test.ts b/app/game-engine/test/monthlySemiAnnualAction.test.ts index ecadbd02..40391033 100644 --- a/app/game-engine/test/monthlySemiAnnualAction.test.ts +++ b/app/game-engine/test/monthlySemiAnnualAction.test.ts @@ -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 => ({ @@ -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 () => { diff --git a/docs/index.md b/docs/index.md index 8eeafa7f..1178f2e3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,8 +43,8 @@ Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따 [게임 시계](./architecture/game-clock.md)에 설명합니다. [패키지와 파일 경계](./architecture/package-boundaries.md)는 source import와 폴더별 책임, 자동 검사 방법을 설명합니다. -Ref 전용 수치·저장 표현 보정과 제거 절차는 -[Ref 호환 shim 인벤토리](./ref-compatibility-shims.md)에 모아 둡니다. +수치 상태의 정밀도와 정수화 경계는 +[수치 상태 정책](./numeric-state-policy.md)에 정리합니다. 세부 문서는 다음 책임으로 나뉩니다. diff --git a/docs/numeric-state-policy.md b/docs/numeric-state-policy.md new file mode 100644 index 00000000..93859f06 --- /dev/null +++ b/docs/numeric-state-policy.md @@ -0,0 +1,25 @@ +# 수치 상태 정책 + +Core2026 제품 계산은 JavaScript `number`와 PostgreSQL/JSON이 보존하는 정밀도를 +그대로 사용합니다. Ref의 PHP 문자열 변환이나 MariaDB `FLOAT` 저장 형식을 맞추기 +위한 유효숫자 재양자화는 제품 상태에 적용하지 않습니다. + +## 실수 상태 + +- 기술과 민심처럼 소수 누적이 가능한 값은 계산 결과를 `Math.fround()`, + `toPrecision()` 또는 `Number.EPSILON`으로 보정하지 않습니다. +- 비교 도구는 Ref 저장 형식을 해석할 수 있지만 그 투영을 Core 제품 상태에 다시 + 적용하지 않습니다. +- 화면 표시의 자리수와 로그 포맷은 상태 정밀도와 별개의 사용자 출력 계약입니다. + +## 정수 상태 + +- 인구, 내정치, 자원, 경험과 공헌처럼 도메인에서 정수인 상태는 각 action의 + 명시된 정수화 경계에서 `Math.round()`를 적용합니다. +- 부상 적용 뒤 최종 능력치와 전투 사망자 40%/60% 분할은 각각 기존 도메인 규칙대로 + `Math.trunc()`를 유지합니다. +- 수입은 장수 급여 비율을 계산할 때까지 소수 원값을 유지하고, 국가의 최종 정수 + 상태를 갱신할 때 반올림합니다. + +이 정수화와 처리 순서는 상태 의미와 다음 action 입력을 결정하므로 제거 대상 +호환 shim이 아닙니다. diff --git a/docs/ref-compatibility-shims.md b/docs/ref-compatibility-shims.md deleted file mode 100644 index 0240ea25..00000000 --- a/docs/ref-compatibility-shims.md +++ /dev/null @@ -1,159 +0,0 @@ -# Ref 호환 shim 인벤토리 - -이 문서는 `core2026` 제품 코드에 남아 있는 **구조가 아니라 Ref의 PHP/MariaDB -표현을 재현하기 위한 국소 보정**의 단일 인벤토리입니다. 일반 도메인 규칙, -인증·권한, RNG 소비 순서, transaction 경계와 사용자 출력은 이 목록에 넣지 -않습니다. 그런 계약은 Ref 제거와 별개로 유지해야 합니다. - -코드에서는 제거 후보의 최소 범위를 다음처럼 표시합니다. - -```ts -// REF-COMPAT:BEGIN ref-example-id -const adjusted = legacyAdjustment(value); -// REF-COMPAT:END ref-example-id -``` - -한 ID가 여러 파일에 나타날 수 있습니다. `pnpm check:ref-compat-markers`는 모든 -`BEGIN`/`END` 쌍, 중첩, ID 오타와 이 문서의 누락·유령 항목을 검사합니다. -표식은 feature flag가 아니며 현재 동작을 끄지 않습니다. - -## 제거 원칙 - -1. “Ref 대응책 제거” 작업에서도 ID를 한 번에 하나씩 다룹니다. -2. 먼저 각 항목의 중립 구현을 정의하고, 해당 ID의 모든 source region과 호출부를 - 찾습니다. 주석만 지우는 것은 제거가 아닙니다. -3. 기존 exact test를 삭제하지 않고 중립 구현의 새 기대값으로 의도적으로 - 변경합니다. canonical snapshot에서 RNG trace, 정수 결과, 저장 상태와 다음 - 월까지 비교합니다. -4. PostgreSQL schema/type을 함께 바꾸는 항목은 migration·기존 DB 증분 적용·복구 - 경로까지 별도 작업으로 검증합니다. -5. 구현과 검증이 끝난 뒤 source marker와 이 문서의 ID 항목을 함께 제거하고 - `pnpm check:ref-compat-markers`를 실행합니다. - -## 현재 인벤토리 - -### MariaDB FLOAT 저장·읽기 경계 - - - -- 성격: MariaDB `FLOAT` binary32 저장과 mysqli/PDO의 6자리 유효숫자 half-even - 읽기를 JavaScript `number` 안에서 반복 재현하는 보정입니다. -- 현재 위치: `packages/logic/src/compat/legacyFloat.ts`, - `packages/logic/src/actions/turn/general/legacyCityTrust.ts`, 기술 연구, 월간 보급·재해· - 반기 처리, 수입 계산과 전투 기술 증가 경계입니다. -- Ref 근거: `nation.tech`, `city.trust`가 `FLOAT`이고 PHP 계산과 SQL 내부 계산의 - read/write 순서가 서로 다릅니다. 최초 도입은 `58b9a23`, half-even 보강은 - `5d6923f`입니다. -- 현재 검증: `general_commands_new.test.ts`, `monthlyEventHandler.test.ts`, - `monthlySemiAnnualAction.test.ts`, `warAftermath.test.ts`와 scenario 2601/2400 - 월간 차등입니다. -- 중립 구현 후보: PostgreSQL에 저장된 full-precision 값을 그대로 읽고 쓰며 - `Math.fround`, 6자리 decimal 변환과 legacy helper를 제거합니다. -- 제거 위험: 기술·민심이 다음 명령/월간 이벤트 입력이므로 한 지점만 제거하면 - 장기 진행이 더 쉽게 어긋납니다. 이 ID의 모든 region을 한 작업으로 검토합니다. - -### PHP half-boundary 반올림 보정 - - - -- 성격: JavaScript 계산값이 `.5`보다 수 ulp 아래에 머물러도 PHP `round()`처럼 - half-away-from-zero가 되도록 `Number.EPSILON`을 더하는 보정입니다. -- 현재 위치: `packages/logic/src/war/utils.ts`의 전투 정수 저장과 - `packages/logic/src/actions/turn/general/che_징병.ts`의 징병 비용입니다. -- Ref 근거: `4159.499999999999`, `8719.4999999999945` 같은 누적값 및 징병 비용의 - PHP 결과입니다. 도입 `58b9a23`, 전투 tolerance 보강 `cef6a90`입니다. -- 현재 검증: `warUtils.test.ts`, `general_commands_new.test.ts`입니다. -- 중립 구현 후보: 제품이 정한 하나의 명시적 rounding mode 또는 단순 - `Math.round()`로 교체합니다. -- 제거 위험: 음수 tie와 전투 누적 정수 결과가 바뀌므로 fixed-seed 전투 전체 상태를 - 다시 승인해야 합니다. - -### Decimal half 안정화 후 정수화 - - - -- 성격: `toPrecision(15)`로 이진 오차를 먼저 줄인 뒤 half-away-from-zero로 - 반올림하는 2단계 보정입니다. -- 현재 위치: `monthlySemiAnnualAction.ts`, `monthlyNationStatsHandler.ts`입니다. -- Ref 근거: MariaDB가 decimal rate 식을 평가한 뒤 `ROUND()`하는 순서를 맞추기 - 위해 scenario 2601 월간 일치 작업 `58b9a23`에서 추가했습니다. -- 현재 검증: `monthlySemiAnnualAction.test.ts`, - `monthlyNationStatsHandler.test.ts`, 월간 seed parity입니다. -- 중립 구현 후보: 공통 정수 반올림 정책으로 교체하고 사전 `toPrecision(15)`를 - 제거합니다. -- 제거 위험: 국가 power는 뒤의 RNG 분기와 NPC 의사결정에 영향을 줍니다. - -### MeekroDB SQL 문자열 precision=14 재현 - - - -- 성격: 전투 숙련도 누적값을 SQL 문자열에 넣기 전 PHP/MeekroDB의 14자리 - precision으로 다시 양자화하는 보정입니다. -- 현재 위치: `packages/logic/src/war/units/general.ts`의 `addDex()`입니다. -- Ref 근거: `58b9a23`의 scenario 2601 전투 후 정수 dex 경계입니다. -- 현재 검증: battle/war fixed-seed 및 differential 테스트입니다. -- 중립 구현 후보: full-precision 누적 뒤 명시적 integer 저장 정책만 적용합니다. -- 제거 위험: 숙련도와 병종 보정이 이후 전투 입력이므로 단일 전투뿐 아니라 연속 - 전투를 비교합니다. - -### MariaDB INT write 반올림 투영 - - - -- 성격: 중간 계산값을 그대로 유지하지 않고 Ref의 정수 column write 시점마다 - `Math.round()`로 투영하는 보정입니다. -- 현재 위치: `monthlyCitySupplyAction.ts`, `monthlyDisasterAction.ts`, - `che_물자조달.ts`의 국소 helper입니다. -- Ref 근거: 보급·재해·장수 경험/공헌이 각각 DB write 뒤 다음 action에서 다시 - 읽히는 순서입니다. 월간/명령 이관 및 `58b9a23`에서 확인했습니다. -- 현재 검증: 월간 event 테스트와 `general_commands_new.test.ts`입니다. -- 중립 구현 후보: 도메인 상태의 정수 invariant를 한 저장 계층에 두거나 - full-precision 상태를 유지합니다. -- 제거 위험: 이 항목은 단순 표시 반올림이 아닙니다. 다음 action 입력과 저장 - 상태를 함께 비교해야 합니다. - -### 부상 적용 뒤 정수 능력치 절삭 - - - -- 성격: 부상 비율을 능력치에 먼저 곱하고 trigger 계산 뒤 `Math.trunc()`하는 Ref - 순서를 별도 helper로 재현합니다. -- 현재 위치: `packages/logic/src/actions/turn/general/legacyGeneralStat.ts`입니다. -- Ref 근거: 징병 등 일반 명령의 effective stat 처리이며 `58b9a23`에서 분리됐습니다. -- 현재 검증: `general_commands_new.test.ts`와 general turn compatibility입니다. -- 중립 구현 후보: 정식 stat value object/공통 계산 정책에 흡수하거나 실수 능력치를 - 유지합니다. -- 제거 위험: 성공량·비용·로그가 함께 달라질 수 있습니다. - -### 수입 배분 전 pre-flush 실수 유지 - - - -- 성격: 국가 정수 column에 저장될 최종 수입을 장수 급여 배분 전에는 일부러 - 반올림하지 않는 순서 보정입니다. -- 현재 위치: `app/game-engine/src/turn/incomeHandler.ts`입니다. -- Ref 근거: Ref는 `943.5` 같은 persistence 전 수입으로 ratio를 계산한 뒤 최종 - 국가 state만 정수화합니다. `58b9a23`에서 확인했습니다. -- 현재 검증: income/monthly 테스트와 scenario 2601 월간 parity입니다. -- 중립 구현 후보: 급여와 국가 수입에 동일한 명시적 rounding phase를 정의합니다. -- 제거 위험: 여러 장수의 급여 합과 다음 명령의 개인 자원이 바뀝니다. - -### 전투 사망자 분할값의 개별 INT binding - - - -- 성격: 40%/60% 사망자 분할을 합산 뒤 한 번 반올림하지 않고 각 SQL binding - 직전에 `Math.trunc()`하는 보정입니다. -- 현재 위치: `packages/logic/src/war/aftermath.ts`의 `increaseDeadCounter()`입니다. -- Ref 근거: Ref의 `dead + %i` binding 순서이며 `58b9a23`에서 확인했습니다. -- 현재 검증: `warAftermath.test.ts`입니다. -- 중립 구현 후보: 정확한 분수 누적 또는 합계 보존형 정수 배분 정책입니다. -- 제거 위험: 월간 회복과 전쟁 수입에 연쇄 영향이 있습니다. - -## 범위 밖 항목 - -- 비교 도구의 `tools/seed-parity/legacy-float.mjs`는 제품 동작을 바꾸지 않는 - oracle이므로 제거 대상 source marker를 붙이지 않습니다. -- `number_format`, 화면 소수점 자리수, 날짜 포맷은 사용자 출력 계약입니다. -- 전투 공식, RNG 소비, 순회/정렬, 로그와 persistence 순서는 구조적 호환 계약이며 - “트윅”이라는 이유로 일괄 제거하지 않습니다. diff --git a/package.json b/package.json index c249a016..015e70fc 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", "check:legacy:scenario": "SAMMO_REQUIRE_REF_SOURCE=1 pnpm --filter @sammo-ts/game-engine test monthlyCatalogCoverage.test.ts scenarioLoader.test.ts scenarioComposition.test.ts", - "check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs", "check:architecture": "node tools/check-package-boundaries.mjs", "check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs", "test:architecture": "node --test tools/check-package-boundaries.test.mjs", diff --git a/packages/logic/src/actions/turn/general/che_기술연구.ts b/packages/logic/src/actions/turn/general/che_기술연구.ts index 9641ab0b..f6142803 100644 --- a/packages/logic/src/actions/turn/general/che_기술연구.ts +++ b/packages/logic/src/actions/turn/general/che_기술연구.ts @@ -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; diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts index ba5cfd40..30f3fab7 100644 --- a/packages/logic/src/actions/turn/general/che_물자조달.ts +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -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)) { diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts index 62ea1c92..149b22c8 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -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, effects: GeneralActionEffect[] ): 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( diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts index 7d80c02d..f00c75f2 100644 --- a/packages/logic/src/actions/turn/general/che_주민선정.ts +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -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; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 8b63780d..2682e0f3 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -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 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] = diff --git a/packages/logic/src/actions/turn/general/cityTrust.ts b/packages/logic/src/actions/turn/general/cityTrust.ts new file mode 100644 index 00000000..6d0860f7 --- /dev/null +++ b/packages/logic/src/actions/turn/general/cityTrust.ts @@ -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); diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index b6a8f541..79961b2d 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -198,4 +198,4 @@ export const loadGeneralTurnCommandSpecs = async ( return specs; }; -export { readLegacyCityTrust } from './legacyCityTrust.js'; +export { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js'; diff --git a/packages/logic/src/actions/turn/general/legacyCityTrust.ts b/packages/logic/src/actions/turn/general/legacyCityTrust.ts deleted file mode 100644 index d3360050..00000000 --- a/packages/logic/src/actions/turn/general/legacyCityTrust.ts +++ /dev/null @@ -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 diff --git a/packages/logic/src/actions/turn/general/legacyGeneralStat.ts b/packages/logic/src/actions/turn/general/legacyGeneralStat.ts index 465e315b..68533c17 100644 --- a/packages/logic/src/actions/turn/general/legacyGeneralStat.ts +++ b/packages/logic/src/actions/turn/general/legacyGeneralStat.ts @@ -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 diff --git a/packages/logic/src/compat/legacyFloat.ts b/packages/logic/src/compat/legacyFloat.ts deleted file mode 100644 index e9b973ec..00000000 --- a/packages/logic/src/compat/legacyFloat.ts +++ /dev/null @@ -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 diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index 2354a1de..a694eea7 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -89,14 +89,12 @@ const isAssignedToOfficerCity = ( (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 = ( 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', diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index 07fd40fc..888650b4 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -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 { diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index d5d97a3c..8ac18588 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -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, key: string, fallback = 0): number => { const value = meta[key]; diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index 00aeeffd..6af204e0 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -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', () => { diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index d61fe7db..ce3b87a1 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -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); diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 3ac3a3f2..c7d7229e 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -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', () => { diff --git a/packages/logic/test/warUtils.test.ts b/packages/logic/test/warUtils.test.ts index cf142561..c076904f 100644 --- a/packages/logic/test/warUtils.test.ts +++ b/packages/logic/test/warUtils.test.ts @@ -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); + }); }); diff --git a/tools/check-ref-compat-markers.mjs b/tools/check-ref-compat-markers.mjs deleted file mode 100644 index ab4c60e2..00000000 --- a/tools/check-ref-compat-markers.mjs +++ /dev/null @@ -1,101 +0,0 @@ -import { readdir, readFile } from 'node:fs/promises'; -import path from 'node:path'; -import process from 'node:process'; - -const root = process.cwd(); -const sourceRoots = ['app', 'packages']; -const inventoryPath = path.join(root, 'docs', 'ref-compatibility-shims.md'); -const beginPattern = /REF-COMPAT:BEGIN ([a-z0-9]+(?:-[a-z0-9]+)*)/g; -const endPattern = /REF-COMPAT:END ([a-z0-9]+(?:-[a-z0-9]+)*)/g; -const inventoryPattern = //g; -const sourceExtensions = new Set(['.ts', '.tsx', '.vue', '.js', '.mjs', '.cjs']); -const ignoredDirectories = new Set(['node_modules', 'dist', 'coverage', '.turbo']); - -const collectFiles = async (directory) => { - const entries = await readdir(directory, { withFileTypes: true }); - const files = []; - for (const entry of entries) { - const absolute = path.join(directory, entry.name); - if (entry.isDirectory()) { - if (ignoredDirectories.has(entry.name)) { - continue; - } - files.push(...(await collectFiles(absolute))); - } else if (sourceExtensions.has(path.extname(entry.name))) { - files.push(absolute); - } - } - return files; -}; - -const fail = (message) => { - process.stderr.write(`ref compatibility marker check failed: ${message}\n`); - process.exitCode = 1; -}; - -const regionsById = new Map(); -for (const sourceRoot of sourceRoots) { - for (const file of await collectFiles(path.join(root, sourceRoot))) { - const relative = path.relative(root, file); - const lines = (await readFile(file, 'utf8')).split(/\r?\n/); - let openRegion = null; - for (let index = 0; index < lines.length; index += 1) { - const lineNumber = index + 1; - const begins = [...lines[index].matchAll(beginPattern)].map((match) => match[1]); - const ends = [...lines[index].matchAll(endPattern)].map((match) => match[1]); - if (begins.length + ends.length > 1) { - fail(`${relative}:${lineNumber} contains more than one marker`); - continue; - } - if (begins.length === 1) { - if (openRegion) { - fail(`${relative}:${lineNumber} nests ${begins[0]} inside ${openRegion.id}`); - } else { - openRegion = { id: begins[0], line: lineNumber }; - } - } - if (ends.length === 1) { - if (!openRegion) { - fail(`${relative}:${lineNumber} closes ${ends[0]} without a BEGIN marker`); - } else if (openRegion.id !== ends[0]) { - fail(`${relative}:${lineNumber} closes ${ends[0]} but ${openRegion.id} is open`); - } else { - const regions = regionsById.get(openRegion.id) ?? []; - regions.push(`${relative}:${openRegion.line}-${lineNumber}`); - regionsById.set(openRegion.id, regions); - openRegion = null; - } - } - } - if (openRegion) { - fail(`${relative}:${openRegion.line} leaves ${openRegion.id} open`); - } - } -} - -const inventory = await readFile(inventoryPath, 'utf8'); -const inventoryIds = [...inventory.matchAll(inventoryPattern)].map((match) => match[1]); -const uniqueInventoryIds = new Set(inventoryIds); -if (uniqueInventoryIds.size !== inventoryIds.length) { - fail('the inventory contains a duplicate REF-COMPAT-ID'); -} - -for (const id of regionsById.keys()) { - if (!uniqueInventoryIds.has(id)) { - fail(`${id} is marked in source but missing from docs/ref-compatibility-shims.md`); - } -} -for (const id of uniqueInventoryIds) { - if (!regionsById.has(id)) { - fail(`${id} is documented but has no source region`); - } -} - -if (process.exitCode) { - process.exit(process.exitCode); -} - -const regionCount = [...regionsById.values()].reduce((sum, regions) => sum + regions.length, 0); -process.stdout.write( - `ref compatibility markers: ${regionsById.size} ids, ${regionCount} source regions, inventory synchronized\n` -); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 30cfd27d..e41698af 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -1,12 +1,10 @@ import { GameClock, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; -import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js'; import { GENERAL_TURN_COMMAND_KEYS, isSelectableGeneralTurnCommandKey, LogFormat, NATION_TURN_COMMAND_KEYS, normalizeScenarioEffect, - readLegacyCityTrust, sendMessage, type MapDefinition, type MessageDraft, @@ -934,10 +932,7 @@ const projectWorld = ( conflict: city.conflict ?? {}, state: city.state, term: readNumber(city.meta, 'term'), - // The reference snapshot observes MariaDB FLOAT through its text - // protocol. Project the in-memory binary32 value at that same - // read boundary before comparing state deltas. - trust: readLegacyCityTrust(readNumber(city.meta, 'trust')), + trust: readNumber(city.meta, 'trust'), trade: readNumber(city.meta, 'trade'), officerSet: readNumber(city.meta, 'officer_set'), })), @@ -950,7 +945,7 @@ const projectWorld = ( capitalCityId: nation.capitalCityId, gold: toDatabaseInt(nation.gold), rice: toDatabaseInt(nation.rice), - tech: readLegacyStoredFloat(readNumber(nation.meta, 'tech')), + tech: readNumber(nation.meta, 'tech'), level: nation.level, typeCode: nation.typeCode, generalCount: readNumber(