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

This commit is contained in:
2026-08-24 21:27:26 +00:00
32 changed files with 148 additions and 522 deletions
+4 -9
View File
@@ -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 () => {
+2 -2
View File
@@ -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)에 정리합니다.
세부 문서는 다음 책임으로 나뉩니다.
+25
View File
@@ -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이 아닙니다.
-159
View File
@@ -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 저장·읽기 경계
<!-- REF-COMPAT-ID: ref-mariadb-float-boundary -->
- 성격: 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 반올림 보정
<!-- REF-COMPAT-ID: ref-php-half-rounding -->
- 성격: 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 안정화 후 정수화
<!-- REF-COMPAT-ID: ref-decimal-half-stabilization -->
- 성격: `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 재현
<!-- REF-COMPAT-ID: ref-meekrodb-sql-precision -->
- 성격: 전투 숙련도 누적값을 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-COMPAT-ID: ref-int-column-write-rounding -->
- 성격: 중간 계산값을 그대로 유지하지 않고 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 입력과 저장
상태를 함께 비교해야 합니다.
### 부상 적용 뒤 정수 능력치 절삭
<!-- REF-COMPAT-ID: ref-stat-injury-truncation -->
- 성격: 부상 비율을 능력치에 먼저 곱하고 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 실수 유지
<!-- REF-COMPAT-ID: ref-income-preflush-fraction -->
- 성격: 국가 정수 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
<!-- REF-COMPAT-ID: ref-dead-split-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 순서는 구조적 호환 계약이며
“트윅”이라는 이유로 일괄 제거하지 않습니다.
-1
View File
@@ -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",
@@ -27,11 +27,6 @@ import {
} from './che_상업투자.js';
import { JosaUtil } from '@sammo-ts/common';
import { clamp } from 'es-toolkit';
import {
addLegacyStoredFloat,
readLegacyStoredFloat,
toLegacyStoredFloat,
} from '@sammo-ts/logic/compat/legacyFloat.js';
export interface TechResearchArgs {}
@@ -59,18 +54,7 @@ const readTech = (nation: Nation): number => {
return typeof tech === 'number' && Number.isFinite(tech) ? tech : 0;
};
// 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다.
// Ref stores tech in a MariaDB FLOAT column. FLOAT applies binary32
// quantization on write; its six-significant-digit text rendering happens
// only when the value is read, not on every update.
export const toLegacyStoredTech = toLegacyStoredFloat;
// mysqli renders a MariaDB FLOAT with six significant decimal digits before
// PHP performs the next command's arithmetic. Model that read boundary, then
// model the binary32 write boundary separately.
export const readLegacyStoredTech = readLegacyStoredFloat;
export const addLegacyStoredTech = addLegacyStoredFloat;
export const addTech = (current: number, delta: number): number => current + delta;
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -143,7 +127,7 @@ export class ActionDefinition<
context.nation.meta = {
...context.nation.meta,
tech: addLegacyStoredTech(currentTech, techScore / generalCount),
tech: addTech(currentTech, techScore / generalCount),
};
context.general.gold = Math.max(0, context.general.gold - result.costGold);
context.general.experience += result.exp;
@@ -26,9 +26,7 @@ interface ProcureContext<
const ACTION_NAME = '물자조달';
const ACTION_KEY = 'che_물자조달';
// REF-COMPAT:BEGIN ref-int-column-write-rounding
export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
// REF-COMPAT:END ref-int-column-write-rounding
export const roundAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
export const resolveLegacyExperienceLevel = (experience: number): number =>
Math.max(
@@ -123,8 +121,8 @@ export class ActionResolver<
// accumulated binary value is exactly 4564.5 and persists as 4565.
const rawNextExp = general.experience + exp;
const rawNextDed = general.dedication + ded;
const nextExp = Math.round(rawNextExp);
const nextDed = Math.round(rawNextDed);
const nextExp = roundAccumulatedInteger(general.experience, exp);
const nextDed = roundAccumulatedInteger(general.dedication, ded);
let appliedScore = score;
if (context.city && [1, 3].includes(context.city.frontState)) {
@@ -5,7 +5,7 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
import { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
import {
STRATEGY_ARGS_SCHEMA,
StrategyActionDefinition,
@@ -43,9 +43,8 @@ export class ActionResolver<
result: StrategyResult<TriggerState>,
effects: GeneralActionEffect<TriggerState>[]
): void {
const currentTrust =
typeof context.destCity.meta.trust === 'number' ? readLegacyCityTrust(context.destCity.meta.trust) : 50;
const nextTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.secondaryAmount));
const currentTrust = resolveCityTrustValue(context.destCity.meta.trust);
const nextTrust = adjustCityTrust(currentTrust, -result.secondaryAmount);
effects.push(
createCityPatchEffect(
@@ -22,8 +22,7 @@ import {
updateDomesticCriticalMeta,
} from './che_상업투자.js';
import { JosaUtil } from '@sammo-ts/common';
import { clamp } from 'es-toolkit';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
import { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
export interface TrustActionArgs {}
@@ -45,8 +44,7 @@ const CONFIG: InvestmentConfig = {
};
const readTrust = (city: City): number => {
const trust = city.meta.trust;
return typeof trust === 'number' && Number.isFinite(trust) ? readLegacyCityTrust(trust) : DEFAULT_TRUST;
return resolveCityTrustValue(city.meta.trust, DEFAULT_TRUST);
};
const remainCityTrust = (): Constraint => ({
@@ -105,7 +103,7 @@ export class ActionDefinition<
const trustDelta = result.score / 10;
context.city.meta = {
...context.city.meta,
trust: storeLegacyCityTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)),
trust: adjustCityTrust(readTrust(context.city), trustDelta),
};
context.general.rice = Math.max(0, context.general.rice - result.costGold);
context.general.experience += result.exp;
@@ -30,7 +30,7 @@ import {
isCrewTypeAvailable,
} from '@sammo-ts/logic/world/unitSet.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
import { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
export interface RecruitEnvironment {
@@ -61,14 +61,7 @@ const DEFAULT_MIN_POP = 30000;
const DEFAULT_TRUST = 50;
const MIN_CREW = 100;
// REF-COMPAT:BEGIN ref-php-half-rounding
// PHP round() compensates for the small binary drift around a half boundary.
// Ref then converts the result to int through Util::round().
export const roundLegacyRecruitCost = (value: number): number => {
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value));
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
};
// REF-COMPAT:END ref-php-half-rounding
export const roundRecruitCost = (value: number): number => Math.round(value);
export const ARGS_SCHEMA = z.preprocess(
(raw) => {
@@ -330,7 +323,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
crewType ? { armType: crewType.armType } : undefined
);
return {
gold: roundLegacyRecruitCost(adjustedGold * costOffset),
gold: roundRecruitCost(adjustedGold * costOffset),
rice: Math.round(adjustedRice),
applied: plan.applied,
requested: plan.requested,
@@ -444,9 +437,9 @@ export class ActionResolver<
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
const recruitPop = this.command.getRecruitPopulation(context, appliedCrew);
const nextPopulation = Math.max(city.population - recruitPop, 0);
const baseTrust = readLegacyCityTrust(readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST));
const baseTrust = resolveCityTrustValue(readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST));
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
const nextTrust = storeLegacyCityTrust(Math.max(baseTrust - trustLoss, 0));
const nextTrust = adjustCityTrust(baseTrust, -trustLoss);
const actionName = this.env.actionName ?? ACTION_NAME;
const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] =
@@ -0,0 +1,6 @@
import { clamp } from 'es-toolkit';
export const resolveCityTrustValue = (value: unknown, fallback = 50): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
export const adjustCityTrust = (current: number, delta: number): number => clamp(current + delta, 0, 100);
@@ -198,4 +198,4 @@ export const loadGeneralTurnCommandSpecs = async (
return specs;
};
export { readLegacyCityTrust } from './legacyCityTrust.js';
export { adjustCityTrust, resolveCityTrustValue } from './cityTrust.js';
@@ -1,12 +0,0 @@
/**
* MariaDB stores city.trust as FLOAT (binary32), while the PHP driver exposes
* the value rounded to six significant decimal digits on the next read.
* Keep those boundaries separate so later SQL expressions use binary32 state.
*/
import { readLegacyStoredFloat, toLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
export const storeLegacyCityTrust = (value: number): number => toLegacyStoredFloat(value);
export const readLegacyCityTrust = (value: number): number => readLegacyStoredFloat(value);
// REF-COMPAT:END ref-mariadb-float-boundary
@@ -1,5 +1,3 @@
// REF-COMPAT:BEGIN ref-stat-injury-truncation
export const applyLegacyInjury = (value: number, injury: number): number => value * ((100 - injury) / 100);
export const finalizeLegacyStat = (value: number): number => Math.trunc(value);
// REF-COMPAT:END ref-stat-injury-truncation
-31
View File
@@ -1,31 +0,0 @@
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
// MariaDB FLOAT stores binary32, but its text protocol exposes only six
// significant decimal digits. Ref reads that text into PHP before every
// command/battle update, so both boundaries are part of the game state.
export const toLegacyStoredFloat = (value: number): number => Math.fround(value);
const roundHalfEven = (value: number): number => {
const lower = Math.floor(value);
const fraction = value - lower;
const tolerance = Number.EPSILON * Math.max(1, Math.abs(value)) * 4;
if (Math.abs(fraction - 0.5) <= tolerance) {
return lower % 2 === 0 ? lower : lower + 1;
}
return Math.round(value);
};
export const readLegacyStoredFloat = (value: number): number => {
const stored = Math.fround(value);
if (!Number.isFinite(stored) || stored === 0) {
return stored;
}
const sign = stored < 0 ? -1 : 1;
const absolute = Math.abs(stored);
const exponent = Math.floor(Math.log10(absolute));
const scale = 10 ** (5 - exponent);
return sign * (roundHalfEven(absolute * scale) / scale);
};
export const addLegacyStoredFloat = (current: number, delta: number): number =>
toLegacyStoredFloat(readLegacyStoredFloat(current) + delta);
// REF-COMPAT:END ref-mariadb-float-boundary
+1 -7
View File
@@ -89,14 +89,12 @@ const isAssignedToOfficerCity = <TriggerState extends GeneralTriggerState>(
(key) => getMetaNumber(general.meta, key, Number.NaN) === cityId
);
// REF-COMPAT:BEGIN ref-dead-split-int-binding
const increaseDeadCounter = (city: City, delta: number): void => {
// Ref binds each `dead + %i` increment as an integer before MariaDB adds
// it. Truncate each 40/60 percent split independently; rounding the
// accumulated counter changes monthly recovery and war income.
city.meta[META_DEAD] = getDeadCounter(city) + Math.trunc(delta);
};
// REF-COMPAT:END ref-dead-split-int-binding
const isSupplyCity = (city: City): boolean => {
const raw = city.meta.supply;
@@ -168,11 +166,7 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
const divisor = Math.max(config.initialNationGenLimit, total);
const currentTech = getMetaNumber(nation.meta, 'tech', 0);
const delta = gain / divisor;
// REF-COMPAT:BEGIN ref-mariadb-float-boundary
// Ref executes `tech + delta` inside MariaDB for battle gains, so the
// arithmetic starts from the stored binary32 value without a PHP text read.
nation.meta.tech = Math.fround(currentTech + delta);
// REF-COMPAT:END ref-mariadb-float-boundary
nation.meta.tech = currentTech + delta;
if (input.trace?.isEnabled('WAR_TECH_TRACE', { nationIds: [nation.id] })) {
input.trace.write('WAR_TECH_TRACE', {
engine: 'core',
+1 -6
View File
@@ -402,12 +402,7 @@ export class WarUnitGeneral<
nextExp *= 0.9;
}
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', nextExp, { armType });
// REF-COMPAT:BEGIN ref-meekrodb-sql-precision
// PHP interpolates floats into MeekroDB SQL with precision=14 before
// MariaDB rounds the integer dex column. Normalize this accumulated
// battle value at the same boundary (for example ...499999999996 -> .5).
this.general.meta[key] = Number((base + adjustedExp).toPrecision(14));
// REF-COMPAT:END ref-meekrodb-sql-precision
this.general.meta[key] = base + adjustedExp;
}
public calcRiceConsumption(damage: number): number {
+1 -19
View File
@@ -9,25 +9,7 @@ export const clamp = (value: number, min: number, max: number): number => Math.m
export const clampMin = (value: number, min: number): number => (value < min ? min : value);
// REF-COMPAT:BEGIN ref-php-half-rounding
// PHP's round() compensates for small binary floating-point drift around a
// half boundary and rounds halves away from zero. War state is persisted to
// integer columns through legacy Util::round(), so Math.round() is not enough:
// e.g. accumulated siege damage can produce 4159.499999999999, which PHP
// rounds to 4160 while Math.round() returns 4159.
export const round = (value: number): number => {
if (!Number.isFinite(value)) {
return Math.round(value);
}
// PHP 8.3's round() uses a wider half-boundary fuzz than one JavaScript
// ulp at four-digit battle totals. Accumulating several fractional battle
// rewards can land about three ulps below .5 (for example
// 8719.4999999999945), which PHP still rounds upward.
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)) * 4;
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
};
// REF-COMPAT:END ref-php-half-rounding
export const round = (value: number): number => Math.round(value);
export const getMetaNumber = (meta: Record<string, TriggerValue>, key: string, fallback = 0): number => {
const value = meta[key];
@@ -5,7 +5,7 @@ import type { City, General, Nation } from '../../src/domain/entities.js';
import type { WorldSnapshot } from '../../src/world/types.js';
import {
commandSpec as procureSpec,
roundLegacyAccumulatedInteger,
roundAccumulatedInteger,
resolveLegacyDedicationLevel,
resolveLegacyExperienceLevel,
} from '../../src/actions/turn/general/che_물자조달.js';
@@ -35,13 +35,9 @@ import {
normalizeLegacyGeneratedDex,
resolveLegacySpecialityAge,
} from '../../src/actions/turn/general/che_인재탐색.js';
import {
addLegacyStoredTech,
readLegacyStoredTech,
toLegacyStoredTech,
} from '../../src/actions/turn/general/che_기술연구.js';
import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js';
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
import { addTech } from '../../src/actions/turn/general/che_기술연구.js';
import { adjustCityTrust, resolveCityTrustValue } from '../../src/actions/turn/general/cityTrust.js';
import { roundRecruitCost } from '../../src/actions/turn/general/che_징병.js';
import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js';
import { traitModule as ambitiousPersonality } from '../../src/actionModules/traits/personality/che_출세.js';
@@ -54,7 +50,7 @@ describe('General Commands New Scenario', () => {
const delta = (45 * 0.7) / 3;
expect(delta).toBe(10.499999999999998);
expect(Math.round(delta)).toBe(10);
expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565);
expect(roundAccumulatedInteger(4554, delta)).toBe(4565);
});
it('calculates procurement levels before MariaDB rounds the INT columns', () => {
@@ -71,32 +67,28 @@ describe('General Commands New Scenario', () => {
expect(resolveLegacySpecialityAge(80, 24, 12)).toBe(29);
});
it('stores technology as binary32 without per-update decimal quantization', () => {
const value = 433.51797;
expect(toLegacyStoredTech(value)).toBe(Math.fround(value));
expect(toLegacyStoredTech(value)).not.toBe(Number(Math.fround(value).toPrecision(6)));
expect(readLegacyStoredTech(624.0966796875)).toBe(624.097);
expect(addLegacyStoredTech(624.0966796875, 22.9)).toBe(Math.fround(624.097 + 22.9));
expect(readLegacyStoredTech(533.3125)).toBe(533.312);
expect(readLegacyStoredTech(533.4375)).toBe(533.438);
it('keeps full precision while accumulating technology', () => {
const current = 624.0966796875;
const updated = addTech(current, 22.9);
expect(updated).toBe(current + 22.9);
expect(updated).not.toBe(Math.fround(current + 22.9));
expect(updated).not.toBe(Number((current + 22.9).toPrecision(6)));
});
it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => {
const stored = storeLegacyCityTrust(88.306755);
it('keeps full precision while adjusting city trust', () => {
const current = resolveCityTrustValue(88.306755);
expect(stored).toBe(Math.fround(88.306755));
expect(stored).not.toBe(readLegacyCityTrust(stored));
expect(readLegacyCityTrust(stored)).toBe(88.3068);
expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068);
expect(readLegacyCityTrust(storeLegacyCityTrust(93.40625))).toBe(93.4062);
expect(adjustCityTrust(current, 10)).toBe(98.306755);
expect(adjustCityTrust(current, 20)).toBe(100);
expect(adjustCityTrust(current, -100)).toBe(0);
});
it('rounds recruitment cost across the PHP half boundary', () => {
it('uses the native integer boundary for recruitment cost', () => {
const cavalryCost = (11 * 1.15 * 7000) / 100;
expect(cavalryCost).toBe(885.4999999999999);
expect(Math.round(cavalryCost)).toBe(885);
expect(roundLegacyRecruitCost(cavalryCost)).toBe(886);
expect(roundRecruitCost(cavalryCost)).toBe(885);
});
it('uses the legacy minimum trust for domestic calculations', () => {
+2 -2
View File
@@ -278,8 +278,8 @@ describe('war aftermath', () => {
messageTime: MESSAGE_TIME,
});
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
expect(defenderNation.meta.tech).toBe(Math.fround(1000.9));
expect(attackerNation.meta.tech).toBe(1000.6);
expect(defenderNation.meta.tech).toBe(1000.9);
expect(outcome.diplomacyDeltas).toHaveLength(2);
expect(attackerCity.meta.dead).toBe(60);
expect(defenderCity.meta.dead).toBe(90);
+2 -2
View File
@@ -293,7 +293,7 @@ describe('war triggers', () => {
expect(unit.getComputedAttack()).toBeCloseTo(608, 12);
});
it('normalizes accumulated dexterity to the PHP SQL float precision', () => {
it('keeps accumulated dexterity at full precision', () => {
const general = buildGeneral(80);
general.meta.dex4 = 14_677.199999999997;
const wizard = new WarCrewType({
@@ -316,7 +316,7 @@ describe('war triggers', () => {
unit.addDex(wizard, 2047);
expect(general.meta.dex4).toBe(16_519.5);
expect(general.meta.dex4).toBe(16_519.499999999996);
});
it('preserves the legacy fractional morale gain after a win', () => {
+11 -6
View File
@@ -2,16 +2,21 @@ import { describe, expect, it } from 'vitest';
import { round } from '../src/war/utils.js';
describe('legacy war rounding', () => {
it('matches PHP round() at drifted positive and negative half boundaries', () => {
expect(round(4159.499999999999)).toBe(4160);
expect(round(-4159.499999999999)).toBe(-4160);
expect(round(8719.4999999999945)).toBe(8720);
expect(round(-8719.4999999999945)).toBe(-8720);
describe('war rounding', () => {
it('uses the native number boundary without half-point correction', () => {
expect(round(4159.499999999999)).toBe(4159);
expect(round(-4159.499999999999)).toBe(-4159);
expect(round(8719.4999999999945)).toBe(8719);
expect(round(-8719.4999999999945)).toBe(-8719);
});
it('keeps values meaningfully below a half boundary on the lower integer', () => {
expect(round(4159.499999)).toBe(4159);
expect(round(-4159.499999)).toBe(-4159);
});
it('uses JavaScript semantics at exact half points', () => {
expect(round(1.5)).toBe(2);
expect(round(-1.5)).toBe(-1);
});
});
-101
View File
@@ -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 = /<!-- REF-COMPAT-ID: ([a-z0-9]+(?:-[a-z0-9]+)*) -->/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`
);
@@ -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(