diff --git a/data/combat.json b/data/combat.json index 626432a..e53839a 100644 --- a/data/combat.json +++ b/data/combat.json @@ -243,7 +243,7 @@ "color": "#ff2d6f" } }, - "$factions_note": "FRAMES 30–37 = THE APEX, frames 40–47 = THE MINDWEAVE — the two FACTION rosters (the ?combat=apex / ?combat=mindweave duel arena — combat.factions below names them). BALANCE RULE (the user's numbers, vs the PIRATE raider — 60 armor, 8 bolt damage): BOTH FACTIONS = 2× DAMAGE (16 = 8 × 2, every bolt/seeker/shell) and 3.5× ARMOR (175 hull + 35 shields = 210 = 60 × 3.5). Cooldowns 0.6–3.0 s (pattern character, not power), ramDamage 13 (10 × 1.3). xp = (hull+shields)/5 = 42 (the roster's rule of thumb). DIFFERENTIATION comes from the WEAPON PATTERN (weapon.pattern — the shared dispatch, js/combat/Weapons.js) + the ABILITY LAYER (abilities[].id — js/combat/EnemyAbilities.js: cloaked_ambush / flank_strafe for the Apex's predatory kit, blink_reposition / illusion_decoy / telekinetic_wave for the Mindweave's psychic kit). Each fighter is its own craft; the faction's SHARED skills are the kit's common entries (the Apex all carry the cloak and most the flank; the Mindweave all carry the blink or the phantoms and several the tides) at per-ship numbers.", + "$factions_note": "FRAMES 30–37 = THE APEX, frames 40–47 = THE MINDWEAVE — the two FACTION rosters (the ?combat=apex / ?combat=mindweave duel arena — combat.factions below names them). BALANCE RULE (the user's numbers, vs the PIRATE raider — 60 armor, 8 bolt damage): BOTH FACTIONS = 2× DAMAGE (16 = 8 × 2, every bolt/seeker/shell) and 3.5× ARMOR (175 hull + 35 shields = 210 = 60 × 3.5). Cooldowns 0.6–3.0 s (pattern character, not power), ramDamage 13 (10 × 1.3). xp = (hull+shields)/5 = 42 (the roster's rule of thumb). DIFFERENTIATION comes from the WEAPON PATTERN (weapon.pattern — the shared dispatch, js/combat/Weapons.js) + the ABILITY LAYER (abilities[].id — js/combat/EnemyAbilities.js: cloaked_ambush / flank_strafe for the Apex's predatory kit, blink_reposition / illusion_decoy / telekinetic_wave for the Mindweave's psychic kit). Each fighter is its own craft; the faction's SHARED skills are the kit's common entries (the Apex all carry the cloak and most the flank; the Mindweave all carry the blink or the phantoms and several the tides) at per-ship numbers. LEVEL RAMP: the 2×/3.5× above are the LEVEL-35 numbers — combat.factionScaling scales the damage (1.2× at level 10) and armor (2.0× at level 10) DOWN with the player's level below 35 (ramDamage and xp stay flat).", "apex_raptor": { "$comment": "APEX 1 (frame 30) — RAPTOR, the forward gun: a 5-bolt SWEEP out the nose (heavy forward firepower) on a medium beat; the flank's cut gets it behind the player every 10 s or so so the sweep lands on the back.", "name": "Raptor", @@ -834,7 +834,7 @@ } ] }, - "_zenith_note": "FRAMES 50–57 = THE ZENITH — the third FACTION roster (the ?combat=zenith duel arena). TWO ARCHETYPES, split 4/4: FRAMES 50–53 = THE COMBO SEEKERS — a fast low-damage WEAPON (the gun) paired with the flank_barrage ABILITY (js/combat/EnemyAbilities.js): a salvo of HEAT-SEEKERS out of BOTH WINGS at 90° off the heading, on a SLOW capped turn-rate (missileTurn 0.7–1.3, far below the nose seekers' 2.2) so they curve wide and a committed turn beats them. FRAMES 54–57 = THE RANGED ELEMENTALS — the shock_dart ABILITY: fast darts that detonate near the target and bloom an ELECTRIFIED ZONE (startRadius→maxRadius over growMs, finite lifeMs, fadeMs), ticking damage (tickDamage/tickMs) on anything inside — a growing, dodgeable field, not a one-shot. BALANCE (the same rule as the $factions_note above, vs the PIRATE raider — 60 armor, 8 bolt damage): 2× DAMAGE (16 every bolt/seeker/shell; the shock zones tick 3–5 per beat — a sustained, avoidable pressure, never a spike) and 3.5× ARMOR (175 hull + 35 shields = 210). xp = (hull+shields)/5 = 42. Differentiation comes from PACE AND SHAPE: the combo ships vary the gun (rapid / burst / spray / sweep) and the volley's size and seek-rate; the elemental ships vary the dart count, the zone's size, its bloom time and its tick rate.", + "_zenith_note": "FRAMES 50–57 = THE ZENITH — the third FACTION roster (the ?combat=zenith duel arena). TWO ARCHETYPES, split 4/4: FRAMES 50–53 = THE COMBO SEEKERS — a fast low-damage WEAPON (the gun) paired with the flank_barrage ABILITY (js/combat/EnemyAbilities.js): a salvo of HEAT-SEEKERS out of BOTH WINGS at 90° off the heading, on a SLOW capped turn-rate (missileTurn 0.7–1.3, far below the nose seekers' 2.2) so they curve wide and a committed turn beats them. FRAMES 54–57 = THE RANGED ELEMENTALS — the shock_dart ABILITY: fast darts that detonate near the target and bloom an ELECTRIFIED ZONE (startRadius→maxRadius over growMs, finite lifeMs, fadeMs), ticking damage (tickDamage/tickMs) on anything inside — a growing, dodgeable field, not a one-shot. BALANCE (the same rule as the $factions_note above, vs the PIRATE raider — 60 armor, 8 bolt damage): 2× DAMAGE (16 every bolt/seeker/shell; the shock zones tick 3–5 per beat — a sustained, avoidable pressure, never a spike) and 3.5× ARMOR (175 hull + 35 shields = 210). xp = (hull+shields)/5 = 42. Differentiation comes from PACE AND SHAPE: the combo ships vary the gun (rapid / burst / spray / sweep) and the volley's size and seek-rate; the elemental ships vary the dart count, the zone's size, its bloom time and its tick rate. LEVEL RAMP: same as the Apex/Mindweave note — combat.factionScaling scales the damage (1.2× at level 10) and armor (2.0× at level 10) with the player's level up to these level-35 numbers; ramDamage and xp stay flat.", "zenith_wingblade": { "$comment": "ZENITH 1 (frame 50) — WINGBLADE, the gun-wing: a RAPID gun (0.55 s) for constant low-damage pressure, and on its own clock a SIX-SEEKER wing volley (three out of each wing at 90°, a slight fan) that curves wide toward the player — the archetype's base kit at balanced tempo.", "name": "Wingblade", @@ -1155,6 +1155,15 @@ "durationMs": 1000, "range": 720 }, + "factionScaling": { + "_comment": "THE FACTION RAMP (js/combat/EnemyTypes.js → factionScaleForLevel / enemyTypeConfig(typeId, level), applied by CombatSystem.spawnEnemy at the player's combat level): the FACTION rosters' (combat.factions — Apex / Mindweave / Zenith, 24 fighters) per-shot damage and armor scale with the player's level between levelStart and levelEnd (clamped at both ends), measured against the PIRATE raider baseline (the classic 2× DAMAGE / 3.5× ARMOR balance rule — 60 armor, 8 bolt damage). The data/*.json static values ARE the levelEnd numbers (16 damage, 175+35 armor); at lower levels they scale DOWN proportionally (× damageMult(level)/damageMultEnd, × armorMult(level)/armorMultEnd, rounded). Level 10: damage ×0.6 (16→10), armor ×0.5714 (210→120). Level 35 and above: the shipped 2×/3.5×. EVERYTHING ELSE is untouched — cooldowns, the weapon pattern, the ability kit, ramDamage (13 = 10×1.3) and xp (42) stay flat across the whole ramp. Non-faction types (the pirate classics, the neutrals, the transport) never scale.", + "levelStart": 10, + "levelEnd": 35, + "damageMultStart": 1.2, + "damageMultEnd": 2.0, + "armorMultStart": 2.0, + "armorMultEnd": 3.5 + }, "factions": { "_comment": "The FACTION DUEL ARENA (the ?combat=apex / ?combat=mindweave boots — js/scenes/GameScene.js → _combatBootMode, js/combat/DuelPicker.js the in-game picker, CombatSystem.startDuel/spawnDuel/spawnDuelPack the fight's seam). Each faction names its fighters (enemyTypes ids, display order = the picker's rows 1–8); 'style' is the picker's one-line read. Key '0' (or the row) sends the WHOLE pack. These rosters are NOT wired into any quest/galaxy content — the arena is their only home for now.", "apex": { diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 2aa46a2..cc8e363 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -176,6 +176,7 @@ const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/E const { enemyTypeConfig, enemyTypeIds, enemySheetList, neutralTypeConfig, neutralTypeIds, neutralSheetList, + factionScaleForLevel, isFactionType, } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href); const { normalizeWaves, resolveRoster, WaveRunner } = await import( pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href @@ -837,6 +838,51 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d); const unknown = enemyTypeConfig('nope'); check('types: unknown id → the base craft (warned, not thrown)', unknown === base); + // ---- the faction level ramp (combat.factionScaling: 1.2×/2.0× at level 10 + // → 2.0×/3.5× at level 35, vs the pirate raider; the data's numbers ARE the + // level-35 ones, so level 10 = damage ×0.6, armor ×4/7) + const factionIds24 = enemyTypeIds().slice(8, 32); + check('ramp: the 24 faction fighters are the ramp apply gate (pirates/neutrals never scale)', + factionIds24.length === 24 && + factionIds24.every((id) => isFactionType(id)) && + !isFactionType('raider') && !isFactionType('warden') && !isFactionType('gunner') && + !isFactionType(null) && !isFactionType('')); + check('ramp: scale = mult(level)/mult(35) — 1.0 at and past 35, 0.6/4/7 at and below 10', + approx(factionScaleForLevel(35).damage, 1) && approx(factionScaleForLevel(35).armor, 1) && + approx(factionScaleForLevel(99).damage, 1) && approx(factionScaleForLevel(99).armor, 1) && + approx(factionScaleForLevel(10).damage, 0.6) && approx(factionScaleForLevel(10).armor, 4 / 7) && + approx(factionScaleForLevel(0).damage, 0.6) && approx(factionScaleForLevel(0).armor, 4 / 7)); + const raptor10 = enemyTypeConfig('apex_raptor', 10); + const raptor35 = enemyTypeConfig('apex_raptor', 35); + check('ramp: level 10 → damage 16×0.6 = 10, armor 210×4/7 = 120 (hull 100 + shields 20)', + raptor10.weapon.damage === 10 && raptor10.hull === 100 && raptor10.shields === 20); + check('ramp: level 35 → the shipped stats (damage 16, hull 175 + shields 35)', + raptor35.weapon.damage === 16 && raptor35.hull === 175 && raptor35.shields === 35); + const raptor20 = enemyTypeConfig('apex_raptor', 20); + check('ramp: level 20 (mid-way) → damage 16×0.76 = 12, armor 210×(2.6/3.5) = 130 + 26', + raptor20.weapon.damage === 12 && raptor20.hull === 130 && raptor20.shields === 26); + check('ramp: clamped past both ends (level 0 = level 10, level 99 = level 35)', + JSON.stringify(enemyTypeConfig('apex_raptor', 0)) === JSON.stringify(raptor10) && + JSON.stringify(enemyTypeConfig('apex_raptor', 99)) === JSON.stringify(raptor35)); + check('ramp: EVERY faction fighter lands on the same ramp (120 armor @10, 210 @35, damage ×0.6 @10)', + factionIds24.every((id) => { + const lo = enemyTypeConfig(id, 10); + const hi = enemyTypeConfig(id, 35); + return lo.hull + lo.shields === 120 && hi.hull + hi.shields === 210 && + lo.weapon.damage === Math.max(1, Math.round(hi.weapon.damage * 0.6)); + })); + check('ramp: ramDamage (13) and xp (42) stay flat across the whole ramp', + [10, 20, 35].every((lv) => { + const t = enemyTypeConfig('apex_raptor', lv); + return t.ramDamage === 13 && t.xp === 42; + })); + check('ramp: non-faction types are untouched even with a level passed (raider/warden/gunner)', + ['raider', 'warden', 'gunner'].every((id) => + JSON.stringify(enemyTypeConfig(id, 10)) === JSON.stringify(enemyTypeConfig(id)))); + check('ramp: no level passed = the shipped stats (the arena/tests without an XP bank)', + JSON.stringify(enemyTypeConfig('apex_raptor')) === JSON.stringify(enemyTypeConfig('apex_raptor', null)) && + JSON.stringify(enemyTypeConfig('apex_raptor')) === JSON.stringify(raptor35)); + const sheets = enemySheetList(); check('sheets: base first, one entry per source file (bomber shares the sheet)', sheets.length === 1 && sheets[0].src === base.texture && sheets[0].frameWidth === 128 && sheets[0].frameHeight === 128); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index baa7037..5057019 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -388,11 +388,16 @@ export class CombatSystem { /** * One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its - * bearing. + * bearing. FACTION roster fighters (isFactionType) get their combat + * damage/armor at the player's current combat level (the + * combat.factionScaling ramp — the 2×/3.5× pirate ratio scales down to + * 1.2×/2.0× at level 10; js/combat/EnemyTypes.js → + * enemyTypeConfig(type, level)). * @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base */ spawnEnemy(x, y, index, type = null) { - const cfg = enemyTypeConfig(type); + const level = this.xp ? this.xp.level : null; + const cfg = enemyTypeConfig(type, level); const e = new EnemyShip(this.scene, x, y, index, cfg); // The comm rolls' config (data/combat.json → enemyTypes..comms — // the pirate's lines; null for a type without one, then every roll is diff --git a/js/combat/EnemyTypes.js b/js/combat/EnemyTypes.js index b9d5e91..201b8da 100644 --- a/js/combat/EnemyTypes.js +++ b/js/combat/EnemyTypes.js @@ -77,16 +77,72 @@ export function factionIds() { return Object.keys(f).filter((k) => !k.startsWith('_') && isPlain(f[k])); } +/** + * Is a type id one of the FACTION rosters' fighters (listed in any + * combat.factions..types)? The ramp's (factionScaleForLevel) apply + * gate — the pirate classics, neutrals, and the transport never scale. + * + * @param {string} typeId a combat.enemyTypes id + * @returns {boolean} + */ +export function isFactionType(typeId) { + if (typeof typeId !== 'string' || typeId === '') return false; + for (const fid of factionIds()) { + const f = config.section('combat.factions', {})[fid]; + if (Array.isArray(f.types) && f.types.some((t) => isPlain(t) && t.id === typeId)) return true; + } + return false; +} + +/** + * The FACTION RAMP (data/combat.json → combat.factionScaling): the + * per-shot-damage and armor multipliers for a player LEVEL, measured + * against the pirate raider baseline (the classic 2× damage / 3.5× armor + * balance rule). Linear between levelStart (10 — 1.2×/2.0×) and levelEnd + * (35 — 2.0×/3.5×), clamped at both ends. Returned NORMALIZED to the + * shipped static values (÷ the levelEnd multiplier), so a caller simply + * multiplies the data's numbers: at level ≥ levelEnd both are 1.0. + * A missing/malformed section degrades to no scaling (the shipped stats). + * + * @param {number} level the player's combat level + * @returns {{damage: number, armor: number}} the scale ratios (≤ 1) + */ +export function factionScaleForLevel(level) { + const s = config.section('combat.factionScaling', null); + if (!isPlain(s)) return { damage: 1, armor: 1 }; + const l0 = Number(s.levelStart) || 0; + const l1 = Number(s.levelEnd) || 1; + const d0 = Number(s.damageMultStart) || 0; + const d1 = Number(s.damageMultEnd) || 0; + const a0 = Number(s.armorMultStart) || 0; + const a1 = Number(s.armorMultEnd) || 0; + if (!(d1 > 0) || !(a1 > 0) || !(l1 > l0)) return { damage: 1, armor: 1 }; + const lv = Number(level); + const t = !Number.isFinite(lv) ? 1 : Math.min(1, Math.max(0, (lv - l0) / (l1 - l0))); + return { + damage: (d0 + (d1 - d0) * t) / d1, + armor: (a0 + (a1 - a0) * t) / a1, + }; +} + /** * The FULL config for a hostile type: the base (combat.enemy) deep-merged * with the type's overrides (combat.enemyTypes[id]). * + * `level` (optional): the player's combat level — for FACTION roster + * fighters (isFactionType) it applies the combat.factionScaling ramp: + * weapon.damage × factionScaleForLevel(level).damage, hull/shields × .armor + * (rounded; damage floors at 1, shields at 0). The pirates, neutrals, + * transport, and an omitted level all return the shipped stats untouched; + * ramDamage and xp never scale. + * * @param {string|null} [typeId] null/'' = the base craft; a named id = * base + that type's overrides; an unknown id = the base (warned once * per call — a roster typo, not a crash). + * @param {number|null} [level] the player's combat level (the faction ramp) * @returns {object} the config an EnemyShip builds from */ -export function enemyTypeConfig(typeId = null) { +export function enemyTypeConfig(typeId = null, level = null) { const base = config.section('combat.enemy', {}); if (typeId == null || typeId === '') return base; const t = config.section('combat.enemyTypes', {}); @@ -97,7 +153,18 @@ export function enemyTypeConfig(typeId = null) { } return base; } - return deepMerge(base, over); + const c = deepMerge(base, over); + if (!isFactionType(typeId) || level == null) return c; + const s = factionScaleForLevel(level); + if (s.damage === 1 && s.armor === 1) return c; + const weapon = { ...(c.weapon ?? {}) }; + weapon.damage = Math.max(1, Math.round((Number(weapon.damage) || 0) * s.damage)); + return { + ...c, + weapon, + hull: Math.max(1, Math.round((Number(c.hull) || 0) * s.armor)), + shields: Math.max(0, Math.round((Number(c.shields) || 0) * s.armor)), + }; } /** diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 227a51f..8c897c0 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -6480,25 +6480,30 @@ export class GameScene extends Phaser.Scene { // land on the fade's close (_sealEscortDelivery). if (esc.phase === 'delivering') { this._runEscortDelivery(id, route); return; } - // RENDEZVOUS LINK — the ship reaches the convoy's standing point; the convoy - // forms on the wing and the lane goes hot. - if (esc.phase === 'rendezvous' && esc.originSystemId === this.systemRecord.id) { - const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null; - const radius = Math.max(1, Number(config.get('sidequests.escort.visitRadius', 460)) || 460); - if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1]) && Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius) { - esc.phase = 'transit'; - this.sideQuestState.setRoute(id, route); // persist the phase - this._ensureEscortConvoy(id, route); // form on the wing - const t = config.get('sidequests.ui.escortLinkToast', {}); - this.playSfx('construct'); - this.comms?.trigger('escortLink'); // the TRANSPORT CAPTAIN's greeting (data/comms.json — neutral-transport-link) - this.consoleToast( - String(t.text ?? 'CONVOY LINKED — {name} HOLDS YOUR WING THROUGH TO {dest}') - .replace('{name}', esc.name || 'the convoy').replace('{dest}', esc.destSystemName || 'the destination'), - { glyph: t.glyph ?? '➳', glyphColor: t.color ? toCss(t.color) : undefined }, - ); + // RENDEZVOUS — before the link the convoy stands at its RENDEZVOUS point + // only (the faction escort's rendezvous is in ANOTHER system than the + // board's), so nothing convoy-related happens here: no spawn on the wing, + // no lane, no delivery witness. The link lands when the ship reaches the + // standing point; the convoy forms on the wing and the lane goes hot. + if (esc.phase === 'rendezvous') { + if (esc.originSystemId === this.systemRecord.id) { + const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null; + const radius = Math.max(1, Number(config.get('sidequests.escort.visitRadius', 460)) || 460); + if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1]) && Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius) { + esc.phase = 'transit'; + this.sideQuestState.setRoute(id, route); // persist the phase + this._ensureEscortConvoy(id, route); // form on the wing + const t = config.get('sidequests.ui.escortLinkToast', {}); + this.playSfx('construct'); + this.comms?.trigger('escortLink'); // the TRANSPORT CAPTAIN's greeting (data/comms.json — neutral-transport-link) + this.consoleToast( + String(t.text ?? 'CONVOY LINKED — {name} HOLDS YOUR WING THROUGH TO {dest}') + .replace('{name}', esc.name || 'the convoy').replace('{dest}', esc.destSystemName || 'the destination'), + { glyph: t.glyph ?? '➳', glyphColor: t.color ? toCss(t.color) : undefined }, + ); + } } - return; + return; // not linked yet — the convoy waits at the rendezvous } // TRANSIT — the convoy follows (re-forms after a jump), the lane fires its