diff --git a/CHANGELOG.md b/CHANGELOG.md index d484b1d..8e10ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -325,3 +325,10 @@ All notable changes to DarkFrame are documented here. Format based on - Initial Postgres migration effort, GitHub repo sanitization (secret scrub + history rewrite), README redesign, Vercel deployment pipeline bring-up (lazy DB connection for build-time env isolation), production DB connection fixes. + +### Changed — ladder-truth gate extended to every documented game-math table (FID-20260915-007) + +- **Four new ladders** (10 new documented sites, 65 new cells): regeneration-rate (4 sites — the rate table + three range summaries, actuals **engine-derived** as tick(0) ÷ spawner-max), unit-cost curve (game.types roster counts, BALANCING PHILOSOPHY table ↔ `TIER_UNLOCK_REQUIREMENTS`, slot ladder 1/3/7/15/30), build-rate (header intervals ↔ `BUILD_RATES`, Ghost pinned at the docs' one-decimal precision — 0.67 is a rounded 1/1.5), army composition (header bullets + table comments ↔ `ARMY_COMPOSITION`, bare `50/50` rows parsed as str-first). +- **Two live falsehoods corrected** (the new gate's first catch, before it even shipped): both regen summaries said "5-20% per hour" while Boss regenerates at 2% — now "2-20%" in all four sites; UNIT_CONFIGS' "all 40 units (5 tiers × 8)" now scoped truthfully to 65 units (40-unit blueprint-derived core + 25 SPEC/PRESTIGE). +- `BUILD_RATES`/`ARMY_COMPOSITION` exported from botGrowthEngine (pure data; botArmyCaps.test.ts already behavior-pins caps/age — scope respected). +- Drill-proven: comment-only edits to the regen table (drill E), the botService range summary (drill F), and the philosophy table (drill G) each fail the gate (2/2/2 tests), restored green after each. Coverage arithmetic asserted in-test (a parser silently skipping the bare 50/50 row shape is itself caught). diff --git a/SCOPE.md b/SCOPE.md index 1e0a862..093e118 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -1043,3 +1043,4 @@ Every step of the approved plan carries an explicit status (`implemented | block Verification evidence for the `implemented` statuses is recorded in `dev/session-summaries/SESSION-2026-09-01-001.md` and `dev/session-summaries/SESSION-2026-09-02-001.md`. +| 54 | **Ladder-truth gate → every documented game-math table (FID-20260915-007):** four new ladders (regen-rate 12 cells/4 sites engine-derived via tick(0)÷max, unit-cost 23/2, build-rate 10/2, army-composition 20/2) + first catches corrected pre-ship: regen summaries said 5-20% while Boss is 2% (now 2-20% ×4 sites), UNIT_CONFIGS header said 40 units vs 65 actual (rescoped). BUILD_RATES/ARMY_COMPOSITION exported. Drill-proven E/F/G (comment-only edits fail 2/2/2). Gates: tsc 0 · lint 0 · vitest 839/1 skipped. Status: verified (uncommitted) | diff --git a/__tests__/lib/ladderTruth.test.ts b/__tests__/lib/ladderTruth.test.ts index 358a57b..fe9f762 100644 --- a/__tests__/lib/ladderTruth.test.ts +++ b/__tests__/lib/ladderTruth.test.ts @@ -1,18 +1,29 @@ /** * Ladder-truth gate — the FID-20260915-006a lesson made executable. * - * The bot tier ladders drifted for months because the documented tables lived - * only in comments while the truth lived in formulas; nothing ever EXECUTED - * the comments. This gate parses the documented ladders out of - * `lib/botService.ts` itself and asserts each documented value equals the - * live function output, so: + * Documented game-math tables are parsed out of the production source itself + * and paired with live function/constant output, so: * - a comment edit that drifts from code fails CI (the original sin), and * - a formula change without a doc update fails too. * + * Seven ladders (FID-20260915-007 extended the original three to every + * documented game-math table): + * 1. resource multiplier (botService, 2 sites, 21 cells) + * 2. base defense (botService, 2 sites, 14 cells) + * 3. player level bracket (botService, 1 site, 7 cells) + * 4. regeneration rate (botGrowthEngine + botService, 4 sites, 12 cells) + * 5. unit cost curve (game.types, 2 sites, 23 cells) + * 6. build rate (botGrowthEngine, 2 sites, 10 cells) + * 7. army composition (botGrowthEngine, 2 sites, 20 cells) + * * The scanner (scripts/ladderTruth.ts) executes at import and throws loudly * if a documented table disappears or its format changes — the gate cannot - * silently degrade to zero assertions. Independent truth spot-pins below - * guard against the scanner itself lying. + * silently degrade to zero assertions. Site counts AND cell counts are + * asserted here (a parser silently skipping a row shape must fail, not + * shrink). Independent truth spot-pins guard against the scanner lying. + * Blueprint↔config field parity of the unit roster is pinned separately by + * __tests__/unit/catalog-unification.test.ts (behavior) — this gate covers + * the documented claims (docs ↔ code). */ import { describe, it, expect } from 'vitest'; import { @@ -20,22 +31,40 @@ import { resourceLadder, defenseLadder, bracketLadder, + regenRateLadder, + unitCostLadder, + buildRateLadder, + armyCompositionLadder, } from '@/scripts/ladderTruth'; import { getResourceRange, getBotDefenseForTier, getPlayerLevelBonus, } from '@/lib/botService'; -import { BotSpecialization } from '@/types/game.types'; +import { regenerateBotResources, BUILD_RATES, ARMY_COMPOSITION } from '@/lib/botGrowthEngine'; +import { BotSpecialization, type Player } from '@/types/game.types'; + +const EXPECTED_CELLS: Record = { + 'resource multiplier': 21, + 'base defense': 14, + 'player level bracket': 7, + 'regeneration rate': 12, + 'unit cost curve': 23, + 'build rate': 10, + 'army composition': 20, +}; -describe('ladder truth: documented tier tables equal code (FID-20260915-006a lesson)', () => { - it('scanner is alive — all three ladders found with full expected coverage', () => { - expect(ladders).toHaveLength(3); - // Resource: 7 header tiers × 2 specs + 7 function-comment tiers = 21. - // Defense: 7 × 2 sites = 14. Brackets: 7 rows × 1 site. - expect(resourceLadder.cellsChecked).toBe(21); - expect(defenseLadder.cellsChecked).toBe(14); - expect(bracketLadder.cellsChecked).toBe(7); +describe('ladder truth: documented game-math tables equal code (FID-20260915-006a/-007)', () => { + it('scanner is alive — all seven ladders found at full expected coverage', () => { + expect(ladders).toHaveLength(7); + for (const ladder of ladders) { + expect(ladder.cellsChecked, `${ladder.ladder} coverage`).toBe(EXPECTED_CELLS[ladder.ladder]); + } + // Site-count guards hold (drift-by-deletion protection) + expect(regenRateLadder.sites).toHaveLength(4); + expect(unitCostLadder.sites).toHaveLength(2); + expect(buildRateLadder.sites).toHaveLength(2); + expect(armyCompositionLadder.sites).toHaveLength(2); }); it('resource multiplier ladder: header table and function comment match getResourceRange', () => { @@ -50,6 +79,22 @@ describe('ladder truth: documented tier tables equal code (FID-20260915-006a les expect(bracketLadder.mismatches).toEqual([]); }); + it('regeneration-rate ladder: rate table, range summaries, and engine agree', () => { + expect(regenRateLadder.mismatches).toEqual([]); + }); + + it('unit-cost ladder: roster counts, philosophy table, and slot ladder match code', () => { + expect(unitCostLadder.mismatches).toEqual([]); + }); + + it('build-rate ladder: header intervals and table comments match BUILD_RATES', () => { + expect(buildRateLadder.mismatches).toEqual([]); + }); + + it('army-composition ladder: header bullets and table comments match ARMY_COMPOSITION', () => { + expect(armyCompositionLadder.mismatches).toEqual([]); + }); + it('aggregate: zero drift anywhere (failure lists every offending cell)', () => { const bad = ladders.flatMap((l) => l.mismatches.map((m) => `${l.ladder} [${m.site}] ${m.key}: documented ${m.documented} ≠ actual ${m.actual}`) @@ -58,12 +103,24 @@ describe('ladder truth: documented tier tables equal code (FID-20260915-006a les }); // --- Independent truth spot-pins (do not trust the scanner blindly) --- - it('truth spot-pins: the ladders are what FID-20260915-006a established', () => { + it('truth spot-pins: the ladders are what FID-006a/-007 established', () => { expect(getBotDefenseForTier(1)).toBe(15); expect(getBotDefenseForTier(7)).toBe(2880); expect(getResourceRange(BotSpecialization.Hoarder, 1).max).toBe(112500); // 150k × 0.75 expect(getResourceRange(BotSpecialization.Hoarder, 7).max).toBe(337500); // 150k × 2.25 expect(getPlayerLevelBonus(5)).toBe(1.0); expect(getPlayerLevelBonus(65)).toBe(2.5); + // regen: Boss's 0.02 entry → 120,000/h on the 6M fixed range (the cell + // whose existence the "5-20%" falsehood hid) + const boss = { + botConfig: { specialization: BotSpecialization.Boss, tier: 1 }, + resources: { metal: 0, energy: 0, food: 0 }, + } as unknown as Player; + expect(regenerateBotResources(boss).metal).toBe(120_000); + // build rates: the rounded-reciprocal pairs (0.5 ↔ 2h, 0.67 ↔ 1.5h) + expect(BUILD_RATES.Fortress).toBe(0.5); + expect(BUILD_RATES.Ghost).toBe(0.67); + // composition: Fortress 30/70 wall + expect(ARMY_COMPOSITION.Fortress).toEqual({ str: 0.3, def: 0.7 }); }); }); diff --git a/lib/botGrowthEngine.ts b/lib/botGrowthEngine.ts index bf66885..5b5e8aa 100644 --- a/lib/botGrowthEngine.ts +++ b/lib/botGrowthEngine.ts @@ -8,7 +8,7 @@ * challenging bot populations that scale in difficulty over time. * * KEY FEATURES: - * - Full Permanence: Bots regenerate resources hourly (5-20% by type) + * - Full Permanence: Bots regenerate resources hourly (2-20% by type — Boss 2%; FID-20260915-007 corrected) * - Unit Building: Bots build BOTH STR and DEF armies that scale with age/tier * - Growth Patterns: 70% grow, 20% stable, 10% decrease for dynamic economy * - Movement System: Raiders roam, Ghosts teleport, others stationary @@ -73,7 +73,7 @@ const REGENERATION_RATES = { /** * Unit building rates by specialization (units built per hour) */ -const BUILD_RATES = { +export const BUILD_RATES = { Fortress: 0.5, // 1 unit every 2 hours - slow but defensive Raider: 1.0, // 1 unit per hour - fast aggressive builds Hoarder: 0.25, // 1 unit every 4 hours - minimal unit focus @@ -84,7 +84,7 @@ const BUILD_RATES = { /** * Army composition by specialization (STR vs DEF percentages) */ -const ARMY_COMPOSITION = { +export const ARMY_COMPOSITION = { Fortress: { str: 0.3, def: 0.7 }, // 30% STR, 70% DEF - defensive wall Raider: { str: 0.7, def: 0.3 }, // 70% STR, 30% DEF - offensive power Hoarder: { str: 0.5, def: 0.5 }, // 50/50 - minimal but balanced @@ -611,7 +611,7 @@ export async function forceRegeneration(): Promise<{ success: boolean; count: nu * - DEF units: Guard (T1), Sentinel (T2), Bastion (T3) * * 5. FULL PERMANENCE MODEL: - * - Bots regenerate 5-20% resources hourly (never despawn) + * - Bots regenerate 2-20% resources hourly (never despawn; Boss 2% — FID-20260915-007 corrected) * - Growth pattern adds economic variation (70/20/10) * - Movement creates dynamic map presence * - Nest attraction maintains strategic clustering diff --git a/lib/botService.ts b/lib/botService.ts index 6413b71..35d3977 100644 --- a/lib/botService.ts +++ b/lib/botService.ts @@ -6,7 +6,7 @@ * * OVERVIEW: * Manages AI-controlled bot players that mimic real player behavior. - * Full Permanence Model: Bots stay on map permanently, regenerate resources hourly (5-20% by type). + * Full Permanence Model: Bots stay on map permanently, regenerate resources hourly (2-20% by type — Boss 2%; FID-20260915-007 corrected). * Beer Bases despawn when defeated and respawn weekly at random locations. * * EXPANDED BOT TIER SYSTEM (7 Tiers) — values below are CODE truth @@ -916,7 +916,7 @@ export async function createBeerBaseBots(count: number): Promise // - Full Permanence Model: Regular bots never despawn, regenerate resources hourly // - Beer Bases despawn when defeated, respawn weekly (Sunday 4 AM) // - All bots have permanentBase=true for static base locations -// - Resource regeneration rates: 5-20% per hour based on specialization +// - Resource regeneration rates: 2-20% per hour based on specialization (Boss 2%; FID-20260915-007 corrected) // - Reputation system tracks defeats for bonus loot (up to 2x) // - Zone system ensures even distribution across 150×150 map // - Admin panel will control all bot parameters via configuration diff --git a/scripts/ladderTruth.ts b/scripts/ladderTruth.ts index 545b097..f564ce0 100644 --- a/scripts/ladderTruth.ts +++ b/scripts/ladderTruth.ts @@ -21,7 +21,14 @@ import { getBotDefenseForTier, getPlayerLevelBonus, } from '@/lib/botService'; -import { BotSpecialization } from '@/types/game.types'; +import { regenerateBotResources, BUILD_RATES, ARMY_COMPOSITION } from '@/lib/botGrowthEngine'; +import { + UNIT_CONFIGS, + UnitType, + UnitTier, + TIER_UNLOCK_REQUIREMENTS, +} from '@/types/game.types'; +import { BotSpecialization, type Player } from '@/types/game.types'; export interface LadderMismatch { site: string; @@ -41,6 +48,13 @@ export interface LadderRow { const SOURCE_PATH = join(process.cwd(), 'lib', 'botService.ts'); const SRC = readFileSync(SOURCE_PATH, 'utf8').replace(/\r\n/g, '\n'); const LINES = SRC.split('\n'); +// FID-20260915-007: the gate now covers the regen-rate table (documented in +// botGrowthEngine + botService) and the unit-cost curve (documented in +// game.types) — every documented game-math table is CI-pinned. +const ENGINE_SRC = readFileSync(join(process.cwd(), 'lib', 'botGrowthEngine.ts'), 'utf8').replace(/\r\n/g, '\n'); +const ENGINE_LINES = ENGINE_SRC.split('\n'); +const TYPES_SRC = readFileSync(join(process.cwd(), 'types', 'game.types.ts'), 'utf8').replace(/\r\n/g, '\n'); +const TYPES_LINES = TYPES_SRC.split('\n'); function parseAll(line: string, re: RegExp, what: string): Array<[string, number]> { const out: Array<[string, number]> = []; @@ -167,4 +181,184 @@ for (const line of LINES) { } export const bracketLadder = makeLadder('player level bracket', 1, bracketCells); -export const ladders: LadderRow[] = [resourceLadder, defenseLadder, bracketLadder]; +// --------------------------------------------------------------------------- +// 4. Regeneration-rate ladder (FID-20260915-007; documented in FOUR sites; +// truth: regenerateBotResources — rate derived as tick(0) ÷ spawner-max, +// same engine-derived philosophy as the property suite, no table import). +// Table rows: " Hoarder: 0.05, // 5% per hour - slow regeneration…" +// Engine header: "Bots regenerate resources hourly (2-20% by type …)" +// botService: header + implementation-notes summaries of the range. +// NOTE: the range claims ("2-20%") pin the table's min/max — this is the +// cell that caught the live "5-20%" falsehood (Boss regenerates at 2%). +// --------------------------------------------------------------------------- +const RATE_KEYS = ['Hoarder', 'Fortress', 'Raider', 'Ghost', 'Balanced', 'Boss'] as const; +/** Engine-derived effective rate per specialization: tick(0) / spawner-max. */ +function derivedRegenRate(specKey: (typeof RATE_KEYS)[number]): number { + const spec = BotSpecialization[specKey]; + const zeroBot = { + botConfig: { specialization: spec, tier: 1 }, + resources: { metal: 0, energy: 0, food: 0 }, + } as unknown as Player; + const firstTick = regenerateBotResources(zeroBot).metal; + const rangeMax = getResourceRange(spec, 1).max; + return firstTick / rangeMax; // exact integers for the shipped table +} + +const regenCells: Array<{ site: string; key: string; documented: number; actual: number }> = []; +for (const line of ENGINE_LINES) { + const tableRow = line.match(/^\s*(Hoarder|Fortress|Raider|Ghost|Balanced|Boss):\s*[\d.]+,\s*\/\/\s*([\d.]+)%\s+per hour/); + if (tableRow) { + const key = tableRow[1] as (typeof RATE_KEYS)[number]; + regenCells.push({ + site: 'REGENERATION_RATES table', + key: `${key} %`, + documented: Number(tableRow[2]), + actual: derivedRegenRate(key) * 100, + }); + } + const range = line.match(/Bots regenerate resources hourly \(([\d.]+)-([\d.]+)% by type/); + if (range) { + const rates = RATE_KEYS.map(derivedRegenRate).map((r) => r * 100); + regenCells.push({ site: 'file header (KEY FEATURES)', key: 'range low %', documented: Number(range[1]), actual: Math.min(...rates) }); + regenCells.push({ site: 'file header (KEY FEATURES)', key: 'range high %', documented: Number(range[2]), actual: Math.max(...rates) }); + } +} +for (const line of LINES) { + const header = line.match(/regenerate resources hourly \(([\d.]+)-([\d.]+)% by type/); + if (header) { + const rates = RATE_KEYS.map(derivedRegenRate).map((r) => r * 100); + regenCells.push({ site: 'botService header', key: 'range low %', documented: Number(header[1]), actual: Math.min(...rates) }); + regenCells.push({ site: 'botService header', key: 'range high %', documented: Number(header[2]), actual: Math.max(...rates) }); + } + const notes = line.match(/Resource regeneration rates: ([\d.]+)-([\d.]+)% per hour/); + if (notes) { + const rates = RATE_KEYS.map(derivedRegenRate).map((r) => r * 100); + regenCells.push({ site: 'botService implementation notes', key: 'range low %', documented: Number(notes[1]), actual: Math.min(...rates) }); + regenCells.push({ site: 'botService implementation notes', key: 'range high %', documented: Number(notes[2]), actual: Math.max(...rates) }); + } +} +export const regenRateLadder = makeLadder('regeneration rate', 4, regenCells); + +// --------------------------------------------------------------------------- +// 5. Unit-cost ladder (FID-20260915-007; documented in TWO comment blocks in +// types/game.types.ts; truth: UNIT_CONFIGS / UnitType / TIER_UNLOCK_REQUIREMENTS). +// Header block: "all 65 units", "40-unit core roster", the 5-row +// BALANCING PHILOSOPHY table (level + RP per tier), slot ladder "1/3/7/15/30". +// Derivation block: "40-unit T1–T5 core", slot ladder repeated. +// Blueprint field parity of the core roster is ALREADY pinned by +// __tests__/unit/catalog-unification.test.ts — the gate does not duplicate it. +// --------------------------------------------------------------------------- +const valueToMember = new Map(Object.entries(UnitType).map(([member, value]) => [value as string, member])); +const coreConfigs = Object.values(UNIT_CONFIGS).filter((c) => /^T[1-5]_/.test(valueToMember.get(c.type) ?? '')); + +const unitCostCells: Array<{ site: string; key: string; documented: number; actual: number }> = []; +// Site cursor: the two doc blocks sit back-to-back in the same comment wall; +// cells are attributed to the block they were parsed from (site-count guard +// integrity — a block losing all its rows must fail the count, not hide in +// the other site's bucket). +let unitCostSite = 'UNIT_CONFIGS header (balancing philosophy)'; +for (const line of TYPES_LINES) { + if (/BALANCING PHILOSOPHY/.test(line)) unitCostSite = 'UNIT_CONFIGS header (balancing philosophy)'; + if (/UNIT_CONFIGS is DERIVED from UNIT_BLUEPRINTS/.test(line)) unitCostSite = 'UNIT_CONFIGS derivation note'; + const total = line.match(/configurations for all (\d+) units/); + if (total) { + unitCostCells.push({ site: unitCostSite, key: 'total configs', documented: Number(total[1]), actual: Object.values(UNIT_CONFIGS).length }); + } + const core = line.match(/the (\d+)-unit core roster/); + if (core) { + unitCostCells.push({ site: unitCostSite, key: 'core roster', documented: Number(core[1]), actual: coreConfigs.length }); + } + // Tier-1's row reads "0 RP" (no "to unlock tier" tail) — both row shapes parse. + const philosophy = line.match(/-\s*Tier (\d):\s*.*Level (\d+)\+,\s*(\d+) RP(?: to unlock tier)?/); + if (philosophy) { + const tier = Number(philosophy[1]) as UnitTier; + const req = TIER_UNLOCK_REQUIREMENTS[tier]; + unitCostCells.push({ site: unitCostSite, key: `T${tier} level`, documented: Number(philosophy[2]), actual: req.level }); + unitCostCells.push({ site: unitCostSite, key: `T${tier} rp`, documented: Number(philosophy[3]), actual: req.rp }); + } + const slots = line.match(/\(?(?:core roster:\s*)?([\d]+\/[\d/]+)\)?;?/); + if (slots && /slot|1\/3/.test(line)) { + for (const [i, doc] of slots[1].split('/').entries()) { + const tier = (i + 1) as UnitTier; + const inTier = coreConfigs.filter((c) => c.tier === tier); + const slotValues = [...new Set(inTier.map((c) => c.slotCost))]; + // Actual = the tier's slot cost iff uniform; NaN (no units) or mixed + // values mismatch the documented ladder. + const actual = slotValues.length === 1 ? slotValues[0] : NaN; + unitCostCells.push({ site: unitCostSite, key: `T${tier} slotCost`, documented: Number(doc), actual }); + } + } + const deriveCore = line.match(/the (\d+)-unit T1.T5 core/); + if (deriveCore) { + unitCostCells.push({ site: unitCostSite, key: 'core roster (derivation)', documented: Number(deriveCore[1]), actual: coreConfigs.length }); + } +} +export const unitCostLadder = makeLadder('unit cost curve', 2, unitCostCells); + +// --------------------------------------------------------------------------- +// 6. Build-rate ladder (FID-20260915-007; documented in TWO sites in +// botGrowthEngine; truth: BUILD_RATES — units/hour, doc quotes hours/unit). +// Header bullets: "* Fortress: 1 unit/2 hours (slow, defensive focus)" +// Table comments: "Fortress: 0.5, // 1 unit every 2 hours - …" +// PRECISION: Ghost's rate 0.67 is a rounded reciprocal of 1/1.5 (exactly +// 1.4925 h/unit), so documented intervals pin at the docs' one-decimal +// precision: actual = round(10 / rate) / 10. A real drift (0.67 → 0.75) +// still fails; a last-digit re-round (0.67 → 0.667) does not. +// --------------------------------------------------------------------------- +type BuildKey = 'Fortress' | 'Raider' | 'Hoarder' | 'Ghost' | 'Balanced'; +const buildCells: Array<{ site: string; key: string; documented: number; actual: number }> = []; +const buildActual = (key: BuildKey): number => Math.round(10 / BUILD_RATES[key]) / 10; +for (const line of ENGINE_LINES) { + const header = line.match(/\*\s+(Fortress|Raider|Hoarder|Ghost|Balanced):\s+1 unit\/(?:([\d.]+) hours?|hour)/); + if (header) { + const key = header[1] as BuildKey; + buildCells.push({ site: 'file header (unit building)', key: `${key} hours/unit`, documented: header[2] ? Number(header[2]) : 1, actual: buildActual(key) }); + } + const tableRow = line.match(/^\s*(Fortress|Raider|Hoarder|Ghost|Balanced):\s*[\d.]+,\s*\/\/\s*1 unit (?:every ([\d.]+) hours?|per hour)/); + if (tableRow) { + const key = tableRow[1] as BuildKey; + buildCells.push({ site: 'BUILD_RATES table', key: `${key} hours/unit`, documented: tableRow[2] ? Number(tableRow[2]) : 1, actual: buildActual(key) }); + } +} +export const buildRateLadder = makeLadder('build rate', 2, buildCells); + +// --------------------------------------------------------------------------- +// 7. Army-composition ladder (FID-20260915-007; documented in TWO sites in +// botGrowthEngine; truth: ARMY_COMPOSITION — str/def fractions). +// Header bullets: "* Fortress: 70% DEF, 30% STR (defensive wall)" +// Table comments: "// 30% STR, 70% DEF - defensive wall" +// Labels parsed (STR/DEF order varies by row); both cells per row pinned. +// --------------------------------------------------------------------------- +type CompKey = 'Fortress' | 'Raider' | 'Hoarder' | 'Ghost' | 'Balanced'; +const compCells: Array<{ site: string; key: string; documented: number; actual: number }> = []; +for (const line of ENGINE_LINES) { + const header = line.match(/\*\s+(Fortress|Raider|Hoarder|Ghost|Balanced):\s+(\d+)%\s+(STR|DEF),\s*(\d+)%\s+(STR|DEF)/); + if (header) { + const key = header[1] as CompKey; + const parts: Array<[string, number]> = [[header[3], Number(header[2])], [header[5], Number(header[4])]]; + for (const [stat, pct] of parts) { + compCells.push({ site: 'file header (army composition)', key: `${key} ${stat}%`, documented: pct, actual: ARMY_COMPOSITION[key][stat.toLowerCase() as 'str' | 'def'] * 100 }); + } + } + const tableRow = line.match(/^\s*(Fortress|Raider|Hoarder|Ghost|Balanced):\s*\{[^}]+},\s*\/\/\s*(\d+)%\s+(STR|DEF),\s*(\d+)%\s+(STR|DEF)/); + if (tableRow) { + const key = tableRow[1] as CompKey; + const parts: Array<[string, number]> = [[tableRow[3], Number(tableRow[2])], [tableRow[5], Number(tableRow[4])]]; + for (const [stat, pct] of parts) { + compCells.push({ site: 'ARMY_COMPOSITION table', key: `${key} ${stat}%`, documented: pct, actual: ARMY_COMPOSITION[key][stat.toLowerCase() as 'str' | 'def'] * 100 }); + } + } + // Bare "50/50" rows (no STR/DEF labels): str-first per this file's comment + // convention — and symmetric in the shipped table, so the pin is + // interpretation-invariant today. If a row ever becomes asymmetric it must + // switch to the labeled form (caught by the labeled regex above). + const bareRow = line.match(/^\s*(Fortress|Raider|Hoarder|Ghost|Balanced):\s*\{[^}]+},\s*\/\/\s*(\d+)\/(\d+)\s*-/); + if (bareRow && !tableRow) { + const key = bareRow[1] as CompKey; + compCells.push({ site: 'ARMY_COMPOSITION table', key: `${key} str%`, documented: Number(bareRow[2]), actual: ARMY_COMPOSITION[key].str * 100 }); + compCells.push({ site: 'ARMY_COMPOSITION table', key: `${key} def%`, documented: Number(bareRow[3]), actual: ARMY_COMPOSITION[key].def * 100 }); + } +} +export const armyCompositionLadder = makeLadder('army composition', 2, compCells); + +export const ladders: LadderRow[] = [resourceLadder, defenseLadder, bracketLadder, regenRateLadder, unitCostLadder, buildRateLadder, armyCompositionLadder]; diff --git a/types/game.types.ts b/types/game.types.ts index f45d804..98427ec 100644 --- a/types/game.types.ts +++ b/types/game.types.ts @@ -1104,26 +1104,30 @@ export interface UnitConfig { * Available unit configurations */ /** - * Complete unit configurations for all 40 units (5 tiers × 8 units) - * - * BALANCING PHILOSOPHY: + * Complete unit configurations for all 65 units: the 40-unit core roster + * (5 tiers × 8 units, blueprint-derived) plus 25 SPEC/PRESTIGE progression + * units (FID-20260915-007 scope correction — previously claimed "all 40", + * undercounting the constant). + * + * BALANCING PHILOSOPHY (core roster): * - Tier 1: Entry-level units (Level 1+, 0 RP) * - Tier 2: Mid-game units (Level 5+, 5 RP to unlock tier) * - Tier 3: Advanced units (Level 10+, 15 RP to unlock tier) * - Tier 4: Elite units (Level 20+, 30 RP to unlock tier) * - Tier 5: Legendary units (Level 30+, 50 RP to unlock tier) - * + * * COST SCALING: * - Metal/Energy costs scale exponentially per tier - * - Higher tiers require more factory slots + * - Higher tiers require more factory slots (core roster: 1/3/7/15/30) * - STR/DEF values scale progressively within each tier */ /** * FID-20260909-033: UNIT_CONFIGS is DERIVED from UNIT_BLUEPRINTS - * (types/units.types.ts) — the canonical roster. Names, stats, and costs here - * are generated from the blueprint table; do not hand-edit values. The - * slotCost per tier follows the exponential slot system (1/3/7/15/30); - * level/rp requirements mirror TIER_UNLOCK_REQUIREMENTS. + * (types/units.types.ts) — the canonical roster for the 40-unit T1–T5 core; + * SPEC/PRESTIGE units have no blueprint counterpart by design. Names, stats, + * and costs of the core are generated from the blueprint table; do not + * hand-edit values. The core slotCost per tier follows the exponential slot + * system (1/3/7/15/30); level/rp requirements mirror TIER_UNLOCK_REQUIREMENTS. */ export const UNIT_CONFIGS: Record = { [UnitType.T1_Infantry]: {