From 722dbc8415607b94b46b9f530d4a13e978f38d64 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 30 Sep 2026 17:54:03 -0600 Subject: [PATCH] Add Mindweave bullet homing and all-hostile ability targeting - Add `bulletHoming` config section and `stepHomingBullets` pass so Mindweave bolts/shells bend toward the nearest live hostile for a capped duration (turn-rate limited, range-limited, own-wing exempt) - Extend telekinetic wave (`stepTkwaves`) to shove/pull every hostile craft in radius, not just the player; own wing and cloaked craft are excluded - Extend illusion pop rule so rival-faction enemy tracers can pop their own court's phantoms (all-hostile), while own-wing bolts pass through and unclaimed courts remain player-only - Tag illusions and tkwaves with `factionId` to support the all-hostile targeting rules - Switch apex_mauler weapon from single "lance" to 3-bolt "burst" pattern (16 dmg each, fan 0.12 rad) per updated design comment - Add `dev/faction-battle-sim.mjs`: headless Node harness that runs the real combat stack with Phaser stubs to fight random faction compositions and print win/loss + power reports --- data/combat.json | 15 +- dev/faction-battle-sim.mjs | 470 ++++++++++++++++++++++++++++++++++++ js/combat/CombatSystem.js | 96 +++++++- js/combat/EnemyAbilities.js | 38 ++- 4 files changed, 605 insertions(+), 14 deletions(-) create mode 100644 dev/faction-battle-sim.mjs diff --git a/data/combat.json b/data/combat.json index 3f85fe7..626432a 100644 --- a/data/combat.json +++ b/data/combat.json @@ -277,7 +277,7 @@ ] }, "apex_mauler": { - "$comment": "APEX 2 (frame 31) — MAULER, the heavy hitter: one BIG slow bolt (damage 16, the 2× bolt, wide visual) — slow, armored, deliberate; comes out of CLOAK to swing it (the shared ambush).", + "$comment": "APEX 2 (frame 31) — MAULER, the heavy hitter: a BIG slow 3-bolt BURST (16 damage each, wide visual) — slow, armored, deliberate; comes out of CLOAK to swing it (the shared ambush).", "name": "Mauler", "frame": 31, "speed": 120, @@ -288,7 +288,7 @@ "ramDamage": 13, "ramDamageSelf": 29, "weapon": { - "pattern": "lance", + "pattern": "burst", "range": 560, "cooldown": 1.6, "bulletSpeed": 240, @@ -298,6 +298,8 @@ "size": 22, "boltLength": 70, "boltWidth": 16, + "bolts": 3, + "burstFan": 0.12, "color": "#ff2d4d" }, "abilities": [ @@ -473,7 +475,7 @@ ] }, "apex_alpha": { - "$comment": "APEX 8 (frame 37) — ALPHA, the pack's top predator (the elite): a COMBO of heavy lance (16) + two seekers every 2.4 s, the FULL kit — the longest cloak, the double-cloak's nap, and the flank's cut. 2× the pirate's damage, 3.5× its armor — the whole predatory doctrine in everything else.", + "$comment": "APEX 8 (frame 37) — ALPHA, the pack's top predator (the elite): a COMBO of heavy lance (16) + three seekers every 2.4 s, the FULL kit — the longest cloak, the double-cloak's nap, and the flank's cut. 2× the pirate's damage, 3.5× its armor — the whole predatory doctrine in everything else.", "name": "Alpha", "frame": 37, "speed": 130, @@ -1146,6 +1148,13 @@ ] } }, + "bulletHoming": { + "_comment": "THE MINDWEAVE'S WILL (js/combat/CombatSystem.js → stepHomingBullets): bolts and shells fired by ships of these factions bend toward the NEAREST LIVE HOSTILE within range for the first durationMs of flight — the weaver's threads find their mark without negating a committed dodge (a capped turn-rate: subtle, yet effective). Hostiles = the rival faction's wing (never the shooter's own) and, for bolts hostile to the player, the player itself. War-zone factions stay neutral-to-the-player, so in an observed duel the will only curves fire at the rival wing.", + "factions": ["mindweave"], + "turnRate": 0.3, + "durationMs": 1000, + "range": 720 + }, "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/faction-battle-sim.mjs b/dev/faction-battle-sim.mjs new file mode 100644 index 0000000..10adf34 --- /dev/null +++ b/dev/faction-battle-sim.mjs @@ -0,0 +1,470 @@ +/** + * FACTION BATTLE SIM (diagnostic — throwaway, no game-code changes): + * fights random compositions of two FACTIONS against each other with the + * REAL combat stack (CombatSystem / EnemyShip / Weapons / EnemyAbilities, + * all numbers from data/combat.json) running headless in Node, and prints + * a win/loss + power report (factions and ships that read over/under). + * + * The scenario mirrors the in-game WAR-ZONE encounter + * (GameScene._spawnFactionEncounter): both factions spawn off-screen on + * opposite bearings, NEUTRAL to the player (the player observes — it is + * untargetable, rams disarmed, bolts fizzle past it), one-shots (no + * respawn). Every faction craft ALWAYS fights the rival faction + * (CombatSystem.pickEnemyTarget's rival rule); the fight ends when one + * faction is wiped out (or on the time cap — judged on armor remaining). + * + * The Phaser arcade world normally integrates body.velocity → position + * (world pre-step, before the scene's update); headless we do the same + * integration by hand each frame, in the same order. + * + * node dev/faction-battle-sim.mjs [BATTLES_PER_MATCHUP=40] [SEED=1] + */ +process.env.NODE_ENV = 'dev'; + +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href; + +// ------------------------------------------------------ the Phaser stub +// (the same minimal stand-in dev/combat.test.mjs runs the real combat +// modules against — sprites, bodies, the scene's display seams.) +const TAU = Math.PI * 2; +const wrap = (a) => { + const min = -Math.PI; + const range = Math.PI * 2; + return (((a - min) % range) + range) % range + min; +}; + +class V2 { + constructor(x = 0, y = 0) { this.x = x; this.y = y; } + set(x, y) { this.x = x; this.y = y; return this; } + length() { return Math.hypot(this.x, this.y); } + scale(s) { this.x *= s; this.y *= s; return this; } +} + +class Sprite { + constructor(scene, x, y, key, frame) { + this.scene = scene; this.x = x; this.y = y; this.key = key; this.frame = frame; + this.rotation = 0; this.scale = 1; this.alpha = 1; this.visible = true; + this.active = true; this.body = null; + } + setScale(s) { this.scale = s; return this; } + setTint() { return this; } + setBlendMode() { return this; } + setDepth() { return this; } + setOrigin() { return this; } + setAlpha(a) { this.alpha = a; return this; } + setVisible(v) { this.visible = v; return this; } + destroy() { this.active = false; this.destroyed = true; } +} + +function makeBody() { + return { + velocity: new V2(), + acceleration: new V2(), + enable: true, + setCircle() { return this; }, + setAllowGravity() { return this; }, + setVelocity(x, y) { this.velocity.set(x, y); return this; }, + }; +} + +const PhaserStub = { + Physics: { Arcade: { Sprite } }, + Math: { + Angle: { + Wrap: wrap, + RotateTo: (cur, tgt, step) => (Math.abs(wrap(tgt - cur)) <= step ? tgt : cur + Math.sign(wrap(tgt - cur)) * step), + }, + }, + Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } }, + BlendModes: { ADD: 'ADD' }, +}; +globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this + +function makeImg(x = 0, y = 0, key = '__dot') { + return { + x, y, key, scale: 1, alpha: 1, visible: true, active: true, + setScale(s) { this.scale = s; return this; }, + setAlpha(a) { this.alpha = a; return this; }, + setTint() { return this; }, + setBlendMode() { return this; }, + setDepth() { return this; }, + setAngle() { return this; }, + setVisible(v) { this.visible = v; return this; }, + destroy() { this.active = false; this.destroyed = true; }, + }; +} + +function makeGraphics() { + return { + visible: true, + clear() { return this; }, + lineStyle() { return this; }, + fillStyle() { return this; }, + lineBetween() { return this; }, + fillCircle() { return this; }, + fillTriangle() { return this; }, + setScrollFactor() { return this; }, + setDepth() { return this; }, + setVisible(v) { this.visible = v; return this; }, + destroy() { this.destroyed = true; }, + }; +} + +function makeTextObj() { + return { + text: '', alpha: 0, visible: true, + setText(t) { this.text = String(t); return this; }, + setAlpha(a) { this.alpha = a; return this; }, + setVisible(v) { this.visible = v; return this; }, + setOrigin() { return this; }, + setScrollFactor() { return this; }, + setDepth() { return this; }, + destroy() { this.destroyed = true; }, + }; +} + +function makeRectObj(x = 0, y = 0, w = 0, h = 0, alpha = 0) { + return { + x, y, width: w, height: h, alpha, visible: true, + setAlpha(a) { this.alpha = a; return this; }, + setVisible(v) { this.visible = v; return this; }, + setOrigin() { return this; }, + setScrollFactor() { return this; }, + setDepth() { return this; }, + destroy() { this.destroyed = true; }, + }; +} + +function makeScene({ withSheet = true } = {}) { + return { + textures: { exists: () => withSheet }, + make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) }, + add: { + existing: (o) => o, + image: (x, y, key) => makeImg(x, y, key), + graphics: () => makeGraphics(), + text: (x, y, s) => makeTextObj(), + rectangle: (x, y, w, h, c, a) => makeRectObj(x, y, w, h, a ?? 0), + }, + physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } }, + scale: { width: 1280, height: 720 }, + time: { now: 0 }, + }; +} + +// ------------------------------------------------------ the real modules +const { config } = await import(file('config/Config.js')); +const fs = await import('node:fs'); +const dataDir = join(__dirname, '../data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +const { CombatSystem } = await import(file('combat/CombatSystem.js')); +const { factionTypeIds } = await import(file('combat/EnemyTypes.js')); + +// ------------------------------------------------------ reproducible rng +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const pick = (rng, arr) => arr[Math.floor(rng() * arr.length) | 0]; +const sampleN = (rng, arr, n) => { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a.slice(0, n); +}; + +// ------------------------------------------------------------------ one +// battle: two factions, random (equal-size) compositions, neutral player. +const TIME_CAP_MS = 90_000; // a fight this long is a stalemate +const FRAME = 16; // ms — the tests' step +const TRACE = !!process.env.TRACE; + +function runBattle(A, B, size, rng) { + const scene = makeScene({ withSheet: true }); // no camera → the on-screen rule is a no-op + const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0)); + ship.radius = 12; + ship.stop = () => {}; + const combat = new CombatSystem(scene, { ship }); + combat.damagePlayer = () => {}; // the observer is invulnerable (the rule already stands it off) + + // -- attribution seams (per-instance wraps; the game code is untouched) + const origFire = combat.fireEnemy.bind(combat); + combat.fireEnemy = (e, t, time) => { combat._shooter = e; return origFire(e, t, time); }; + const origSpawnB = combat.spawnBullet.bind(combat); + combat.spawnBullet = (x, y, a, wpn, owner) => { + const b = origSpawnB(x, y, a, wpn, owner); + if (owner === 'enemy' && combat._shooter) { + b.shooter = combat._shooter; + const rec = combat._shooter._rec; + if (rec) { rec.boltsFired += 1; rec.dmgFired += Number(b.damage) || 0; } + } + return b; + }; + const origHS = combat.hitsSwept.bind(combat); + combat.hitsSwept = (b, other, r, dt) => { + const hit = origHS(b, other, r, dt); + if (hit && other && typeof other.factionId === 'string') other._lastHit = b; // the killing shot's source + return hit; + }; + + // -- spawn (the GameScene._spawnFactionEncounter mirror, opposite bearings) + const r = Math.max(380, combat._offscreenRadius()) * 1.0; + const baseA = rng() * TAU; + let serial = 0; + const typesA = sampleN(rng, factionTypeIds(A), size); + const typesB = sampleN(rng, factionTypeIds(B), size); + const mk = (fid, types, off) => + types.map((t, i) => { + const n = types.length; + const a = baseA + off + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (rng() - 0.5) * 0.4; + const e = combat.spawnEnemy(ship.x + Math.cos(a) * r, ship.y + Math.sin(a) * r, serial++, t); + e._typeId = t; // the registry id (kill-feed / report key) + e.respawn = false; // one-shots: the fight ends with the sky clear + e.factionId = fid; // the war-zone's side (pickEnemyTarget's rival rule) + e.hostileToPlayer = false; // the player observes — guns/rams stand off + e.ramDamage = 0; + e.ramDamageSelf = 0; + return e; + }); + const shipsA = mk(A, typesA, 0); + const shipsB = mk(B, typesB, Math.PI); + const ships = [...shipsA, ...shipsB]; + for (const e of ships) { + e._rec = { + ship: e, faction: e.factionId, type: e._typeId ?? null, + armor0: e.hull + (e.shields || 0), lastArmor: e.hull + (e.shields || 0), + dmgTaken: 0, diedAt: null, killer: null, boltsFired: 0, dmgFired: 0, + }; + } + + // -- the loop: world pre-step (velocity → position), then the combat step + const dts = FRAME / 1000; + let t = 1000; + let frames = 0; + const aliveOf = (list) => list.filter((s) => !s.dead && !s._peelGone); + while (t < TIME_CAP_MS && aliveOf(shipsA).length > 0 && aliveOf(shipsB).length > 0) { + // pre-step: the arcade world would have moved every body on last frame's velocity + for (const e of ships) { + if (e.dead || !e.body) continue; + e.x += e.body.velocity.x * dts; + e.y += e.body.velocity.y * dts; + } + for (const b of combat.bullets) { + if (b.dead || !b.body) continue; + b.x += b.body.velocity.x * dts; + b.y += b.body.velocity.y * dts; + } + for (const m of combat.missiles || []) { + if (m.dead || !m.body) continue; + m.x += m.body.velocity.x * dts; + m.y += m.body.velocity.y * dts; + } + t += FRAME; + combat.update(t, FRAME); + frames++; + + if (TRACE && frames % 60 === 0) { + const alive = (list) => list.filter((s) => !s.dead); + const la = alive(shipsA); + const lb = alive(shipsB); + let minD = Infinity; + for (const a of la) for (const b of lb) minD = Math.min(minD, Math.hypot(a.x - b.x, a.y - b.y)); + const meanD = (list) => list.length ? list.reduce((acc, s) => acc + Math.hypot(s.x - ship.x, s.y - ship.y), 0) / list.length : 0; + console.log(`t=${((t - 1000) / 1000).toFixed(0)}s A alive=${la.length} B alive=${lb.length} minDist=${minD.toFixed(0)} meanToPlayer A=${meanD(la).toFixed(0)} B=${meanD(lb).toFixed(0)} bullets=${combat.bullets.length} ms=${(combat.missiles || []).length}`); + } + + // bookkeeping: armor delta = damage taken; the kill's attribution + for (const rec of ships.map((s) => s._rec)) { + const armor = rec.ship.hull + (rec.ship.shields || 0); + if (armor < rec.lastArmor) rec.dmgTaken += rec.lastArmor - armor; + rec.lastArmor = armor; + if (rec.ship.dead && rec.diedAt == null) { + rec.diedAt = t; + const shot = rec.ship._lastHit; + rec.killer = shot && shot.shooter ? shot.shooter : null; // bullets carry the shooter; seekers → faction-only + } + } + } + + // -- the result + const aAlive = aliveOf(shipsA).length; + const bAlive = aliveOf(shipsB).length; + let winner = 'draw'; + if (aAlive === 0 && bAlive > 0) winner = B; + else if (bAlive === 0 && aAlive > 0) winner = A; + else if (aAlive === 0 && bAlive === 0) winner = 'mutual'; + else { + // the time cap: the side flying more armor (and ships) leads the stalemate + const armor = (list) => list.reduce((acc, s) => acc + (s.dead ? 0 : s.hull + (s.shields || 0)), 0); + const ha = armor(shipsA); + const hb = armor(shipsB); + winner = Math.abs(ha - hb) < 1 ? 'stalemate' : (ha > hb ? A : B); + } + return { + winner, frames, durationMs: (t - 1000), + aAlive, bAlive, + ships: ships.map((s) => s._rec), + dmgDealt: { [A]: shipsB.reduce((a, s) => a + s._rec.dmgTaken, 0), [B]: shipsA.reduce((a, s) => a + s._rec.dmgTaken, 0) }, + }; +} + +// ------------------------------------------------------------------ run +const N = Number(process.argv[2] ?? 40); +const SEED = Number(process.argv[3] ?? 1); +const rng = mulberry32(SEED); +const FACS = ['apex', 'mindweave', 'zenith']; +const MATCHUPS = [ + ['apex', 'mindweave'], + ['apex', 'zenith'], + ['mindweave', 'zenith'], +]; +const SIZES = [3, 4, 5, 6, 8]; // same size both sides — faction strength, not ship-count, is under test + +const battles = []; +const t0 = Date.now(); +for (const [A, B] of MATCHUPS) { + for (let i = 0; i < N; i++) { + const size = pick(rng, SIZES); + // The spawn order flips half the time (the first-spawned wing's bearing + // and orbit alternation must not leak into the win-rate). + const [X, Y] = rng() < 0.5 ? [A, B] : [B, A]; + try { + battles.push({ A, B, size, ...runBattle(X, Y, size, rng) }); + } catch (err) { + console.error(`battle ${A} vs ${B} (size ${size}) threw:`, err); + process.exit(2); + } + } +} +const wallMs = Date.now() - t0; + +// ---------------------------------------------------------------- report +const fmt = (n) => (Number.isFinite(n) ? String(Math.round(n)) : '—'); +const avg = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN); +const pct = (x, y) => (y > 0 ? Math.round((100 * x) / y) + '%' : '—'); + +const lines = []; +const L = (s = '') => lines.push(s); +L(`FACTION BATTLE SIM — ${battles.length} battles (${N} per matchup), sizes ${SIZES.join('/')} (equal both sides)`); +L(`seed=${SEED} · wall ${Math.round(wallMs / 100) / 10}s · real combat stack + data/combat.json, neutral player (observe-only)`); +L(); + +// ---- per matchup +L('── MATCHUP RESULTS ─────────────────────────────────────────────────'); +L(` matchup wins avg duration avg ships left (winner) dmg dealt (win/loss side)`); +for (const [A, B] of MATCHUPS) { + const ms = battles.filter((b) => b.A === A && b.B === B); + const wA = ms.filter((b) => b.winner === A).length; + const wB = ms.filter((b) => b.winner === B).length; + const dr = ms.length - wA - wB; + const dur = avg(ms.map((b) => b.durationMs)) / 1000; + const left = avg(ms.map((b) => (b.winner === A ? b.aAlive : b.winner === B ? b.bAlive : b.aAlive + b.bAlive))); + const dA = avg(ms.map((b) => b.dmgDealt[A])); + const dB = avg(ms.map((b) => b.dmgDealt[B])); + L(` ${A} vs ${B}`.padEnd(25) + + `${wA} / ${wB} (draw ${dr}) ${dur.toFixed(1)} s ${left.toFixed(1)} ${fmt(dA)} / ${fmt(dB)}`); +} +L(); + +// ---- per faction (aggregate) +L('── FACTION LEDGER (aggregate over the two matchups each fights) ──────'); +L(` faction battles win-rate avg dmg dealt avg dmg taken avg kills avg ships alive at end`); +for (const f of FACS) { + const ms = battles.filter((b) => b.A === f || b.B === f); + const wins = ms.filter((b) => b.winner === f).length; + const dealt = avg(ms.map((b) => b.dmgDealt[f])); + const taken = avg(ms.map((b) => (b.A === f ? b.dmgDealt[b.B] : b.dmgDealt[b.A]))); + const kills = avg(ms.map((b) => { + const rivals = b.ships.filter((s) => s.faction !== f && s.diedAt != null); + return rivals.length; + })); + const aliveEnd = avg(ms.map((b) => b.ships.filter((s) => s.faction === f && s.diedAt == null).length)); + L(` ${f.padEnd(11)} ${String(ms.length).padEnd(9)} ${pct(wins, ms.length).padEnd(11)} ${fmt(dealt).padEnd(14)} ${fmt(taken).padEnd(13)} ${fmt(kills).padEnd(10)} ${aliveEnd.toFixed(1)}`); +} +L(); + +// ---- per ship type +L('── SHIP TYPES (across every battle they flew in) ─────────────────────'); +L(' kills = kills attributed to THIS craft (bullet kills; seeker kills count at faction level)'); +L(); +L(` faction · ship flew survived kills avg dmg taken avg armor lost avg dmg fired`); +for (const f of FACS) { + const recs = {}; + for (const b of battles) for (const s of b.ships) { + if (s.faction !== f) continue; + const key = s.type || s.ship.name; + recs[key] ??= { n: 0, surv: 0, kills: 0, taken: 0, lostPct: 0, fired: 0 }; + const r = recs[key]; + r.n++; + r.surv += s.diedAt == null ? 1 : 0; + r.kills += b.ships.filter((o) => o.faction !== f && o.killer === s.ship).length; + r.taken += s.dmgTaken; + r.lostPct += s.armor0 > 0 ? (s.dmgTaken / s.armor0) * 100 : 0; + r.fired += s.dmgFired; + } + const rows = Object.entries(recs).sort((a, b) => b[1].n - a[1].n); + for (const [type, r] of rows) { + L(` ${f} · ${type}`.padEnd(30) + + `${String(r.n).padEnd(6)} ${pct(r.surv, r.n).padEnd(9)} ${String(fmt(r.kills / r.n)).padEnd(6)} ${String(fmt(r.taken / r.n)).padEnd(14)} ${fmt(r.lostPct / r.n) + '%'.padEnd(17)} ${fmt(r.fired / r.n)}`); + } + L(); +} + +// ---- quick read: the extremes +L('── QUICK READ ────────────────────────────────────────────────────────'); +{ + const fstat = {}; + for (const f of FACS) { + const ms = battles.filter((b) => b.A === f || b.B === f); + fstat[f] = { + win: ms.filter((b) => b.winner === f).length / ms.length, + diff: avg(ms.map((b) => b.dmgDealt[f] - (b.A === f ? b.dmgDealt[b.B] : b.dmgDealt[b.A]))), + }; + } + const by = (cmp) => FACS.reduce((a, b) => (cmp(fstat[a], fstat[b]) ? a : b)); + const best = by((x, y) => (x.win === y.win ? x.diff > y.diff : x.win > y.win)); + const worst = by((x, y) => (x.win === y.win ? x.diff < y.diff : x.win < y.win)); + L(` strongest faction: ${best} (win ${pct(Math.round(fstat[best].win * 100), 100)}, avg dmg differential ${fmt(fstat[best].diff)})`); + L(` weakest faction: ${worst} (win ${pct(Math.round(fstat[worst].win * 100), 100)}, avg dmg differential ${fmt(fstat[worst].diff)})`); + + // the ships that read over / under: survival + attributed kills per flight + const agg = {}; + for (const b of battles) { + for (const s of b.ships) { + const key = `${s.faction}/${s.type || s.ship.name}`; + const r = (agg[key] ??= { n: 0, surv: 0, kills: 0 }); + r.n++; + r.surv += s.diedAt == null ? 1 : 0; + r.kills += b.ships.filter((o) => o.faction !== s.faction && o.killer === s.ship).length; + } + } + const stats = Object.entries(agg).map(([k, r]) => ({ + k, ...r, score: r.surv / r.n + r.kills / r.n, + })); + const hot = [...stats].filter((s) => s.n >= 5).sort((a, b) => b.score - a.score).slice(0, 3); + const cold = [...stats].filter((s) => s.n >= 5).sort((a, b) => a.score - b.score).slice(0, 3); + L(' reading strong: ' + hot.map((s) => `${s.k} (survived ${pct(s.surv, s.n)}, ${(s.kills / s.n).toFixed(2)} kills/fight)`).join(' ')); + L(' reading weak: ' + cold.map((s) => `${s.k} (survived ${pct(s.surv, s.n)}, ${(s.kills / s.n).toFixed(2)} kills/fight)`).join(' ')); +} +L(); +L(`raw battles: ${battles.length}; timeouts=${battles.filter((b) => b.durationMs >= TIME_CAP_MS - 1).length}; mutual=${battles.filter((b) => b.winner === 'mutual').length}`); + +console.log(lines.join('\n')); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index aaad8f3..baa7037 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -113,6 +113,10 @@ export class CombatSystem { this.enemies = []; this.allies = []; // neutral escorts (a separate roster — js/combat/NeutralShip.js) this.bullets = []; + // THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): the homing + // stamp for listed factions' bolts (spawnBullet) + their bend (the + // stepHomingBullets pass, run in update before the collisions). + this.bulletHoming = config.section('combat.bulletHoming', {}); // The heat-seeker roster (js/combat/Weapons.js steps + hits them; the // missiles themselves live in js/combat/Missile.js). this.missiles = []; @@ -932,6 +936,11 @@ export class CombatSystem { // end them. No walls here (the range clips its own). stepMissiles(this, delta, this.playerDown ? null : player); + // 3c) THE MINDWEAVE'S WILL (stepHomingBullets): the listed factions' + // bolts bend toward the nearest live hostile before the + // collisions read this frame's travel. + this.stepHomingBullets(delta); + // 4) Collisions: your tracers × the pack, their lances × you, rams. this.collide(delta, player); @@ -1370,11 +1379,81 @@ export class CombatSystem { // a faction's bolt drops the rival faction's hulls, never its own // wing; a classic bolt has none and never shoots itself). b.factionId = typeof wpn.factionId === 'string' ? wpn.factionId : null; + // THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): a listed + // faction's bolt/shell bends toward the nearest live hostile for the + // first `durationMs` of flight — the weaver's threads find their mark + // without negating a committed dodge (the bend is turn-rate capped). + const bh = this.bulletHoming; + if (owner === 'enemy' && b.factionId && Array.isArray(bh.factions) && bh.factions.includes(b.factionId)) { + b.homing = { + turn: Math.max(0, Number(bh.turnRate) || 0), + left: Math.max(0, Number(bh.durationMs) || 0), + range: Math.max(40, Number(bh.range) || 0), + }; + } b.setDepth(20); this.bullets.push(b); return b; } + /** + * THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): the first + * `durationMs` of flight, a listed faction's bolts and shells bend + * toward the NEAREST LIVE HOSTILE within range — the threads find their + * mark, subtle yet effective (a capped turn-rate: a hard dodge still + * wins, a straight shot no longer misses). Hostiles = the rival + * faction's wing (never the shooter's own — no friendly homing) and, + * for bolts hostile to the player, the player itself. War-zone fire + * (hostileToPlayer false) never homes on the player — the will only + * curves the fire at the rival wing. + * @param {number} delta frame delta (ms) + */ + stepHomingBullets(delta) { + const bs = this.bullets; + if (!bs || bs.length === 0) return; + const dt = Math.min(delta, 64) / 1000; + const player = this.playerDown ? null : this.ship; + for (let i = bs.length - 1; i >= 0; i--) { + const b = bs[i]; + const h = b.homing; + if (b.dead || !h || h.left <= 0) continue; + h.left -= delta; + if (h.left <= 0 || h.turn <= 0) continue; + let bx = 0, by = 0, bd = Infinity; + for (const e of this.enemies) { + if (e.dead || e.cloaked) continue; + if (!e.factionId || e.factionId === b.factionId) continue; // the own wing stands + const dx = e.x - b.x; + const dy = e.y - b.y; + const d2 = dx * dx + dy * dy; + if (d2 < bd) { bd = d2; bx = e.x; by = e.y; } + } + if (player && b.hostileToPlayer !== false) { + const dx = player.x - b.x; + const dy = player.y - b.y; + const d2 = dx * dx + dy * dy; + if (d2 < bd) { bd = d2; bx = player.x; by = player.y; } + } + if (bd === Infinity || bd > h.range * h.range) continue; + if (!b.body) continue; + const vx = b.body.velocity.x; + const vy = b.body.velocity.y; + const sp = Math.hypot(vx, vy); + if (sp <= 0) continue; + const cur = Math.atan2(vy, vx); + const want = Math.atan2(by - b.y, bx - b.x); + let dA = Phaser.Math.Angle.Wrap(want - cur); + const maxTurn = h.turn * dt; + dA = Math.max(-maxTurn, Math.min(maxTurn, dA)); + const na = cur + dA; + b.body.velocity.x = Math.cos(na) * sp; + b.body.velocity.y = Math.sin(na) * sp; + b.angle0 = na; + b.rotation = na; + if (typeof b._syncCore === 'function') b._syncCore(); + } + } + // ------------------------------------------------------------- collisions /** @@ -1399,15 +1478,22 @@ export class CombatSystem { /** The collision pass: your tracers × the pack, their lances × you, rams. */ collide(delta, player) { - // THE MINDWEAVE'S PHANTOMS (js/combat/EnemyAbilities.js) — your - // tracers × the illusions FIRST: a bolt that reaches a phantom pops - // it (the hologram has no hull to break) and is spent — the real - // hull stands elsewhere (the blink's point). + // THE MINDWEAVE'S PHANTOMS (js/combat/EnemyAbilities.js) — tracers × + // the illusions FIRST: a bolt that reaches a phantom pops it (the + // hologram has no hull to break) and is spent — the real hull stands + // elsewhere (the blink's point). The ALL-HOSTILE rule: your tracers + // pop any court, and a rival wing's tracers pop THEIR court (the + // bait is built around every enemy, not just the ship) — the own + // wing's bolts pass through their own phantoms (no friendly fire + // on a hologram), and a classic (unclaimed) court only your tracers + // can pop (the old behavior). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; - if (b.dead || b.owner !== 'player') continue; + if (b.dead) continue; + if (b.owner !== 'player' && !(b.owner === 'enemy' && b.factionId)) continue; for (const il of this.illusions) { if (il.dead) continue; + if (b.owner === 'enemy' && (!il.factionId || il.factionId === b.factionId)) continue; if (this.hitsSwept(b, il, il.hitRadius, delta)) { this.fx.flash(il.x, il.y, 0xffffff, 30, 150); this.fx.sparks(il.x, il.y, 0x9d4dff, 7, 220, 420); diff --git a/js/combat/EnemyAbilities.js b/js/combat/EnemyAbilities.js index 89e8dfc..07d262d 100644 --- a/js/combat/EnemyAbilities.js +++ b/js/combat/EnemyAbilities.js @@ -38,10 +38,13 @@ * same decoy roster the blink uses. * 'telekinetic_wave' (Mindweave) the PSYCHIC TIDE: every `cooldown` s a * wave blooms from the craft (travelMs to - * arrive); at arrival, a player inside - * `radius` gets shoved (dir 'push') or - * DRAGGED IN (dir 'pull') — an impulse of - * `power` px/s on the hull, no damage. + * arrive); at arrival, EVERY hostile inside + * `radius` — the player OR a rival faction's + * craft (the caster's own wing stands; the + * all-hostile rule) — gets shoved (dir + * 'push') or DRAGGED IN (dir 'pull'): an + * impulse of `power` px/s on the hull, + * no damage. * 'flank_barrage' (Zenith) the WING VOLLEY: every `cooldown` s a * salvo of `count` HEAT-SEEKERS flies out * of BOTH WINGS at 90° off the craft's @@ -165,6 +168,7 @@ export function spawnIllusion(ctx, x, y, e, lifeMs = 2000, tintHex = '#9d4dff') lifeMs: Math.max(1, num(lifeMs, 2000)), until: null, // the caller stamps it on the scene's clock dead: false, + factionId: e && typeof e.factionId === 'string' ? e.factionId : null, // the court's faction (the all-hostile pop rule) }); return ctx.illusions[ctx.illusions.length - 1]; } @@ -202,8 +206,10 @@ export function stepIllusions(ctx, time) { /** * The telekinetic WAVES' frame step (CombatSystem.update's 0b): each wave - * blooms to its radius over travelMs; at arrival a player inside it gets - * the impulse (push/pull), and the ring's beat plays either way. + * blooms to its radius over travelMs; at arrival, EVERY hostile standing + * in its reach — the player AND rival-faction craft (the all-hostile + * rule; the caster's own wing and cloaked craft stand) — gets the + * impulse (push/pull), and the ring's beat plays either way. */ export function stepTkwaves(ctx, time, player) { const ws = ctx.tkwaves; @@ -224,6 +230,25 @@ export function stepTkwaves(ctx, time, player) { } if (ctx.scene) sfx(ctx.scene, w.dir === 'pull' ? 'hull' : 'blink'); } + // THE ALL-HOSTILE TIDE (the hand is built around EVERY enemy, not + // just the ship): every live craft standing in the wave's reach takes + // the same shove — or pull (a tide that drags the rival's wing into + // the weaver's ring). The caster's own wing stands (no friendly + // tide), and a cloaked craft stays phased (the cloak's unhittable + // rule covers the hands too). + if (Array.isArray(ctx.enemies)) { + for (const s of ctx.enemies) { + if (s.dead || s.cloaked) continue; + if (s.factionId && s.factionId === w.factionId) continue; + if (Math.hypot(s.x - w.x, s.y - w.y) > w.r) continue; + const imp = pushImpulse(w, s); + if (s.body) { + s.body.velocity.x += imp.vx; + s.body.velocity.y += imp.vy; + } + if (ctx.fx) ctx.fx.sparks(s.x, s.y, c, 8, 200, 400); + } + } if (ctx.fx) ctx.fx.ring(w.x, w.y, c, Math.max(60, w.r * 0.9), 420); } ctx.tkwaves = keep; @@ -487,6 +512,7 @@ const HANDLERS = { power: num(a.power, 260), until: time + num(a.travelMs, 650), colorInt: col(a.color, 0x9d4dff), + factionId: typeof e.factionId === 'string' ? e.factionId : null, // the all-hostile tide's own-wing stand }); if (ctx.fx) ctx.fx.ring(e.x, e.y, col(a.color, 0x9d4dff), 60, 500); sfx(ctx.scene, 'shield');