diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index 92a02e5..e014884 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -150,11 +150,13 @@ 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 b80f033..4f90465 100644 --- a/app/game-engine/src/turn/monthlyCitySupplyAction.ts +++ b/app/game-engine/src/turn/monthlyCitySupplyAction.ts @@ -4,7 +4,9 @@ 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 resolveOfficerCity = (meta: Record): number => { const camel = meta.officerCity; @@ -82,9 +84,11 @@ export const createUpdateCitySupplyHandler = (options: { wall: roundLegacyIntegerColumn(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 }, }); if (damaged) { diff --git a/app/game-engine/src/turn/monthlyDisasterAction.ts b/app/game-engine/src/turn/monthlyDisasterAction.ts index 99a341e..461f7c9 100644 --- a/app/game-engine/src/turn/monthlyDisasterAction.ts +++ b/app/game-engine/src/turn/monthlyDisasterAction.ts @@ -67,7 +67,9 @@ 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 export const createRaiseDisasterHandler = (options: { getWorld: () => InMemoryTurnWorld | null; @@ -134,7 +136,9 @@ 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 world.updateCity(city.id, { state: picked.stateCode, population: roundLegacyIntegerColumn( diff --git a/app/game-engine/src/turn/monthlyNationStatsHandler.ts b/app/game-engine/src/turn/monthlyNationStatsHandler.ts index 62dc8f2..323b0bc 100644 --- a/app/game-engine/src/turn/monthlyNationStatsHandler.ts +++ b/app/game-engine/src/turn/monthlyNationStatsHandler.ts @@ -7,10 +7,12 @@ 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 const readNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number' && Number.isFinite(value)) { diff --git a/app/game-engine/src/turn/monthlySemiAnnualAction.ts b/app/game-engine/src/turn/monthlySemiAnnualAction.ts index 523f30a..fd67604 100644 --- a/app/game-engine/src/turn/monthlySemiAnnualAction.ts +++ b/app/game-engine/src/turn/monthlySemiAnnualAction.ts @@ -7,12 +7,14 @@ 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 parseResource = (args: readonly unknown[]): SemiAnnualResource => { const resource = args[0]; @@ -29,7 +31,9 @@ const resolveBasePopulationIncrease = (world: InMemoryTurnWorld): number => { const decayDomesticValue = (value: number): number => roundLegacyIntegerColumn(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 const applyResourceMaintenance = (value: number, ratios: readonly [number, number][]): number => { if (value <= 1_000) { diff --git a/docs/index.md b/docs/index.md index 4ac3ea9..ba991ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,6 +34,8 @@ features: Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요. 게임 진행 시각과 운영 벽시계의 경계는 [게임 시계](./architecture/game-clock.md)에 설명합니다. +Ref 전용 수치·저장 표현 보정과 제거 절차는 +[Ref 호환 shim 인벤토리](./ref-compatibility-shims.md)에 모아 둡니다. 세부 문서는 다음 책임으로 나뉩니다. diff --git a/docs/ref-compatibility-shims.md b/docs/ref-compatibility-shims.md new file mode 100644 index 0000000..0240ea2 --- /dev/null +++ b/docs/ref-compatibility-shims.md @@ -0,0 +1,159 @@ +# 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 8029425..e67d69c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "manage:general-icons": "node tools/manage-general-icons.mjs", "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:ref-compat-markers": "node tools/check-ref-compat-markers.mjs", "test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json", "typecheck:e2e:frontend-legacy": "tsc -p tools/frontend-legacy-parity/tsconfig.json --noEmit", "test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs", diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts index c959ec0..2fa2753 100644 --- a/packages/logic/src/actions/turn/general/che_물자조달.ts +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -25,7 +25,9 @@ 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 resolveLegacyExperienceLevel = (experience: number): number => Math.max( diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 117183e..65a93e6 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -60,12 +60,14 @@ 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 ARGS_SCHEMA = z.preprocess( (raw) => { diff --git a/packages/logic/src/actions/turn/general/legacyCityTrust.ts b/packages/logic/src/actions/turn/general/legacyCityTrust.ts index b8dcfed..d336005 100644 --- a/packages/logic/src/actions/turn/general/legacyCityTrust.ts +++ b/packages/logic/src/actions/turn/general/legacyCityTrust.ts @@ -5,6 +5,8 @@ */ 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 68533c1..465e315 100644 --- a/packages/logic/src/actions/turn/general/legacyGeneralStat.ts +++ b/packages/logic/src/actions/turn/general/legacyGeneralStat.ts @@ -1,3 +1,5 @@ +// 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 index 2acf50b..e9b973e 100644 --- a/packages/logic/src/compat/legacyFloat.ts +++ b/packages/logic/src/compat/legacyFloat.ts @@ -1,3 +1,4 @@ +// 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. @@ -27,3 +28,4 @@ export const readLegacyStoredFloat = (value: number): number => { 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 6c8465e..10e11d8 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -50,12 +50,14 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0); +// 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; @@ -127,9 +129,11 @@ 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 if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) { process.stdout.write( `WAR_TECH_TRACE ${JSON.stringify({ engine: 'core', nationId: nation.id, side: context.side, currentTech, baseGain, gain, total, effective, divisor, delta, storedTech: nation.meta.tech, attackerGeneralId: context.attackerReport.id })}\n` diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index 41d1f3e..e16a1145 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -390,10 +390,12 @@ 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 } public calcRiceConsumption(damage: number): number { diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index 35f5ac4..5666af8 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -11,6 +11,7 @@ export const clampMin = (value: number, min: number): number => (value < min ? m export const clampMax = (value: number, max: number): number => (value > max ? max : 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: @@ -28,6 +29,7 @@ export const round = (value: number): number => { 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 getMetaNumber = (meta: Record, key: string, fallback = 0): number => { const value = meta[key]; diff --git a/tools/check-ref-compat-markers.mjs b/tools/check-ref-compat-markers.mjs new file mode 100644 index 0000000..1d3e021 --- /dev/null +++ b/tools/check-ref-compat-markers.mjs @@ -0,0 +1,97 @@ +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 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()) { + 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` +);