diff --git a/README.md b/README.md index 786c7bd..6fd32e4 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ respawns, the bar — lives in **`data/combat.json`**: (the roster sets XP-per-threat: the base craft 10, the bomber 25 — `data/combat.json → enemy.xp`), banked against a curve that's easy at first and hard later, never a grind (`data/progression.json` — - `base × n^power`, shipped 100 × n², capped at level 80; ten base kills - buy level 1, the whole run to the cap is 17.4M XP). A crossed level + `base × n^power`, shipped 50 × n², capped at level 80; five base kills + buy level 1, the whole run to the cap is 8.7M XP). A crossed level fires a `levelup` EVENT (one per level — the armor bump (`progression.reward`: +3 hull / +2 shields, full heal), the burst + voice, the feed's line all ride it; the skill layer — active and diff --git a/data/combat.json b/data/combat.json index 3472a88..a269431 100644 --- a/data/combat.json +++ b/data/combat.json @@ -45,7 +45,7 @@ }, "enemy": { - "_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). xp = the EXP a kill of one is worth (the XP bank's currency — js/combat/XpManager.js; the curve is data/progression.json, and the base value is 10, so ten kills of the base craft = level 1). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s; boltLength/boltWidth px + muzzle px are the lance's VISUALS (the bolt's length × thickness and the muzzle flash's size — js/combat/Bullet.js + Fx.muzzle). ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.", + "_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). xp = the EXP a kill of one is worth (the XP bank's currency — js/combat/XpManager.js; the curve is data/progression.json, and the base value is 10, so five kills of the base craft = level 1). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s; boltLength/boltWidth px + muzzle px are the lance's VISUALS (the bolt's length × thickness and the muzzle flash's size — js/combat/Bullet.js + Fx.muzzle). ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.", "texture": "assets/images/ships-enemy.png", "frameWidth": 128, "frameHeight": 128, diff --git a/data/progression.json b/data/progression.json index 9389b2b..0ce600d 100644 --- a/data/progression.json +++ b/data/progression.json @@ -1,7 +1,7 @@ { - "_comment": "COMBAT PROGRESSION — the combat level + experience bank (js/combat/XpManager.js, wired into js/combat/CombatSystem.js's kill bookkeeping). maxLevel = the level CAP (the player starts at level 0 — the barometer: level 1 costs 10 base enemies). The curve: xpForLevel(n) = base × n^power, where n = the level being ADVANCED TO (1..maxLevel) — a quadratic: level 1 = 100 XP (10 × the raider's 10 XP, data/combat.json → enemy.xp), and each level's cost is n² — the relative step SHRINKS (4×, 2.25×, 1.78× … 1.03×), so early levels fly by and the late ones never become a wall (the 'easier than WoW' pitch: with an honest roster, kills-per-level grow LINEARLY, 10n). power = the GRIND INTENSITY knob (lower = gentler back half — 1.8; higher = steeper — 2.2); base scales the WHOLE curve (lower base = faster early levels). Enemy XP is PER-TYPE (each combat.enemyTypes entry's own `xp` — the rule of thumb is (hull+shields)/5, rounded): the curve is fixed in XP, the roster sets XP-per-threat, so pacing tunes two independent dials. CAP BEHAVIOR: the level holds at maxLevel and the XP is BANKED (xp keeps counting — harmless; the post-cap currency will be the skill layer, not XP). reward = the per-level ARMOR BUMP the level-up grants (hullPerLevel/shieldPerLevel added to combat.playerShip's max armor; fullHealOnLevelUp refills both) — the placeholder toughness until the skill layer lands (a passive can later override or stack these).", + "_comment": "COMBAT PROGRESSION — the combat level + experience bank (js/combat/XpManager.js, wired into js/combat/CombatSystem.js's kill bookkeeping). maxLevel = the level CAP (the player starts at level 0 — the barometer: level 1 costs 5 base enemies). The curve: xpForLevel(n) = base × n^power, where n = the level being ADVANCED TO (1..maxLevel) — a quadratic: level 1 = 50 XP (5 × the raider's 10 XP, data/combat.json → enemy.xp), and each level's cost is n² — the relative step SHRINKS (4×, 2.25×, 1.78× … 1.03×), so early levels fly by and the late ones never become a wall (the 'easier than WoW' pitch: with an honest roster, kills-per-level grow LINEARLY, 5n). power = the GRIND INTENSITY knob (lower = gentler back half — 1.8; higher = steeper — 2.2); base scales the WHOLE curve (lower base = faster early levels). Enemy XP is PER-TYPE (each combat.enemyTypes entry's own `xp` — the rule of thumb is (hull+shields)/5, rounded): the curve is fixed in XP, the roster sets XP-per-threat, so pacing tunes two independent dials. CAP BEHAVIOR: the level holds at maxLevel and the XP is BANKED (xp keeps counting — harmless; the post-cap currency will be the skill layer, not XP). reward = the per-level ARMOR BUMP the level-up grants (hullPerLevel/shieldPerLevel added to combat.playerShip's max armor; fullHealOnLevelUp refills both) — the placeholder toughness until the skill layer lands (a passive can later override or stack these).", "maxLevel": 80, - "base": 100, + "base": 50, "power": 2, "reward": { "hullPerLevel": 3, diff --git a/dev/xp.test.mjs b/dev/xp.test.mjs index d4fe448..5a47172 100644 --- a/dev/xp.test.mjs +++ b/dev/xp.test.mjs @@ -55,19 +55,19 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; const m = new XpManager(); const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10; - // The curve (data/progression.json — base × n^power, shipped: 100 × n²): - check('curve: level 1 = 100 XP (the data, not a private copy)', m.xpForLevel(1) === 100); - check('curve: level 1 = ten base kills (the barometer)', m.xpForLevel(1) === 10 * baseXp); - check('curve: level 2 = 400 XP', m.xpForLevel(2) === 400); - check('curve: level 3 = 900 XP', m.xpForLevel(3) === 900); - check('curve: level 80 = 640,000 XP', m.xpForLevel(80) === 640000); + // The curve (data/progression.json — base × n^power, shipped: 50 × n²): + check('curve: level 1 = 50 XP (the data, not a private copy)', m.xpForLevel(1) === 50); + check('curve: level 1 = five base kills (the barometer)', m.xpForLevel(1) === 5 * baseXp); + check('curve: level 2 = 200 XP', m.xpForLevel(2) === 200); + check('curve: level 3 = 450 XP', m.xpForLevel(3) === 450); + check('curve: level 80 = 320,000 XP', m.xpForLevel(80) === 320000); check('curve: quadratic (n²) — level n = n² × level 1', m.xpForLevel(5) === 25 * m.xpForLevel(1)); check('curve: the relative step shrinks (2→3 is a smaller × than 1→2)', m.xpForLevel(3) / m.xpForLevel(2) < m.xpForLevel(2) / m.xpForLevel(1)); check('curve: the cap is the level (80), not the formula', m.maxLevel === 80); check('curve: xpForLevel(<1) = 0', m.xpForLevel(0) === 0 && m.xpForLevel(-4) === 0); check('curve: fresh ledger (level 0, 0 xp, not max)', - m.level === 0 && m.xp === 0 && m.isMax === false && m.xpForNext() === 100); + m.level === 0 && m.xp === 0 && m.isMax === false && m.xpForNext() === 50); } // ------------------------------------------------------------- the grants @@ -76,12 +76,12 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; const m = new XpManager(); const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10; - // The barometer, as a player would see it: ten kills of the base craft. + // The barometer, as a player would see it: five kills of the base craft. let levelUps = 0; const sub = m.on('levelup', () => levelUps++); - for (let i = 0; i < 10; i++) m.grant(baseXp, 'kill'); - check('grant: ten base kills → level 1 (xp 0, the bank is spent)', - m.level === 1 && m.xp === 0 && levelUps === 1 && m.xpForNext() === 400); + for (let i = 0; i < 5; i++) m.grant(baseXp, 'kill'); + check('grant: five base kills → level 1 (xp 0, the bank is spent)', + m.level === 1 && m.xp === 0 && levelUps === 1 && m.xpForNext() === 200); sub(); m.grant(999); check('grant: the unsubscribe stops the listener (no more levelup events)', @@ -89,15 +89,15 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; // A BIG grant crosses several levels — one 'levelup' event EACH (an // "unlocks exactly at level N" rule can't miss its level). 10,000 buys - // 100+400+900+1600+2500+3600 = 9,100 → level 6, 900 banked toward 7. + // 50+200+450+800+1250+1800+2450 = 7,000 → level 7, 3,000 banked toward 8. const m2 = new XpManager(); const crossed = []; m2.on('levelup', (ev) => crossed.push(ev.to)); - const r = m2.grant(10000, 'dev'); // → level 6 - check("grant: a big grant crosses to level 6 (the curve's math)", - r.level === 6 && r.levelsGained === 6 && r.leveledUp === true && m2.xp === 900); - check('grant: one levelup event PER level crossed (1,2,3,4,5,6)', - JSON.stringify(crossed) === JSON.stringify([1, 2, 3, 4, 5, 6])); + const r = m2.grant(10000, 'dev'); // → level 7 + check("grant: a big grant crosses to level 7 (the curve's math)", + r.level === 7 && r.levelsGained === 7 && r.leveledUp === true && m2.xp === 3000); + check('grant: one levelup event PER level crossed (1,2,3,4,5,6,7)', + JSON.stringify(crossed) === JSON.stringify([1, 2, 3, 4, 5, 6, 7])); // The source tag rides the events (the ledger's audit trail). const m3 = new XpManager(); @@ -141,9 +141,9 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; m.on('levelup', (ev) => levelEvents.push(ev.to)); // A grant bigger than the WHOLE curve (the sum of levels 1..80 = - // 17,388,000): the level caps, the overflow is BANKED (harmless — the + // 8,694,000): the level caps, the overflow is BANKED (harmless — the // post-cap currency will be skills, not XP). - const totalToCap = (100 * (max * (max + 1) * (2 * max + 1))) / 6; // Σ n², base 100 + const totalToCap = (50 * (max * (max + 1) * (2 * max + 1))) / 6; // Σ n², base 50 const huge = totalToCap + 12345; const r = m.grant(huge, 'dev'); check('cap: the level holds at the cap (80)', r.level === max && m.isMax === true); @@ -177,14 +177,14 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; { const m = new XpManager(); - m.grant(550, 'kill'); // 550 = 100 + 400 + 50 → level 2, 50 banked - check('state: level 2 with 50 banked toward level 3', m.level === 2 && m.xp === 50); + m.grant(550, 'kill'); // 550 = 50 + 200 + 300 → level 2, 300 banked + check('state: level 2 with 300 banked toward level 3', m.level === 2 && m.xp === 300); const json = m.toJSON(); check('save: the record is {level, xp} — the curve stays in data', - json.level === 2 && json.xp === 50 && Object.keys(json).length === 2); + json.level === 2 && json.xp === 300 && Object.keys(json).length === 2); const m2 = XpManager.fromJSON(json); - check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 50); + check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 300); // A restore clamps junk (a hand-edited record can't break the run). const m3 = XpManager.fromJSON({ level: 999, xp: -50 }); @@ -199,14 +199,14 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; // stats(): the bar's snapshot (progress 0..1, the next level's cost). const m5 = new XpManager(); - m5.grant(50, 'kill'); + m5.grant(25, 'kill'); const s = m5.stats(); - check('stats: level 0, 50/100 toward level 1', - s.level === 0 && s.xp === 50 && s.xpForNext === 100 && approx(s.progress, 0.5) && s.isMax === false); + check('stats: level 0, 25/50 toward level 1', + s.level === 0 && s.xp === 25 && s.xpForNext === 50 && approx(s.progress, 0.5) && s.isMax === false); m5.grant(60, 'kill'); const s2 = m5.stats(); - check('stats: after crossing, 10/400 toward level 2', - s2.level === 1 && s2.xp === 10 && s2.xpForNext === 400 && approx(s2.progress, 0.025)); + check('stats: after crossing, 35/200 toward level 2', + s2.level === 1 && s2.xp === 35 && s2.xpForNext === 200 && approx(s2.progress, 0.175)); } // --------------------------------------------------- the system's wiring @@ -312,15 +312,15 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; check("system: a kill grants the raider's xp (10, level still 0)", combat.xp.xp === 10 && combat.xp.level === 0 && levels.length === 0); - // Nine more base kills → the level lands: the armor bump + the beat. - for (let i = 0; i < 9; i++) { + // Four more base kills → the level lands: the armor bump + the beat. + for (let i = 0; i < 4; i++) { const e = combat.spawnEnemy(200 + i * 40, 0, 10 + i); e.damage(999); combat.killEnemy(e, 2000 + i); } const hullPerLevel = Number(config.get('progression.reward.hullPerLevel', 3)) || 0; const shieldPerLevel = Number(config.get('progression.reward.shieldPerLevel', 2)) || 0; - check('system: ten base kills → level 1 (the barometer, through the system)', + check('system: five base kills → level 1 (the barometer, through the system)', combat.xp.level === 1 && levels.length === 1 && levels[0] === 1); check("system: the level's armor bump stacks on the base (max armor up)", combat.maxHull === 100 + hullPerLevel && combat.maxShields === 40 + shieldPerLevel); @@ -332,7 +332,7 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; // The bomber's kill banks ITS value (the roster sets XP-per-threat). bomber.damage(999); combat.killEnemy(bomber, 3000); - check("system: the bomber's kill banks 25 (toward level 2's 400)", + check("system: the bomber's kill banks 25 (toward level 2's 200)", combat.xp.xp === 25 && combat.xp.level === 1 && levels.length === 1); // A RESTORED ledger: the level's armor applies on build — with no diff --git a/js/combat/XpManager.js b/js/combat/XpManager.js index 0f9c20c..413254a 100644 --- a/js/combat/XpManager.js +++ b/js/combat/XpManager.js @@ -8,8 +8,8 @@ * * xpForLevel(n) = base × n^power (n = the level ADVANCED TO, 1..maxLevel) * - * with the shipped values (maxLevel 80, base 100, power 2) that makes - * level 1 cost 100 XP — ten kills of the base hostile (its 10 XP, + * with the shipped values (maxLevel 80, base 50, power 2) that makes + * level 1 cost 50 XP — five kills of the base hostile (its 10 XP, * data/combat.json → enemy.xp) — the barometer. The quadratic means each * level's cost is n²: the relative step SHRINKS as you climb (4×, 2.25×, * 1.78× … 1.03×), so early levels fly by and the late ones never become @@ -45,7 +45,7 @@ import { config } from '../config/Config.js'; export class XpManager { constructor() { this.maxLevel = Math.max(1, Math.floor(Number(config.get('progression.maxLevel', 80)) || 80)); - this.base = Math.max(0, Number(config.get('progression.base', 100)) || 0); + this.base = Math.max(0, Number(config.get('progression.base', 50)) || 0); this.power = Math.max(0.05, Number(config.get('progression.power', 2)) || 2); this.level = 0; this.xp = 0; // progress toward the next level (BANKED at the cap)