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
This commit is contained in:
parent
1a581af211
commit
722dbc8415
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue