preview
Mouse (move and aim) • E (inventory)
๐Ÿ”ฅ slow (GPU melter) ๐Ÿ“ฑ RIP mobile users
source
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  <title>Survivors RPG</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { overflow: hidden; background: #111; }
    #game { display: block; width: 100vw; height: 100vh; }
    #inventory-overlay {
      display: none;
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.7);
      justify-content: center;
      align-items: center;
      z-index: 100;
    }
    #inventory-overlay.open { display: flex; }
    #inventory-panel {
      background: #2a2a2a;
      border: 3px solid #555;
      border-radius: 8px;
      padding: 20px;
      display: flex;
      gap: 30px;
    }
    #equipment-slots {
      display: flex;
      flex-direction: column;
      gap: 8px;
      min-width: 80px;
    }
    .equip-slot {
      width: 56px; height: 56px;
      border: 2px solid #444;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 28px;
      background: #1a1a1a;
      cursor: pointer;
    }
    .equip-slot:hover { border-color: #888; background: #252525; }
    .equip-slot.drag-over { border-color: #8af; background: #2a3a4a; }
    .equip-slot[draggable="true"] { cursor: grab; }
    .equip-slot[draggable="true"]:active { cursor: grabbing; }
    .equip-slot.common { text-shadow: -1px -1px 0 #888, 1px -1px 0 #888, -1px 1px 0 #888, 1px 1px 0 #888, 0 -1px 0 #888, 0 1px 0 #888, -1px 0 0 #888, 1px 0 0 #888; }
    .equip-slot.uncommon { text-shadow: -1px -1px 0 #4a9eff, 1px -1px 0 #4a9eff, -1px 1px 0 #4a9eff, 1px 1px 0 #4a9eff, 0 -1px 0 #4a9eff, 0 1px 0 #4a9eff, -1px 0 0 #4a9eff, 1px 0 0 #4a9eff; }
    .equip-slot.rare { text-shadow: -1px -1px 0 #ffd700, 1px -1px 0 #ffd700, -1px 1px 0 #ffd700, 1px 1px 0 #ffd700, 0 -1px 0 #ffd700, 0 1px 0 #ffd700, -1px 0 0 #ffd700, 1px 0 0 #ffd700; }
    .equip-slot.epic { text-shadow: -1px -1px 0 #9333ea, 1px -1px 0 #9333ea, -1px 1px 0 #9333ea, 1px 1px 0 #9333ea, 0 -1px 0 #9333ea, 0 1px 0 #9333ea, -1px 0 0 #9333ea, 1px 0 0 #9333ea; }
    .equip-slot.legendary { text-shadow: -1px -1px 0 #f97316, 1px -1px 0 #f97316, -1px 1px 0 #f97316, 1px 1px 0 #f97316, 0 -1px 0 #f97316, 0 1px 0 #f97316, -1px 0 0 #f97316, 1px 0 0 #f97316; }
    .equip-slot-wrap { display: flex; flex-direction: column; align-items: center; gap: 2px; }
    .equip-slot-label { font-size: 10px; color: #888; text-align: center; }
    #inventory-grid {
      display: grid;
      grid-template-columns: repeat(6, 56px);
      grid-template-rows: repeat(4, 56px);
      gap: 4px;
    }
    .inv-slot {
      width: 56px; height: 56px;
      border: 2px solid #444;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 28px;
      background: #1a1a1a;
      cursor: pointer;
    }
    .inv-slot:hover { border-color: #888; background: #252525; }
    .inv-slot.common { border-color: #888; text-shadow: -1px -1px 0 #888, 1px -1px 0 #888, -1px 1px 0 #888, 1px 1px 0 #888, 0 -1px 0 #888, 0 1px 0 #888, -1px 0 0 #888, 1px 0 0 #888; }
    .inv-slot.uncommon { border-color: #4a9eff; text-shadow: -1px -1px 0 #4a9eff, 1px -1px 0 #4a9eff, -1px 1px 0 #4a9eff, 1px 1px 0 #4a9eff, 0 -1px 0 #4a9eff, 0 1px 0 #4a9eff, -1px 0 0 #4a9eff, 1px 0 0 #4a9eff; }
    .inv-slot.rare { border-color: #ffd700; text-shadow: -1px -1px 0 #ffd700, 1px -1px 0 #ffd700, -1px 1px 0 #ffd700, 1px 1px 0 #ffd700, 0 -1px 0 #ffd700, 0 1px 0 #ffd700, -1px 0 0 #ffd700, 1px 0 0 #ffd700; }
    .inv-slot.epic { border-color: #9333ea; text-shadow: -1px -1px 0 #9333ea, 1px -1px 0 #9333ea, -1px 1px 0 #9333ea, 1px 1px 0 #9333ea, 0 -1px 0 #9333ea, 0 1px 0 #9333ea, -1px 0 0 #9333ea, 1px 0 0 #9333ea; }
    .inv-slot.legendary { border-color: #f97316; text-shadow: -1px -1px 0 #f97316, 1px -1px 0 #f97316, -1px 1px 0 #f97316, 1px 1px 0 #f97316, 0 -1px 0 #f97316, 0 1px 0 #f97316, -1px 0 0 #f97316, 1px 0 0 #f97316; }
    .inv-slot.selected { border-color: #fff; box-shadow: 0 0 8px #fff; }
    .inv-slot.drag-over { border-color: #8af; background: #2a3a4a; }
    .inv-slot[draggable="true"] { cursor: grab; }
    .inv-slot[draggable="true"]:active { cursor: grabbing; }
    #inventory-right { display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
    #inventory-grid, #scrap-slot, #trash-all-btn { align-self: flex-end; }
    #scrap-resource {
      align-self: flex-end;
      min-width: 148px;
      padding: 8px 12px;
      border: 2px solid #6f5a43;
      border-radius: 6px;
      background: #1d1712;
      color: #f0d7a8;
      font-family: monospace;
      font-size: 14px;
      text-align: right;
    }
    #station-panel {
      display: none;
      width: 100%;
      background: #241c18;
      border: 2px solid #5c4a3d;
      border-radius: 6px;
      padding: 12px;
      color: #eee;
      font-family: monospace;
    }
    #station-panel.open { display: flex; flex-direction: column; gap: 10px; }
    #station-title { font-size: 16px; font-weight: bold; }
    #station-description { font-size: 12px; color: #cfcfcf; line-height: 1.35; }
    #station-inputs { display: flex; gap: 10px; flex-wrap: wrap; }
    .station-input-wrap { display: flex; flex-direction: column; gap: 4px; align-items: center; }
    .station-input-label { font-size: 11px; color: #9a9a9a; text-transform: uppercase; text-align: center; }
    .station-input-slot.selected { border-color: #fff; box-shadow: 0 0 8px rgba(255,255,255,0.55); }
    #station-content { display: flex; flex-direction: column; gap: 10px; align-items: stretch; }
    #station-output { display: flex; flex-direction: column; gap: 6px; flex: 1; }
    .station-section-label { font-size: 11px; color: #888; text-transform: uppercase; }
    #station-output-grid {
      display: grid;
      grid-template-columns: repeat(6, 56px);
      grid-template-rows: repeat(2, 56px);
      gap: 4px;
    }
    #station-controls {
      display: flex;
      flex-direction: row;
      gap: 10px;
      align-items: center;
      justify-content: flex-end;
      min-width: 160px;
    }
    #station-status { font-size: 13px; color: #ddd; text-align: right; flex: 1; }
    #station-action-btn, #station-close-btn {
      padding: 10px 16px;
      font-size: 13px;
      cursor: pointer;
      border-radius: 4px;
      font-family: inherit;
    }
    #station-action-btn:hover:not(:disabled) { filter: brightness(1.1); }
    #station-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
    #station-close-btn { background: #3a3a3a; border: 2px solid #555; color: #ccc; }
    #station-close-btn:hover { background: #4a4a4a; color: #fff; }
    .scrap-slot { border-color: #5a4a3a; }
    .scrap-slot:hover { border-color: #8a7a6a; }
    #trash-all-btn {
      padding: 6px 12px;
      font-size: 12px;
      cursor: pointer;
      background: #5a3a3a;
      border: 2px solid #7a4a4a;
      border-radius: 4px;
      color: #ccc;
      font-family: inherit;
    }
    #trash-all-btn:hover { background: #7a4a4a; border-color: #9a6a6a; color: #fff; }
    #tooltip-wrapper {
      position: fixed;
      display: none;
      flex-direction: row;
      gap: 12px;
      align-items: flex-start;
      pointer-events: none;
      z-index: 200;
    }
    #tooltip-wrapper.show { display: flex; }
    #tooltip-equipped,
    #tooltip {
      background: #1a1a1a;
      border: 2px solid #555;
      border-radius: 4px;
      padding: 8px 12px;
      font-family: monospace;
      font-size: 12px;
      color: #eee;
      max-width: 220px;
    }
    #tooltip-equipped { display: none; }
    #tooltip-equipped.show { display: block; }
    #tooltip.common, #tooltip-equipped.common { border-color: #888; }
    #tooltip.uncommon, #tooltip-equipped.uncommon { border-color: #4a9eff; }
    #tooltip.rare, #tooltip-equipped.rare { border-color: #ffd700; }
    #tooltip.epic, #tooltip-equipped.epic { border-color: #9333ea; }
    #tooltip.legendary, #tooltip-equipped.legendary { border-color: #f97316; }
    .tooltip-name { font-weight: bold; margin-bottom: 4px; }
    .tooltip-name.common { color: #aaa; }
    .tooltip-name.uncommon { color: #4a9eff; }
    .tooltip-name.rare { color: #ffd700; }
    .tooltip-name.epic { color: #9333ea; }
    .tooltip-name.legendary { color: #f97316; }
    .tooltip-stat { color: #8f8; }
    .tooltip-mod { color: #8af; }
    #hud {
      position: fixed;
      top: 10px; left: 10px;
      color: #fff;
      font-family: monospace;
      font-size: 14px;
      z-index: 50;
    }
    #game-over {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.8);
      display: none;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      color: #fff;
      font-size: 48px;
      z-index: 150;
    }
    #game-over.show { display: flex; }
    #game-over p { margin: 10px; font-size: 24px; }
    #restart-btn {
      margin-top: 20px;
      padding: 12px 24px;
      font-size: 18px;
      cursor: pointer;
      background: #4a4;
      border: none;
      border-radius: 4px;
    }
    #restart-btn:hover { background: #5b5; }
    #stats-panel {
      width: 280px;
      min-width: 280px;
      flex-shrink: 0;
      overflow: hidden;
      background: #1a1a1a;
      border: 2px solid #444;
      border-radius: 4px;
      padding: 12px;
      font-family: monospace;
      font-size: 12px;
      color: #fff;
    }
    #stats-panel h3 { font-size: 11px; color: #888; margin-bottom: 8px; text-transform: uppercase; }
    #stats-panel #stats-content { width: 100%; box-sizing: border-box; border-radius: 2px; overflow: hidden; }
    #stats-panel .stat-row {
      display: grid;
      grid-template-columns: minmax(100px, 1fr) 56px 52px;
      column-gap: 12px;
      row-gap: 0;
      align-items: baseline;
      padding: 2px 6px;
    }
    #stats-panel .stat-row:nth-child(odd) { background: #1e1e1e; }
    #stats-panel .stat-row:nth-child(even) { background: #252525; }
    #stats-panel .stat-label {
      color: #fff;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      min-width: 0;
      display: block;
    }
    #stats-panel .stat-value { color: #fff; text-align: right; }
    #stats-panel .stat-diff { min-width: 52px; text-align: left; }
    #stats-panel .stat-diff-good { color: #4a4; }
    #stats-panel .stat-diff-bad { color: #c44; }
  </style>
</head>
<body>
  <canvas id="game" tabindex="0"></canvas>
  <div id="hud"></div>
  <div id="inventory-overlay">
    <div id="inventory-panel">
      <div id="equipment-column">
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="helmet" title="Helmet"></div><div class="equip-slot-label">Helmet</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="chest" title="Chest"></div><div class="equip-slot-label">Chest</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="pants" title="Pants"></div><div class="equip-slot-label">Pants</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="gloves" title="Gloves"></div><div class="equip-slot-label">Gloves</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="leftHand" title="Primary"></div><div class="equip-slot-label">Primary</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="rightHand" title="Secondary"></div><div class="equip-slot-label">Secondary</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="boots" title="Boots"></div><div class="equip-slot-label">Boots</div></div>
      </div>
      <div id="inventory-right">
        <div id="station-panel">
          <div id="station-title"></div>
          <div id="station-description"></div>
          <div id="station-inputs"></div>
          <div id="station-content">
            <div id="station-output">
              <div class="station-section-label">Output</div>
              <div id="station-output-grid"></div>
            </div>
            <div id="station-controls">
              <div id="station-status"></div>
              <button id="station-action-btn" type="button"></button>
              <button id="station-close-btn" type="button">Close</button>
            </div>
          </div>
        </div>
        <div id="inventory-grid"></div>
        <div id="scrap-resource">Scrap: 0</div>
        <div id="scrap-slot" class="inv-slot scrap-slot" title="Drag items here to scrap">โ™ป๏ธ</div>
        <button id="trash-all-btn" type="button" title="Trash all inventory (keeps equipped items)">Trash All</button>
      </div>
      <div id="stats-panel">
        <h3>Stats</h3>
        <div id="stats-content"></div>
      </div>
    </div>
  </div>
  <div id="tooltip-wrapper">
    <div id="tooltip-equipped"></div>
    <div id="tooltip"></div>
  </div>
  <div id="game-over">
    <span>Game Over</span>
    <p id="survived-time"></p>
    <button id="restart-btn">Restart</button>
  </div>

  <script>
    // ========== 1. CONSTANTS & CONFIG ==========
    const STAT_KEYS = [
      'damage', 'damagePercent', 'fireRate', 'projectileSize', 'projectileSpeed', 'piercing', 'aoeRadius', 'aoePercent',
      'numProjectiles', 'knockback', 'hp', 'hpRegen', 'moveSpeed', 'size', 'contactDamage'
    ];
    const DEFAULT_STATS = {
      damage: 10, damagePercent: 0, fireRate: 1, projectileSize: 8, projectileSpeed: 400, piercing: 1, aoeRadius: 0, aoePercent: 10,
      numProjectiles: 1, knockback: 10, hp: 100, hpRegen: 0.5, moveSpeed: 180, size: 16, contactDamage: 0
    };
    const MELEE_IMPULSE = 120;  // fixed impulse magnitude when melee damage occurs (mass-weighted distribution)
    const PBD_ITERATIONS = 2;   // constraint solver iterations for stability
    const KNOCKBACK_VEL_FACTOR = 10;  // converts projectile knockback stat into velocity impulse
    const IMPULSE_DAMPING_PER_SECOND = 0.015;
    const CORPSE_FADE_SECONDS = 0.75;
    const CORPSE_IMPACT_MIN_SPEED = 45;
    const CORPSE_IMPACT_IMPULSE_FACTOR = 0.45;
    const SPAWN_INTERVAL = 1 / 3;   // 50% of previous rate
    const SPAWN_STEP_SECONDS = 30;   // 200% of previous: budget steps up every 30s
    const GOODNESS_SCALE = 5;        // tanh scale for normalizeGoodness
    const CELL_SIZE = 30;
    const EQUIP_SLOTS = ['helmet', 'chest', 'pants', 'gloves', 'leftHand', 'rightHand', 'boots'];
    const ARENA_SIZE = 2400;
    const ARENA_HALF = ARENA_SIZE / 2;
    const WALL_THICKNESS = 16;
    const HOLE_WIDTH = 80;
    const PORTS_PER_SIDE = 2;
    const OBJECTIVE_RADIUS = 120;
    const CAPTURE_TIME = 2.0;
    const NUM_OBJECTIVES = 3;
    const BOMB_RADIUS = 600;
    const MAGNET_PULL_SPEED = 1400;
    const TRADER_OUTPUT_SLOTS = 12;
    const TRADE_SCRAP_COST = 5;
    const STAT_DISPLAY = [
      { key: 'damage', label: 'Damage' },
      { key: 'damagePercent', label: 'Damage %' },
      { key: 'fireRate', label: 'Fire Rate' },
      { key: 'piercing', label: 'Pierce' },
      { key: 'numProjectiles', label: 'Projectiles' },
      { key: 'aoeRadius', label: 'AOE Radius' },
      { key: 'aoePercent', label: 'AOE %' },
      { key: 'knockback', label: 'Knockback' },
      { key: 'hp', label: 'HP' },
      { key: 'hpRegen', label: 'HP Regen' },
      { key: 'moveSpeed', label: 'Move Speed' },
      { key: 'size', label: 'Size' },
      { key: 'contactDamage', label: 'Contact Dmg' },
      { key: 'projectileSize', label: 'Proj Size' },
      { key: 'projectileSpeed', label: 'Proj Speed' }
    ];
    const HAND_SLOTS = ['leftHand', 'rightHand'];
    const PRIMARY_HAND_SLOT = 'leftHand';
    const SECONDARY_HAND_SLOT = 'rightHand';
    const INV_COLS = 6, INV_ROWS = 4;

    // ========== 2. STATE ==========
    let canvas, ctx;
    let gameTime = 0;
    let pityCounters = {};
    let dropsReceived = 0;
    function rollBoolWithPity(chance, key, backstop) {
      const c = (pityCounters[key] || 0) + 1;
      if (Math.random() < chance || c >= backstop) {
        pityCounters[key] = 0;
        return true;
      }
      pityCounters[key] = c;
      return false;
    }
    let gamePaused = false;
    let gameOver = false;
    const keys = {};
    let mouseX = 0, mouseY = 0;
    let mouseWorldX = 0, mouseWorldY = 0;
    let mouseLmbDown = false;
    let mouseRmbDown = false;
    let lastTime = 0;
    let fireCooldown = 0.5;
    let spawnAccum = 0;
    let screenShake = 0;

    const player = {
      x: 0, y: 0, vx: 0, vy: 0,
      impulseVx: 0, impulseVy: 0,
      dirX: 1, dirY: 0,
      hp: 100, maxHp: 100,
      stats: { ...DEFAULT_STATS },
      equipment: { helmet: null, chest: null, pants: null, gloves: null, leftHand: null, rightHand: null, boots: null },
      hitInvuln: 0,
      abilityCooldowns: { leftHand: 0, rightHand: 0 }
    };
    const abilityVisuals = [];
    const worldParticles = [];

    const enemies = [];
    const projectiles = [];
    const itemDrops = [];
    const mapObjectives = [];
    let traderOpen = false;
    let activeTraderObjective = null;
    let activeStation = null;
    const traderOutput = Array(TRADER_OUTPUT_SLOTS).fill(null);
    const INV_SIZE = INV_COLS * INV_ROWS;
    let inventory = Array(INV_SIZE).fill(null);
    let scrap = 0;

    // ========== 3. DEFS ==========
    const ENEMY_DEFS = [
      { id: 'grub', name: 'Grub', points: 1, baseStats: { hp: 10, moveSpeed: 55, size: 14, contactDamage: 5 }, hue: 90, darkness: 0.6, weight: 1 },
      { id: 'slime', name: 'Slime', points: 5, baseStats: { hp: 26, moveSpeed: 30, size: 16, contactDamage: 10 }, hue: 140, darkness: 0.6, weight: 1 },
      { id: 'imp', name: 'Imp', points: 8, baseStats: { hp: 8, moveSpeed: 115, size: 10, contactDamage: 5 }, hue: 20, darkness: 0.5, weight: 1 },
      { id: 'wraith', name: 'Wraith', points: 12, baseStats: { hp: 30, moveSpeed: 100, size: 14, contactDamage: 20 }, hue: 260, darkness: 0.4, weight: 1 },
      { id: 'troll', name: 'Troll', points: 18, baseStats: { hp: 60, moveSpeed: 50, size: 24, contactDamage: 25 }, hue: 140, darkness: 0.8, weight: 1 },
      { id: 'specter', name: 'Specter', points: 24, baseStats: { hp: 50, moveSpeed: 120, size: 14, contactDamage: 25 }, hue: 280, darkness: 0.45, weight: 1 },
      { id: 'beast', name: 'Beast', points: 30, baseStats: { hp: 100, moveSpeed: 95, size: 20, contactDamage: 35 }, hue: 25, darkness: 0.75, weight: 1 },
      { id: 'demon', name: 'Demon', points: 40, baseStats: { hp: 250, moveSpeed: 75, size: 22, contactDamage: 35 }, hue: 0, darkness: 0.8, weight: 1 },
      { id: 'knight', name: 'Dark Knight', points: 50, baseStats: { hp: 420, moveSpeed: 70, size: 26, contactDamage: 60 }, hue: 120, darkness: 0.9, weight: 1 },
      // Summoners
      { id: 'imp_summoner', name: 'Imp Summoner', points: 16, baseStats: { hp: 28, moveSpeed: 60, size: 14, contactDamage: 8 }, hue: 25, darkness: 0.6, weight: 1, summoner: { summonDefId: 'imp', count: 1, interval: 5 } },
      { id: 'imp_summoner_summoner', name: 'Imp Summoner Summoner', points: 35, baseStats: { hp: 80, moveSpeed: 35, size: 22, contactDamage: 15 }, hue: 30, darkness: 0.7, weight: 1, summoner: { summonDefId: 'imp_summoner', count: 1, interval: 15 } },
      { id: 'demon_summoner', name: 'Demon Summoner', points: 55, baseStats: { hp: 32, moveSpeed: 50, size: 18, contactDamage: 25 }, hue: 0, darkness: 0.88, weight: 1, summoner: { summonDefId: 'demon', count: 2, interval: 6 } },
      { id: 'demon_summoner_summoner', name: 'Demon Summoner Summoner', points: 80, baseStats: { hp: 450, moveSpeed: 30, size: 20, contactDamage: 35 }, hue: 340, darkness: 0.92, weight: 1, summoner: { summonDefId: 'demon_summoner', count: 2, interval: 18 } },
      // Ranged enemies (1 per 2 melee, skip first 2 point levels); points x2, Shooter nerf: fire/3, proj half speed, proj 2x size
      { id: 'shooter', name: 'Shooter', points: 20, ranged: true, baseStats: { hp: 10, moveSpeed: 50, size: 14, contactDamage: 0 }, hue: 120, darkness: 0.2, weight: 1, rangedStats: { projSpeed: 175, projDamage: 8, fireRate: 0.277, numProjectiles: 1, projSize: 10 } },
      { id: 'scatter', name: 'Scatter', points: 33, ranged: true, baseStats: { hp: 32, moveSpeed: 55, size: 16, contactDamage: 0 }, hue: 110, darkness: 0.4, weight: 1, rangedStats: { projSpeed: 175, projDamage: 6, fireRate: 0.2, numProjectiles: 3, projSize: 10, spread: 0.25 } },
      { id: 'sniper', name: 'Sniper', points: 51, ranged: true, baseStats: { hp: 60, moveSpeed: 70, size: 14, contactDamage: 0 }, hue: 0, darkness: 0.6, weight: 1, rangedStats: { projSpeed: 600, projDamage: 15, fireRate: 0.3, numProjectiles: 1, projSize: 10 } },
      { id: 'warlock', name: 'Warlock', points: 80, ranged: true, baseStats: { hp: 85, moveSpeed: 60, size: 18, contactDamage: 0 }, hue: 100, darkness: 0.8, weight: 1, rangedStats: { projSpeed: 300, projDamage: 8, fireRate: 0.2, numProjectiles: 5, projSize: 15, spread: 0.35 } }
    ];

    const ITEM_BASE_DEFS = [
      { id: 'cloth_helmet', name: 'Cloth Cap', slot: 'helmet', emoji: '๐Ÿช–', baseStat: { stat: 'hp', value: 5 }, tier: 1 },
      { id: 'cloth_chest', name: 'Cloth Vest', slot: 'chest', emoji: '๐Ÿ‘•', baseStat: { stat: 'hp', value: 10 }, tier: 1 },
      { id: 'cloth_pants', name: 'Cloth Leggings', slot: 'pants', emoji: '๐Ÿ‘–', baseStat: { stat: 'hpRegen', value: 0.2 }, tier: 1 },
      { id: 'cloth_gloves', name: 'Cloth Gloves', slot: 'gloves', emoji: '๐Ÿงค', baseStat: { stat: 'fireRate', value: 0.1 }, tier: 1 },
      { id: 'iron_gloves', name: 'Iron Gauntlets', slot: 'gloves', emoji: '๐ŸฅŠ', baseStat: { stat: 'fireRate', value: 0.2 }, tier: 2 },
      { id: 'plate_gloves', name: 'Plate Gauntlets', slot: 'gloves', emoji: '๐ŸฅŠ', baseStat: { stat: 'fireRate', value: 0.35 }, tier: 3 },
      { id: 'iron_helmet', name: 'Iron Helm', slot: 'helmet', emoji: 'โ›‘๏ธ', baseStat: { stat: 'hp', value: 15 }, tier: 2 },
      { id: 'iron_chest', name: 'Iron Armor', slot: 'chest', emoji: '๐Ÿ‘”', baseStat: { stat: 'hp', value: 25 }, tier: 2 },
      { id: 'plate_helmet', name: 'Plate Helm', slot: 'helmet', emoji: '๐Ÿช–', baseStat: { stat: 'hp', value: 30 }, tier: 3 },
      { id: 'plate_chest', name: 'Plate Armor', slot: 'chest', emoji: '๐Ÿ›ก๏ธ', baseStat: { stat: 'hp', value: 50 }, tier: 3 },
      { id: 'dagger', name: 'Dagger', slot: 'leftHand', emoji: '๐Ÿ—ก๏ธ', baseStat: { stat: 'damage', value: 5 }, tier: 1 },
      { id: 'sword', name: 'Sword', slot: 'leftHand', emoji: 'โš”๏ธ', baseStat: { stat: 'damage', value: 12 }, tier: 2 },
      { id: 'greataxe', name: 'Greataxe', slot: 'leftHand', emoji: '๐Ÿช“', baseStat: { stat: 'damage', value: 25 }, tier: 3 },
      { id: 'wand', name: 'Wand', slot: 'leftHand', emoji: '๐Ÿช„', baseStat: { stat: 'fireRate', value: 0.3 }, tier: 2 },
      { id: 'bow', name: 'Bow', slot: 'leftHand', emoji: '๐Ÿน', baseStat: { stat: 'damage', value: 10 }, tier: 2 },
      { id: 'shield', name: 'Shield', slot: 'leftHand', emoji: '๐Ÿ›ก๏ธ', baseStat: { stat: 'hp', value: 15 }, tier: 1 },
      { id: 'boots', name: 'Boots', slot: 'boots', emoji: '๐Ÿ‘ข', baseStat: { stat: 'moveSpeed', value: 20 }, tier: 2 }
    ];

    const ABILITY_DEFS = {
      dagger:   { kind: 'toggle',     speedMult: 0.75, moveBackward: true, displayName: 'Sneak' },
      shield:   { kind: 'toggle',     speedMult: 0.2, reflectProjectiles: true, displayName: 'Reflect' },
      wand:     { kind: 'cooldown',   cooldown:  5, action: 'teleport',  displayName: 'Blink' },
      bow:      { kind: 'cooldown',   cooldown:  5, action: 'super_shot', sizeBonus: 5, pierceBonus: 10, displayName: 'Power Shot' },
      sword:    { kind: 'cooldown',   cooldown:  5, action: 'aoe',       baseRadius: 150, baseAoePct: 50, displayName: 'Cleave' },
      greataxe: { kind: 'continuous', action: 'whirlwind', moveTowardCursor: true, radius: 120, baseAoePct: 50, displayName: 'Whirlwind' }
    };
    const SHIELD_REFLECT_RADIUS = 80;

    const OBJECTIVE_DEFS = [
      { id: 'item',    name: 'Item',          color: '#ffd700' },
      { id: 'trader',  name: 'Scrap Trader',  color: '#c08040' },
      { id: 'mods_transfer', name: 'Mods Transfer', color: '#8b5cf6' },
      { id: 'merge',   name: 'Merge',         color: '#f59e0b' },
      { id: 'reforge', name: 'Reforge',       color: '#06b6d4' },
      { id: 'upgrade', name: 'Upgrade',       color: '#ec4899' },
      { id: 'heal',    name: 'Heal',          color: '#4ade80' },
      { id: 'magnet',  name: 'Magnet',        color: '#60a5fa' },
      { id: 'bomb',    name: 'Bomb',          color: '#ef4444' }
    ];
    const STATION_OBJECTIVE_IDS = ['trader', 'mods_transfer', 'merge', 'reforge', 'upgrade'];
    const OBJECTIVE_CATEGORY_IDS = ['station', 'bomb', 'heal', 'item', 'magnet'];

    const MODIFIER_DEFS = [
      { id: 'flat_hp', name: '+# HP', stat: 'hp', weight: 2, valueFn: g => Math.floor(6 + g * 5.0) },
      { id: 'flat_damage', name: '+# Damage', stat: 'damage', weight: 2, valueFn: g => Math.max(1, Math.floor(1 + g * 0.75)) },
      { id: 'pct_damage', name: '+#% Damage', stat: 'damagePercent', weight: 1, pct: true, valueFn: g => roundTo(3 + Math.sqrt(g) * 4, 1) },
      { id: 'fire_rate', name: '+# Fire Rate', stat: 'fireRate', weight: 2, float: true, valueFn: g => roundTo(0.03 + Math.sqrt(g) * 0.07, 2) },
      { id: 'pierce', name: '+# Pierce', stat: 'piercing', weight: 1, valueFn: g => Math.max(1, Math.floor(Math.sqrt(g) / 2.2)) },
      { id: 'aoe_radius', name: '+# AOE Radius', stat: 'aoeRadius', weight: 1, valueFn: g => Math.floor(8 + Math.sqrt(g) * 8) },
      { id: 'aoe_pct', name: '+#% AOE Damage', stat: 'aoePercent', weight: 1, pct: true, valueFn: g => roundTo(10 + Math.sqrt(g) * 6, 1) },
      { id: 'num_proj', name: '+# Projectiles', stat: 'numProjectiles', weight: 1, valueFn: g => Math.max(1, Math.floor(Math.sqrt(g) / 2.2)) },
      { id: 'knockback', name: '+# Knockback', stat: 'knockback', weight: 1, valueFn: g => Math.floor(10 + Math.sqrt(g) * 10) },
      { id: 'regen', name: '+# HP Regen', stat: 'hpRegen', weight: 2, float: true, valueFn: g => roundTo(0.1 + g * 0.07, 2) },
      { id: 'speed', name: '+# Move Speed', stat: 'moveSpeed', weight: 2, valueFn: g => Math.floor(5 + Math.sqrt(g) * 6) },
      { id: 'proj_size', name: '+# Projectile Size', stat: 'projectileSize', weight: 1, valueFn: g => Math.max(1, Math.floor(1 + Math.sqrt(g) * 0.65)) },
      { id: 'proj_speed', name: '+# Proj Speed', stat: 'projectileSpeed', weight: 2, valueFn: g => Math.floor(20 + Math.sqrt(g) * 20) }
    ];

    // ========== 4. UTILS ==========
    function dist(ax, ay, bx, by) {
      return Math.hypot(bx - ax, by - ay);
    }
    function normalize(x, y) {
      const d = Math.hypot(x, y);
      return d > 0 ? [x / d, y / d] : [1, 0];
    }
    function getMass(entity) {
      const s = entity.stats?.size ?? 10;
      return s * s;
    }
    function rand(min, max) {
      return min + Math.random() * (max - min);
    }
    function randInt(min, max) {
      return Math.floor(rand(min, max + 1));
    }
    function roundTo(value, places = 2) {
      const scale = Math.pow(10, places);
      return Math.round(value * scale) / scale;
    }
    function fmtNum(n) {
      const num = Number(n);
      if (num === Math.floor(num) && Math.abs(num) < 1e12) return String(Math.round(num));
      if (Math.abs(num) >= 1e6 || (Math.abs(num) < 0.01 && num !== 0)) return num.toExponential(2);
      return parseFloat(num.toFixed(2)).toString();
    }
    function weightedPick(arr, weightKey = 'weight') {
      const total = arr.reduce((s, x) => s + (x[weightKey] ?? 1), 0);
      let r = Math.random() * total;
      for (const x of arr) {
        r -= (x[weightKey] ?? 1);
        if (r <= 0) return x;
      }
      return arr[arr.length - 1];
    }
    function normalizeGoodness(raw) {
      return Math.tanh(raw / GOODNESS_SCALE);
    }
    function pointsToGoodness(points) {
      return Math.max(1, points ?? 1);
    }
    function objectiveTimeToGoodness(timeSeconds) {
      return 2 + Math.min(6, timeSeconds / 45);
    }
    function traderTimeToGoodness(timeSeconds) {
      return 4 + Math.min(8, timeSeconds / 30);
    }
    function rerollGoodnessForItem(item) {
      const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
      return Math.max(2, (base?.tier || 1) * 2 + getRarityIndex(item.rarity));
    }
    function upgradeGoodnessForItem(item, nextRarity) {
      const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
      return Math.max(3, (base?.tier || 1) * 2 + getRarityIndex(nextRarity) * 2);
    }
    function tierWeight(tier, n) {
      const unlock = (tier - 1) / 2;
      if (n < unlock * 0.6) return 0;
      const excess = n - unlock * 0.6;
      return Math.pow(excess + 0.05, 1.2);
    }
    function pickBaseFromTieredPool(n) {
      const pool = ITEM_BASE_DEFS
        .filter(b => b.tier >= 1 && b.slot != null)
        .map(b => ({ ...b, weight: tierWeight(b.tier, n) }))
        .filter(b => b.weight > 0);
      if (pool.length === 0) return null;
      return weightedPick(pool, 'weight');
    }

    // ========== 5. STATS ==========
    function computePlayerStats() {
      const s = { ...DEFAULT_STATS };
      for (const slot of EQUIP_SLOTS) {
        const item = player.equipment[slot];
        if (!item) continue;
        const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
        if (base?.baseStat) {
          const v = base.baseStat.value;
          s[base.baseStat.stat] = (s[base.baseStat.stat] ?? 0) + v;
        }
        for (const mod of item.modifiers || []) {
          const def = MODIFIER_DEFS.find(m => m.id === mod.modId);
          if (def) s[def.stat] = (s[def.stat] ?? 0) + mod.value;
        }
      }
      player.stats = s;
      player.maxHp = s.hp;
    }
    function computeStatsFromEquipment(equipment) {
      const s = { ...DEFAULT_STATS };
      for (const slot of EQUIP_SLOTS) {
        const item = equipment[slot];
        if (!item) continue;
        const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
        if (base?.baseStat) {
          const v = base.baseStat.value;
          s[base.baseStat.stat] = (s[base.baseStat.stat] ?? 0) + v;
        }
        for (const mod of item.modifiers || []) {
          const def = MODIFIER_DEFS.find(m => m.id === mod.modId);
          if (def) s[def.stat] = (s[def.stat] ?? 0) + mod.value;
        }
      }
      return s;
    }
    function getEquipTargetSlot(item) {
      const base = ITEM_BASE_DEFS.find(b => b.id === item?.baseId);
      if (!base?.slot) return null;
      if (HAND_SLOTS.includes(base.slot)) {
        return !player.equipment.leftHand ? 'leftHand' : !player.equipment.rightHand ? 'rightHand' : 'leftHand';
      }
      return base.slot;
    }

    // ========== 6. ITEM GENERATION ==========
    const RARITY_WEIGHTS = [
      { id: 'common', weight: 60 },
      { id: 'uncommon', weight: 30 },
      { id: 'rare', weight: 10 },
      { id: 'epic', weight: 2.5 },
      { id: 'legendary', weight: 0.625 }
    ];
    const RARITY_ORDER = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
    const RARITY_SCRAP_VALUE = { common: 1, uncommon: 2, rare: 4, epic: 8, legendary: 16 };
    function getRarityModCount(rarity) {
      return rarity === 'common' ? 0 : rarity === 'uncommon' ? 2 : rarity === 'rare' ? 4 : rarity === 'epic' ? 5 : 7;
    }
    function getRarityIndex(rarity) {
      return Math.max(0, RARITY_ORDER.indexOf(rarity || 'common'));
    }
    function getNextRarity(rarity) {
      const idx = getRarityIndex(rarity);
      return idx < RARITY_ORDER.length - 1 ? RARITY_ORDER[idx + 1] : null;
    }
    function getScrapValue(item) {
      return RARITY_SCRAP_VALUE[item?.rarity || 'common'] || 1;
    }
    function rollRarity(n) {
      let rarity = 'common';
      for (let i = 1; i < RARITY_ORDER.length; i++) {
        const next = RARITY_ORDER[i];
        if (!rollBoolWithPity(0.2, 'rarity_' + next, 5)) break;
        rarity = next;
      }
      return rarity;
    }
    function getModValue(def, goodness) {
      if (typeof def.valueFn !== 'function') return 0;
      return def.valueFn(Math.max(1, goodness ?? 1));
    }
    function generateItem(baseId, n, goodness, forcedRarity) {
      const base = ITEM_BASE_DEFS.find(b => b.id === baseId);
      if (!base) return null;
      const rarity = forcedRarity || rollRarity(n);
      const modCount = getRarityModCount(rarity);
      const modifiers = [];
      const used = new Set();
      for (let i = 0; i < modCount; i++) {
        const pool = MODIFIER_DEFS.filter(m => !used.has(m.id));
        if (pool.length === 0) break;
        const def = weightedPick(pool, 'weight');
        used.add(def.id);
        const v = getModValue(def, goodness);
        modifiers.push({ modId: def.id, value: v });
      }
      return {
        id: 'i' + Date.now() + randInt(0, 9999),
        baseId: base.id,
        name: base.name,
        slot: base.slot,
        emoji: base.emoji,
        rarity,
        baseStat: { ...base.baseStat },
        modifiers
      };
    }
    function cloneItem(item) {
      if (!item) return null;
      return {
        ...item,
        baseStat: item.baseStat ? { ...item.baseStat } : null,
        modifiers: (item.modifiers || []).map(mod => ({ ...mod }))
      };
    }
    function rollModifierSet(rarity, goodness = 4, excludeIds = []) {
      const count = getRarityModCount(rarity);
      const modifiers = [];
      const used = new Set(excludeIds);
      for (let i = 0; i < count; i++) {
        const pool = MODIFIER_DEFS.filter(m => !used.has(m.id));
        if (pool.length === 0) break;
        const def = weightedPick(pool, 'weight');
        used.add(def.id);
        modifiers.push({ modId: def.id, value: getModValue(def, goodness) });
      }
      return modifiers;
    }
    function rerollItemLike(item) {
      const goodness = rerollGoodnessForItem(item);
      return {
        ...cloneItem(item),
        modifiers: rollModifierSet(item.rarity, goodness)
      };
    }
    function upgradeItemLike(item) {
      const nextRarity = getNextRarity(item.rarity);
      if (!nextRarity) return null;
      const upgraded = cloneItem(item);
      upgraded.rarity = nextRarity;
      const goodness = upgradeGoodnessForItem(item, nextRarity);
      const targetCount = getRarityModCount(nextRarity);
      const used = new Set((upgraded.modifiers || []).map(m => m.modId));
      while ((upgraded.modifiers || []).length < targetCount) {
        const pool = MODIFIER_DEFS.filter(m => !used.has(m.id));
        if (pool.length === 0) break;
        const def = weightedPick(pool, 'weight');
        used.add(def.id);
        upgraded.modifiers.push({ modId: def.id, value: getModValue(def, goodness) });
      }
      return upgraded;
    }
    function mergeItemsLike(target, sacrifice) {
      const merged = cloneItem(target);
      const highestRarity = RARITY_ORDER[Math.max(getRarityIndex(target.rarity), getRarityIndex(sacrifice.rarity))];
      merged.rarity = highestRarity;
      const combined = new Map();
      for (const mod of [...(target.modifiers || []), ...(sacrifice.modifiers || [])]) {
        combined.set(mod.modId, (combined.get(mod.modId) || 0) + mod.value);
      }
      const pool = Array.from(combined.entries()).map(([modId, value]) => ({ modId, value }));
      for (let i = pool.length - 1; i > 0; i--) {
        const j = randInt(0, i);
        const tmp = pool[i];
        pool[i] = pool[j];
        pool[j] = tmp;
      }
      merged.modifiers = pool.slice(0, getRarityModCount(highestRarity)).map(mod => ({ ...mod }));
      return merged;
    }

    // ========== 7. SPAWNER ==========
    function getSpawnBudget() {
      return 1 + Math.floor(gameTime / SPAWN_STEP_SECONDS);  // gentle ramp: 1โ†’2โ†’3... every 15s
    }
    function getTopUnlockedEnemies(budget, count = 3) {
      const unlocked = ENEMY_DEFS.filter(e => e.points <= budget);
      const topTiers = [...new Set(unlocked.map(e => e.points))].sort((a, b) => b - a).slice(0, count);
      return unlocked.filter(e => topTiers.includes(e.points));
    }
    const SPAWN_PORTS = (() => {
      const ports = [];
      const segs = PORTS_PER_SIDE + 1;
      for (let i = 1; i <= PORTS_PER_SIDE; i++) {
        const t = i / segs;
        const along = -ARENA_HALF + ARENA_SIZE * t;
        ports.push({ side: 'top',    x: along,        y: -ARENA_HALF });
        ports.push({ side: 'bottom', x: along,        y:  ARENA_HALF });
        ports.push({ side: 'left',   x: -ARENA_HALF,  y: along });
        ports.push({ side: 'right',  x:  ARENA_HALF,  y: along });
      }
      return ports;
    })();
    function pickSpawnPosition(size) {
      const port = SPAWN_PORTS[randInt(0, SPAWN_PORTS.length - 1)];
      const inset = size + 4;
      let x = port.x, y = port.y;
      if (port.side === 'top')    y += inset;
      if (port.side === 'bottom') y -= inset;
      if (port.side === 'left')   x += inset;
      if (port.side === 'right')  x -= inset;
      return { x, y };
    }
    function runSpawner(dt) {
      spawnAccum += dt;
      if (spawnAccum < SPAWN_INTERVAL) return;
      spawnAccum -= SPAWN_INTERVAL;
      const candidates = getTopUnlockedEnemies(getSpawnBudget());
      if (candidates.length === 0) return;
      const def = weightedPick(candidates, 'weight');
      const size = def.baseStats.size ?? 14;
      const { x, y } = pickSpawnPosition(size);
      const enemy = {
        x, y, vx: 0, vy: 0, impulseVx: 0, impulseVy: 0,
        defId: def.id,
        def,
        hp: def.baseStats.hp ?? 20,
        maxHp: def.baseStats.hp ?? 20,
        stats: { ...def.baseStats },
        hue: def.hue,
        darkness: def.darkness,
        hitIds: new Set(),
        corpseHitIds: new Set()
      };
      if (def.ranged) enemy.fireCooldown = 0;
      if (def.summoner) enemy.summonCooldown = def.summoner.interval;
      enemies.push(enemy);
    }

    // ========== 8. PROJECTILES ==========
    const projectilePool = [];
    function getProjectile() {
      if (projectilePool.length) return projectilePool.pop();
      return { x: 0, y: 0, vx: 0, vy: 0, damage: 0, size: 4, piercing: 1, aoeRadius: 0, aoePercent: 0, knockback: 0, hitIds: new Set() };
    }
    function spawnProjectile(opts) {
      const sizeBonus = opts?.sizeBonus ?? 0;
      const pierceBonus = opts?.pierceBonus ?? 0;
      const s = player.stats;
      const count = Math.max(1, Math.floor(s.numProjectiles ?? 1));
      const spread = count > 1 ? 0.3 : 0;
      for (let i = 0; i < count; i++) {
        let dx = player.dirX, dy = player.dirY;
        if (count > 1) {
          const angle = Math.atan2(dy, dx) + (i - (count - 1) / 2) * spread;
          dx = Math.cos(angle);
          dy = Math.sin(angle);
        }
        const p = getProjectile();
        p.x = player.x + dx * (player.stats.size + 5);
        p.y = player.y + dy * (player.stats.size + 5);
        const speed = s.projectileSpeed ?? 400;
        p.vx = dx * speed;
        p.vy = dy * speed;
        const flatDmg = s.damage ?? 10;
        const pctMulti = 1 + (s.damagePercent ?? 0) / 100;
        p.damage = flatDmg * pctMulti;
        p.size = (s.projectileSize ?? 4) + sizeBonus;
        p.piercing = Math.max(1, Math.floor(s.piercing ?? 1)) + pierceBonus;
        p.aoeRadius = s.aoeRadius ?? 0;
        p.aoePercent = (s.aoePercent ?? 0) / 100;
        p.knockback = s.knockback ?? 0;
        p.hitIds.clear();
        p.enemy = false;
        projectiles.push(p);
      }
    }
    function spawnEnemyProjectile(enemy) {
      const def = enemy.def;
      if (!def?.rangedStats) return;
      const rs = def.rangedStats;
      const dx = player.x - enemy.x;
      const dy = player.y - enemy.y;
      const d = Math.hypot(dx, dy);
      if (d <= 0) return;
      const [baseDx, baseDy] = normalize(dx, dy);
      const count = Math.max(1, rs.numProjectiles ?? 1);
      const spread = rs.spread ?? 0;
      for (let i = 0; i < count; i++) {
        let ndx = baseDx, ndy = baseDy;
        if (count > 1 && spread > 0) {
          const angle = Math.atan2(baseDy, baseDx) + (i - (count - 1) / 2) * spread;
          ndx = Math.cos(angle);
          ndy = Math.sin(angle);
        }
        const p = getProjectile();
        p.x = enemy.x + ndx * (enemy.stats.size + 5);
        p.y = enemy.y + ndy * (enemy.stats.size + 5);
        const speed = rs.projSpeed ?? 350;
        p.vx = ndx * speed;
        p.vy = ndy * speed;
        p.damage = rs.projDamage ?? 8;
        p.size = rs.projSize ?? 5;
        p.piercing = 1;
        p.aoeRadius = 0;
        p.aoePercent = 0;
        p.knockback = 0;
        p.hitIds.clear();
        p.enemy = true;
        projectiles.push(p);
      }
    }

    // ========== 9. COLLISION & PBD PHYSICS ==========
    function circleOverlap(ax, ay, ar, bx, by, br) {
      return dist(ax, ay, bx, by) < ar + br;
    }
    function clampToArena(entity) {
      const r = entity.stats?.size ?? 8;
      const lo = -ARENA_HALF + r;
      const hi =  ARENA_HALF - r;
      if (entity.x < lo) entity.x = lo;
      if (entity.x > hi) entity.x = hi;
      if (entity.y < lo) entity.y = lo;
      if (entity.y > hi) entity.y = hi;
    }
    function isOutsideArena(x, y) {
      return x < -ARENA_HALF || x > ARENA_HALF || y < -ARENA_HALF || y > ARENA_HALF;
    }
    function clampPointToArena(x, y, r = 0) {
      const lo = -ARENA_HALF + r;
      const hi = ARENA_HALF - r;
      return {
        x: Math.max(lo, Math.min(hi, x)),
        y: Math.max(lo, Math.min(hi, y))
      };
    }
    function resolveCircleCollision(a, b, ar, br) {
      const d = dist(a.x, a.y, b.x, b.y);
      if (d <= 0) return;
      const overlap = ar + br - d;
      if (overlap <= 0) return;
      const nx = (a.x - b.x) / d;
      const ny = (a.y - b.y) / d;
      const ma = getMass(a);
      const mb = getMass(b);
      const total = ma + mb;
      a.x += nx * overlap * (mb / total);
      a.y += ny * overlap * (mb / total);
      b.x -= nx * overlap * (ma / total);
      b.y -= ny * overlap * (ma / total);
    }
    function addImpulse(entity, vx, vy) {
      entity.impulseVx = (entity.impulseVx ?? 0) + vx;
      entity.impulseVy = (entity.impulseVy ?? 0) + vy;
    }
    function dampImpulse(entity, dt) {
      const keep = Math.pow(IMPULSE_DAMPING_PER_SECOND, dt);
      entity.impulseVx = (entity.impulseVx ?? 0) * keep;
      entity.impulseVy = (entity.impulseVy ?? 0) * keep;
    }
    function updatePhysicsPBD(dt) {
      const pr = player.stats.size ?? 16;
      const pxPrev = player.x, pyPrev = player.y;
      const ePrev = enemies.map(e => ({ x: e.x, y: e.y }));

      const RANGED_RANGE_MIN = 150, RANGED_RANGE_MAX = 250;
      for (const e of enemies) {
        if (e.dead) {
          e.vx = 0;
          e.vy = 0;
          dampImpulse(e, dt);
          continue;
        }
        const def = e.def;
        const dx = player.x - e.x, dy = player.y - e.y;
        const d = Math.hypot(dx, dy);
        if (d > 0) {
          const [nx, ny] = normalize(dx, dy);
          let vx = nx * (e.stats.moveSpeed ?? 80);
          let vy = ny * (e.stats.moveSpeed ?? 80);
          if (def?.ranged) {
            if (d < RANGED_RANGE_MIN) {
              vx = -nx * (e.stats.moveSpeed ?? 80);
              vy = -ny * (e.stats.moveSpeed ?? 80);
            } else if (d > RANGED_RANGE_MAX) {
              vx = nx * (e.stats.moveSpeed ?? 80);
              vy = ny * (e.stats.moveSpeed ?? 80);
            } else {
              vx = vy = 0;
            }
          }
          e.vx = vx;
          e.vy = vy;
        }
        dampImpulse(e, dt);
      }
      dampImpulse(player, dt);
      player.x += (player.vx + (player.impulseVx ?? 0)) * dt;
      player.y += (player.vy + (player.impulseVy ?? 0)) * dt;
      clampToArena(player);
      for (let i = 0; i < enemies.length; i++) {
        enemies[i].x += (enemies[i].vx + (enemies[i].impulseVx ?? 0)) * dt;
        enemies[i].y += (enemies[i].vy + (enemies[i].impulseVy ?? 0)) * dt;
        clampToArena(enemies[i]);
      }

      const meleeImpulses = [];
      const meleeDone = new Set();
      const cellSize = CELL_SIZE;
      const getCell = (x, y) => Math.floor(x / cellSize) + 1000 * Math.floor(y / cellSize);
      let grid = {};
      for (let iter = 0; iter < PBD_ITERATIONS; iter++) {
        grid = {};
        for (let i = 0; i < enemies.length; i++) {
          const e = enemies[i];
          const c = getCell(e.x, e.y);
          if (!grid[c]) grid[c] = [];
          grid[c].push({ e, i });
        }
        for (const e of enemies) {
          if (e.dead) continue;
          const er = e.stats.size ?? 10;
          if (circleOverlap(player.x, player.y, pr, e.x, e.y, er)) {
            resolveCircleCollision(player, e, pr, er);
            if (!meleeDone.has(e) && player.hitInvuln <= 0 && (e.stats.contactDamage ?? 0) > 0) {
              player.hp -= (e.stats.contactDamage ?? 0);
              player.hitInvuln = 0.4;
              meleeDone.add(e);
              const d = dist(player.x, player.y, e.x, e.y);
              if (d > 0) meleeImpulses.push({ e, nx: (player.x - e.x) / d, ny: (player.y - e.y) / d });
            }
          }
        }
        for (let i = 0; i < enemies.length; i++) {
          const a = enemies[i];
          const ar = a.stats.size ?? 10;
          const ac = getCell(a.x, a.y);
          const seen = new Set();
          for (const dy of [-1000, 0, 1000]) {
            for (const dx of [-1, 0, 1]) {
              const cc = ac + dx + dy;
              if (!grid[cc]) continue;
              for (const { e: b, i: bIdx } of grid[cc]) {
                if (b === a || seen.has(b)) continue;
                if (bIdx <= i) continue;
                seen.add(b);
                const br = b.stats.size ?? 10;
                if (circleOverlap(a.x, a.y, ar, b.x, b.y, br)) {
                  resolveCircleCollision(a, b, ar, br);
                }
              }
            }
          }
        }
      }

      player.vx = (player.x - pxPrev) / dt;
      player.vy = (player.y - pyPrev) / dt;
      for (let i = 0; i < enemies.length; i++) {
        const e = enemies[i];
        const p = ePrev[i];
        if (e.dead) {
          e.vx = 0;
          e.vy = 0;
        } else {
          e.vx = (e.x - p.x) / dt;
          e.vy = (e.y - p.y) / dt;
        }
      }
      for (const { e, nx, ny } of meleeImpulses) {
        const mp = getMass(player);
        const me = getMass(e);
        addImpulse(player, (nx * MELEE_IMPULSE) / mp, (ny * MELEE_IMPULSE) / mp);
        addImpulse(e, -(nx * MELEE_IMPULSE) / me, -(ny * MELEE_IMPULSE) / me);
      }
      if (player.hitInvuln > 0) player.hitInvuln -= dt;
      return { grid, getCell, cellSize };
    }
    function spawnSummonedEnemy(summoner, summonDefId) {
      const def = ENEMY_DEFS.find(d => d.id === summonDefId);
      if (!def) return;
      const offset = 25 + (summoner.stats.size ?? 10);
      const angle = Math.random() * Math.PI * 2;
      const x = summoner.x + Math.cos(angle) * offset;
      const y = summoner.y + Math.sin(angle) * offset;
      const e = {
        x, y, vx: 0, vy: 0, impulseVx: 0, impulseVy: 0,
        defId: def.id,
        def,
        hp: def.baseStats.hp ?? 20,
        maxHp: def.baseStats.hp ?? 20,
        stats: { ...def.baseStats },
        hue: def.hue,
        darkness: def.darkness,
        hitIds: new Set(),
        corpseHitIds: new Set()
      };
      if (def.ranged) e.fireCooldown = 0;
      if (def.summoner) e.summonCooldown = def.summoner.interval;
      enemies.push(e);
    }
    function updateSummoners(dt) {
      for (const e of enemies) {
        if (e.dead) continue;
        const def = e.def;
        const sum = def?.summoner;
        if (!sum) continue;
        if (e.summonCooldown == null) e.summonCooldown = sum.interval;
        e.summonCooldown -= dt;
        if (e.summonCooldown <= 0) {
          e.summonCooldown = sum.interval;
          for (let i = 0; i < (sum.count ?? 3); i++) spawnSummonedEnemy(e, sum.summonDefId);
        }
      }
    }
    const RANGED_FIRE_RANGE = 500;
    function updateRangedEnemies(dt) {
      for (const e of enemies) {
        if (e.dead) continue;
        const def = e.def;
        if (!def?.ranged || !def.rangedStats) continue;
        if (e.fireCooldown != null) e.fireCooldown -= dt;
        if ((e.fireCooldown ?? 0) > 0) continue;
        const d = dist(player.x, player.y, e.x, e.y);
        if (d > RANGED_FIRE_RANGE) continue;
        spawnEnemyProjectile(e);
        e.fireCooldown = 1 / (def.rangedStats.fireRate ?? 1);
      }
    }
    function dropLootForEnemy(e) {
      const def = e.def;
      if (!def) return;
      const backstop = Math.min(20, 3 + dropsReceived);
      if (rollBoolWithPity(0.04, 'enemy_drop', backstop)) {
        const goodness = pointsToGoodness(def.points);
        const n = normalizeGoodness(goodness);
        const base = pickBaseFromTieredPool(n);
        if (base) {
          const item = generateItem(base.id, n, goodness);
          if (item) {
            itemDrops.push({ x: e.x, y: e.y, item, pickupRadius: 24 });
            dropsReceived++;
          }
        }
      }
    }
    function killEnemy(e) {
      if (!e || e.dead) return;
      dropLootForEnemy(e);
      e.dead = true;
      e.deathAge = 0;
      e.deathMaxAge = CORPSE_FADE_SECONDS;
      e.stats.contactDamage = 0;
      e.fireCooldown = null;
      e.summonCooldown = null;
      e.corpseHitIds = new Set();
    }
    function applyCorpseImpacts(corpse) {
      if (!corpse.dead) return;
      const speed = Math.hypot(
        (corpse.vx || 0) + (corpse.impulseVx || 0),
        (corpse.vy || 0) + (corpse.impulseVy || 0)
      );
      if (speed < CORPSE_IMPACT_MIN_SPEED) return;
      const cr = corpse.stats.size ?? 10;
      for (const e of enemies) {
        if (e === corpse || e.dead || corpse.corpseHitIds?.has(e)) continue;
        const er = e.stats.size ?? 10;
        if (!circleOverlap(corpse.x, corpse.y, cr, e.x, e.y, er)) continue;
        corpse.corpseHitIds.add(e);
        const [nx, ny] = normalize(e.x - corpse.x, e.y - corpse.y);
        addImpulse(e, nx * speed * CORPSE_IMPACT_IMPULSE_FACTOR, ny * speed * CORPSE_IMPACT_IMPULSE_FACTOR);
        addImpulse(corpse, -nx * speed * 0.12, -ny * speed * 0.12);
      }
    }
    function updateCollisions(dt) {
      const { grid, getCell, cellSize } = updatePhysicsPBD(dt);

      const pr = player.stats.size ?? 16;
      for (let i = projectiles.length - 1; i >= 0; i--) {
        const p = projectiles[i];
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        if (isOutsideArena(p.x, p.y)) {
          projectiles.splice(i, 1);
          delete p.enemy;
          projectilePool.push(p);
          continue;
        }
        if (p.enemy) {
          if (player.hitInvuln <= 0 && circleOverlap(p.x, p.y, p.size, player.x, player.y, pr)) {
            player.hp -= p.damage;
            player.hitInvuln = 0.8;
            projectiles.splice(i, 1);
            delete p.enemy;
            projectilePool.push(p);
          }
          continue;
        }
        const c = getCell(p.x, p.y);
        const nearby = new Set();
        for (const dy of [-1000, 0, 1000]) {
          for (const dx of [-1, 0, 1]) {
            const cc = c + dx + dy;
            if (grid[cc]) for (const { e } of grid[cc]) nearby.add(e);
          }
        }
        for (const e of nearby) {
          if (e.dead) continue;
          if (p.hitIds.has(e)) continue;
          if (!circleOverlap(p.x, p.y, p.size, e.x, e.y, e.stats.size ?? 10)) continue;
          p.hitIds.add(e);
          e.hp -= p.damage;
          if (p.aoeRadius > 0) {
            const r = p.aoeRadius;
            const minCx = Math.floor((p.x - r) / cellSize);
            const maxCx = Math.floor((p.x + r) / cellSize);
            const minCy = Math.floor((p.y - r) / cellSize);
            const maxCy = Math.floor((p.y + r) / cellSize);
            for (let cy = minCy; cy <= maxCy; cy++) {
              for (let cx = minCx; cx <= maxCx; cx++) {
                const cc = cx + 1000 * cy;
                if (!grid[cc]) continue;
                for (const { e: o } of grid[cc]) {
                  if (o === e || o.dead) continue;
                  const d = dist(p.x, p.y, o.x, o.y);
                  if (d < r) o.hp -= p.damage * p.aoePercent;
                }
              }
            }
          }
          if (p.knockback > 0) {
            const [nx, ny] = normalize(e.x - p.x, e.y - p.y);
            addImpulse(
              e,
              nx * p.knockback * KNOCKBACK_VEL_FACTOR,
              ny * p.knockback * KNOCKBACK_VEL_FACTOR
            );
          }
          p.piercing--;
          if (p.piercing <= 0) break;
        }
        if (p.piercing <= 0) {
          projectiles.splice(i, 1);
          delete p.enemy;
          projectilePool.push(p);
        }
      }

      for (let i = enemies.length - 1; i >= 0; i--) {
        const e = enemies[i];
        if (e.hp <= 0 && !e.dead) killEnemy(e);
        if (e.dead) {
          e.deathAge += dt;
          applyCorpseImpacts(e);
          if (e.deathAge >= (e.deathMaxAge || CORPSE_FADE_SECONDS)) {
            enemies.splice(i, 1);
          }
        }
      }

      for (let i = itemDrops.length - 1; i >= 0; i--) {
        const d = itemDrops[i];
        if (d.targetX != null) {
          const dx = d.targetX - d.x;
          const dy = d.targetY - d.y;
          const dd = Math.hypot(dx, dy);
          if (dd <= 1) {
            d.x = d.targetX;
            d.y = d.targetY;
            d.targetX = d.targetY = null;
          } else {
            const step = Math.min(dd, MAGNET_PULL_SPEED * dt);
            d.x += (dx / dd) * step;
            d.y += (dy / dd) * step;
          }
        }
        if (circleOverlap(player.x, player.y, player.stats.size + 10, d.x, d.y, d.pickupRadius)) {
          const free = inventory.findIndex(x => !x);
          if (free >= 0) {
            inventory[free] = d.item;
            itemDrops.splice(i, 1);
          }
        }
      }
    }

    // ========== 9a. WEAPON ABILITIES ==========
    function getEquippedAbility(slot) {
      const item = player.equipment[slot];
      if (!item) return null;
      return ABILITY_DEFS[item.baseId] || null;
    }
    function getPrimaryAbility() {
      return getEquippedAbility(PRIMARY_HAND_SLOT);
    }
    function getAbilityInfo(item, slot) {
      if (!item) return null;
      const ability = ABILITY_DEFS[item.baseId];
      if (!ability) return null;
      const slotName = slot === PRIMARY_HAND_SLOT ? 'Primary' : slot === SECONDARY_HAND_SLOT ? 'Secondary' : slot;
      return {
        ability,
        slot,
        slotName,
        name: ability.displayName || item.name,
        itemName: item.name,
        inactiveText: slot === SECONDARY_HAND_SLOT ? '(inactive - move to primary)' : ''
      };
    }
    function abilityDamage(baseAoePct) {
      const flat = player.stats.damage ?? 0;
      const pctMulti = 1 + (player.stats.damagePercent ?? 0) / 100;
      const aoeMult = baseAoePct / 100 + (player.stats.aoePercent ?? 0) / 100;
      return flat * pctMulti * aoeMult;
    }
    function abilityRadius(baseRadius) {
      return baseRadius + (player.stats.aoeRadius ?? 0);
    }
    function fireCooldownAbility(ab) {
      if (ab.action === 'aoe') {
        const r = abilityRadius(ab.baseRadius);
        const dmg = abilityDamage(ab.baseAoePct);
        for (const e of enemies) {
          if (!e.dead && dist(player.x, player.y, e.x, e.y) <= r) e.hp -= dmg;
        }
        abilityVisuals.push({ kind: 'aoe', x: player.x, y: player.y, radius: r, age: 0, maxAge: 0.48, color: '#ffd54a' });
        spawnAbilityParticles(player.x, player.y, '#ffd54a', { count: 28, speedMin: 110, speedMax: 320, radiusMin: 5, radiusMax: 11 });
        screenShake = Math.max(screenShake, 9);
      } else if (ab.action === 'teleport') {
        const fromX = player.x;
        const fromY = player.y;
        const target = clampPointToArena(mouseWorldX, mouseWorldY, player.stats.size ?? 16);
        player.x = target.x;
        player.y = target.y;
        player.vx = 0;
        player.vy = 0;
        player.impulseVx = 0;
        player.impulseVy = 0;
        abilityVisuals.push({ kind: 'aoe', x: fromX, y: fromY, radius: 70, age: 0, maxAge: 0.28, color: '#ffd54a' });
        abilityVisuals.push({ kind: 'aoe', x: player.x, y: player.y, radius: 90, age: 0, maxAge: 0.36, color: '#ffd54a' });
        spawnAbilityParticles(fromX, fromY, '#ffd54a', { count: 16, speedMin: 70, speedMax: 180, radiusMin: 3, radiusMax: 7 });
        spawnAbilityParticles(player.x, player.y, '#ffd54a', { count: 24, speedMin: 100, speedMax: 260, radiusMin: 4, radiusMax: 9 });
        screenShake = Math.max(screenShake, 6);
      } else if (ab.action === 'super_shot') {
        spawnProjectile({ sizeBonus: ab.sizeBonus, pierceBonus: ab.pierceBonus });
        abilityVisuals.push({ kind: 'flash', x: player.x, y: player.y, dirX: player.dirX, dirY: player.dirY, age: 0, maxAge: 0.22, color: '#ffd54a' });
        spawnAbilityParticles(player.x + player.dirX * 28, player.y + player.dirY * 28, '#ffd54a', {
          count: 16, speedMin: 120, speedMax: 260, radiusMin: 4, radiusMax: 8, dir: Math.atan2(player.dirY, player.dirX), spread: 0.8
        });
        screenShake = Math.max(screenShake, 6);
      } else if (ab.action === 'arc') {
        const range = abilityRadius(ab.range);
        const dmg = abilityDamage(ab.baseAoePct);
        const half = ab.arcAngle / 2;
        const facing = Math.atan2(player.dirY, player.dirX);
        for (const e of enemies) {
          if (e.dead) continue;
          const dx = e.x - player.x, dy = e.y - player.y;
          const d = Math.hypot(dx, dy);
          if (d > range + (e.stats.size ?? 10)) continue;
          let delta = Math.atan2(dy, dx) - facing;
          while (delta >  Math.PI) delta -= Math.PI * 2;
          while (delta < -Math.PI) delta += Math.PI * 2;
          if (Math.abs(delta) <= half) e.hp -= dmg;
        }
        abilityVisuals.push({ kind: 'arc', x: player.x, y: player.y, dirX: player.dirX, dirY: player.dirY, range, halfArc: half, age: 0, maxAge: 0.34, color: '#fff1a8' });
        spawnAbilityParticles(player.x + player.dirX * (range * 0.45), player.y + player.dirY * (range * 0.45), '#fff1a8', {
          count: 20, speedMin: 90, speedMax: 240, radiusMin: 4, radiusMax: 9, dir: facing, spread: ab.arcAngle
        });
        screenShake = Math.max(screenShake, 7);
      }
    }
    function applyContinuousAbility(ab, dt) {
      if (ab.action === 'whirlwind') {
        const r = abilityRadius(ab.radius);
        const dps = abilityDamage(ab.baseAoePct);
        for (const e of enemies) {
          if (!e.dead && dist(player.x, player.y, e.x, e.y) <= r + (e.stats.size ?? 10)) e.hp -= dps * dt;
        }
        abilityVisuals.push({ kind: 'whirlwind', x: player.x, y: player.y, radius: r, age: 0, maxAge: 0.08, color: '#ff9a3c' });
        if (Math.random() < dt * 24) {
          const angle = Math.random() * Math.PI * 2;
          spawnAbilityParticles(player.x + Math.cos(angle) * r * 0.65, player.y + Math.sin(angle) * r * 0.65, '#ff9a3c', {
            count: 4, speedMin: 40, speedMax: 120, radiusMin: 3, radiusMax: 6, dir: angle + Math.PI / 2, spread: 0.9
          });
        }
        screenShake = Math.max(screenShake, 2.2);
      }
    }
    function applyShieldReflect() {
      for (const p of projectiles) {
        if (!p.enemy) continue;
        const dx = p.x - player.x, dy = p.y - player.y;
        const d = Math.hypot(dx, dy);
        if (d > SHIELD_REFLECT_RADIUS) continue;
        const speed = Math.hypot(p.vx, p.vy);
        if (d > 0) {
          p.vx = (dx / d) * speed;
          p.vy = (dy / d) * speed;
        }
        p.enemy = false;
        p.hitIds.clear();
      }
    }
    function tickAbilities(dt) {
      for (const slot of HAND_SLOTS) {
        if (player.abilityCooldowns[slot] > 0) player.abilityCooldowns[slot] -= dt;
      }
      for (let i = abilityVisuals.length - 1; i >= 0; i--) {
        abilityVisuals[i].age += dt;
        if (abilityVisuals[i].age >= abilityVisuals[i].maxAge) abilityVisuals.splice(i, 1);
      }
      if (!mouseRmbDown) return;
      const slot = PRIMARY_HAND_SLOT;
      const ab = getEquippedAbility(slot);
      if (!ab) return;
      if (ab.kind === 'cooldown' && player.abilityCooldowns[slot] <= 0) {
        fireCooldownAbility(ab);
        player.abilityCooldowns[slot] = ab.cooldown;
      } else if (ab.kind === 'continuous') {
        applyContinuousAbility(ab, dt);
      } else if (ab.kind === 'toggle' && ab.reflectProjectiles) {
        applyShieldReflect();
      }
    }

    // ========== 9b. MAP OBJECTIVES ==========
    function objectiveOverlapsAny(x, y, r) {
      const minSep = r * 2 + 40;
      for (const o of mapObjectives) {
        if (dist(x, y, o.x, o.y) < minSep) return true;
      }
      return false;
    }
    function pickObjectivePosition() {
      const margin = OBJECTIVE_RADIUS + 80;
      const lo = -ARENA_HALF + margin;
      const hi =  ARENA_HALF - margin;
      const portClearance = OBJECTIVE_RADIUS + 120;
      for (let attempt = 0; attempt < 200; attempt++) {
        const x = rand(lo, hi);
        const y = rand(lo, hi);
        if (dist(x, y, player.x, player.y) < OBJECTIVE_RADIUS + 100) continue;
        if (objectiveOverlapsAny(x, y, OBJECTIVE_RADIUS)) continue;
        let nearPort = false;
        for (const p of SPAWN_PORTS) {
          if (dist(x, y, p.x, p.y) < portClearance) { nearPort = true; break; }
        }
        if (nearPort) continue;
        return { x, y };
      }
      return null;
    }
    function spawnMapObjective() {
      const pos = pickObjectivePosition();
      if (!pos) return false;
      const def = pickObjectiveDef();
      if (!def) return false;
      mapObjectives.push({
        x: pos.x, y: pos.y,
        defId: def.id,
        def,
        captureProgress: 0
      });
      return true;
    }
    function getObjectiveCategoryId(defId) {
      return STATION_OBJECTIVE_IDS.includes(defId) ? 'station' : defId;
    }
    function pickObjectiveDef() {
      const usedCategories = new Set(mapObjectives.map(o => getObjectiveCategoryId(o.defId)));
      const availableCategories = OBJECTIVE_CATEGORY_IDS.filter(id => !usedCategories.has(id));
      if (availableCategories.length === 0) return null;
      const categoryId = availableCategories[randInt(0, availableCategories.length - 1)];
      if (categoryId === 'station') {
        const stationPool = OBJECTIVE_DEFS.filter(d => STATION_OBJECTIVE_IDS.includes(d.id));
        return stationPool[randInt(0, stationPool.length - 1)];
      }
      return OBJECTIVE_DEFS.find(d => d.id === categoryId) || null;
    }
    function spawnInitialObjectives() {
      for (let i = 0; i < NUM_OBJECTIVES; i++) spawnMapObjective();
    }
    function updateMapObjectives(dt) {
      while (mapObjectives.length < NUM_OBJECTIVES) {
        if (!spawnMapObjective()) break;
      }
      for (let i = mapObjectives.length - 1; i >= 0; i--) {
        const o = mapObjectives[i];
        const inside = dist(player.x, player.y, o.x, o.y) <= OBJECTIVE_RADIUS;
        if (inside) {
          o.captureProgress += dt;
          if (o.captureProgress >= CAPTURE_TIME) {
            triggerObjective(o);
            mapObjectives.splice(i, 1);
          }
        } else if (o.captureProgress > 0) {
          o.captureProgress = Math.max(0, o.captureProgress - dt * 0.5);
        }
      }
    }
    function rollObjectiveItemRarity() {
      return weightedPick([
        { id: 'uncommon', weight: 70 },
        { id: 'rare',     weight: 25 },
        { id: 'epic',     weight: 5 }
      ], 'weight').id;
    }
    function spawnObjectiveItem(x, y) {
      const goodness = objectiveTimeToGoodness(gameTime);
      const n = normalizeGoodness(goodness);
      const base = pickBaseFromTieredPool(n);
      if (!base) return;
      const rarity = rollObjectiveItemRarity();
      const item = generateItem(base.id, n, goodness, rarity);
      if (item) itemDrops.push({ x, y, item, pickupRadius: 24 });
    }
    function applyMagnet() {
      for (const d of itemDrops) {
        d.targetX = player.x;
        d.targetY = player.y;
      }
    }
    function applyBomb(x, y) {
      for (const e of enemies) {
        if (!e.dead && e.hp > 0 && dist(x, y, e.x, e.y) <= BOMB_RADIUS) {
          spawnAbilityParticles(e.x, e.y, '#ef4444', { count: 10, speedMin: 60, speedMax: 210, radiusMin: 3, radiusMax: 8 });
          e.hp = 0;
        }
      }
      abilityVisuals.push({ kind: 'aoe', x, y, radius: BOMB_RADIUS, age: 0, maxAge: 0.58, color: '#ef4444' });
      spawnAbilityParticles(x, y, '#ef4444', { count: 42, speedMin: 160, speedMax: 520, radiusMin: 6, radiusMax: 14 });
      screenShake = Math.max(screenShake, 18);
    }
    function hexToRgba(hex, alpha) {
      const clean = hex.replace('#', '');
      const value = parseInt(clean, 16);
      const r = (value >> 16) & 255;
      const g = (value >> 8) & 255;
      const b = value & 255;
      return `rgba(${r}, ${g}, ${b}, ${alpha})`;
    }
    function spawnCaptureParticles(x, y, color) {
      for (let i = 0; i < 32; i++) {
        const angle = Math.random() * Math.PI * 2;
        const speed = 80 + Math.random() * 260;
        worldParticles.push({
          x,
          y,
          vx: Math.cos(angle) * speed,
          vy: Math.sin(angle) * speed,
          radius: 4 + Math.random() * 8,
          color,
          age: 0,
          maxAge: 0.45 + Math.random() * 0.35
        });
      }
      for (let i = 0; i < 10; i++) {
        const angle = Math.random() * Math.PI * 2;
        const speed = 40 + Math.random() * 120;
        worldParticles.push({
          x: x + Math.cos(angle) * (OBJECTIVE_RADIUS * 0.4),
          y: y + Math.sin(angle) * (OBJECTIVE_RADIUS * 0.4),
          vx: Math.cos(angle) * speed,
          vy: Math.sin(angle) * speed - 30,
          radius: 10 + Math.random() * 12,
          color,
          age: 0,
          maxAge: 0.3 + Math.random() * 0.2
        });
      }
    }
    function spawnAbilityParticles(x, y, color, opts = {}) {
      const count = opts.count ?? 18;
      const speedMin = opts.speedMin ?? 90;
      const speedMax = opts.speedMax ?? 220;
      const radiusMin = opts.radiusMin ?? 3;
      const radiusMax = opts.radiusMax ?? 8;
      const spread = opts.spread ?? (Math.PI * 2);
      const dir = opts.dir ?? 0;
      for (let i = 0; i < count; i++) {
        const angle = dir + (Math.random() - 0.5) * spread;
        const speed = speedMin + Math.random() * (speedMax - speedMin);
        worldParticles.push({
          x,
          y,
          vx: Math.cos(angle) * speed,
          vy: Math.sin(angle) * speed,
          radius: radiusMin + Math.random() * (radiusMax - radiusMin),
          color,
          age: 0,
          maxAge: 0.2 + Math.random() * 0.22
        });
      }
    }
    function triggerCaptureFeedback(o) {
      spawnCaptureParticles(o.x, o.y, o.def.color);
      screenShake = Math.max(screenShake, 14);
    }
    function triggerObjective(o) {
      triggerCaptureFeedback(o);
      const id = o.defId;
      if (id === 'item') {
        spawnObjectiveItem(o.x, o.y);
      } else if (id === 'heal') {
        player.hp = player.maxHp;
      } else if (id === 'magnet') {
        applyMagnet();
      } else if (id === 'bomb') {
        applyBomb(o.x, o.y);
      } else if (id === 'trader') {
        openTrader(o);
      } else if (id === 'mods_transfer') {
        openStation(makeModsTransferStation(o));
      } else if (id === 'merge') {
        openStation(makeMergeStation(o));
      } else if (id === 'reforge') {
        openStation(makeReforgeStation(o));
      } else if (id === 'upgrade') {
        openStation(makeUpgradeStation(o));
      }
    }

    // ========== 10. RENDER ==========
    function seeded(x, y, s) {
      const n = Math.sin(x * 12.9898 + y * 78.233 + (s || 0)) * 43758.5453;
      return n - Math.floor(n);
    }
    function warp(gx, gy, scale, seed) {
      return (seeded(gx * scale, gy * scale, seed) - 0.5) * 2;
    }
    function drawBackgroundDecor(camX, camY, margin) {
      const GRID = 280;
      const JITTER = GRID * 0.1;
      const warpAmt = GRID * 0.4;
      const minGx = Math.floor((camX - margin) / GRID);
      const maxGx = Math.ceil((camX + canvas.width + margin) / GRID);
      const minGy = Math.floor((camY - margin) / GRID);
      const maxGy = Math.ceil((camY + canvas.height + margin) / GRID);
      for (let gx = minGx; gx <= maxGx; gx++) {
        for (let gy = minGy; gy <= maxGy; gy++) {
          const wx = gx * GRID + GRID / 2
            + warp(gx, gy, 0.2, 1) * warpAmt
            + (seeded(gx, gy, 3) - 0.5) * 2 * JITTER;
          const wy = gy * GRID + GRID / 2
            + warp(gx, gy, 0.2, 2) * warpAmt
            + (seeded(gx, gy, 4) - 0.5) * 2 * JITTER;
          if (wx < -ARENA_HALF || wx > ARENA_HALF || wy < -ARENA_HALF || wy > ARENA_HALF) continue;
          const r = seeded(gx, gy, 5);
          if (r < 0.75) {
            ctx.fillStyle = '#2d5016';
            for (let b = 0; b < 3; b++) {
              const splay = (b - 1) * 0.35;
              const h = 14;
              const lean = Math.sin(splay) * h * 0.4;
              const baseX = wx + (b - 1) * 2;
              const tipX = baseX + lean;
              const tipY = wy - h;
              const baseW = 1.2;
              ctx.beginPath();
              ctx.moveTo(baseX - baseW, wy);
              ctx.lineTo(tipX, tipY);
              ctx.lineTo(baseX + baseW, wy);
              ctx.closePath();
              ctx.fill();
            }
          } else {
            const w = 20;
            const h = 6;
            ctx.fillStyle = '#4a4a4a';
            ctx.beginPath();
            ctx.moveTo(wx - w / 2, wy);
            ctx.lineTo(wx - w * 0.2, wy - h * 0.65);
            ctx.lineTo(wx + w * 0.15, wy - h);
            ctx.lineTo(wx + w / 2, wy);
            ctx.closePath();
            ctx.fill();
          }
        }
      }
    }
    function drawGround() {
      ctx.fillStyle = '#1a2a18';
      ctx.fillRect(-10000, -10000, 20000, 20000);
      ctx.fillStyle = '#3d6b2f';
      ctx.fillRect(-ARENA_HALF, -ARENA_HALF, ARENA_SIZE, ARENA_SIZE);
    }
    function drawArenaWalls() {
      ctx.fillStyle = '#5a4a3a';
      ctx.strokeStyle = '#1c1410';
      ctx.lineWidth = 2;
      const t = WALL_THICKNESS;
      const half = ARENA_HALF;
      const segs = PORTS_PER_SIDE + 1;
      const drawHorizSegments = (y) => {
        const cuts = [];
        cuts.push(-half);
        for (let i = 1; i <= PORTS_PER_SIDE; i++) {
          const px = -half + ARENA_SIZE * (i / segs);
          cuts.push(px - HOLE_WIDTH / 2);
          cuts.push(px + HOLE_WIDTH / 2);
        }
        cuts.push(half);
        for (let i = 0; i < cuts.length; i += 2) {
          const x0 = cuts[i], x1 = cuts[i + 1];
          ctx.fillRect(x0, y, x1 - x0, t);
          ctx.strokeRect(x0, y, x1 - x0, t);
        }
      };
      const drawVertSegments = (x) => {
        const cuts = [];
        cuts.push(-half);
        for (let i = 1; i <= PORTS_PER_SIDE; i++) {
          const py = -half + ARENA_SIZE * (i / segs);
          cuts.push(py - HOLE_WIDTH / 2);
          cuts.push(py + HOLE_WIDTH / 2);
        }
        cuts.push(half);
        for (let i = 0; i < cuts.length; i += 2) {
          const y0 = cuts[i], y1 = cuts[i + 1];
          ctx.fillRect(x, y0, t, y1 - y0);
          ctx.strokeRect(x, y0, t, y1 - y0);
        }
      };
      drawHorizSegments(-half - t);
      drawHorizSegments(half);
      drawVertSegments(-half - t);
      drawVertSegments(half);
    }
    function drawMapObjectives() {
      ctx.lineWidth = 4;
      ctx.font = 'bold 16px monospace';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      for (const o of mapObjectives) {
        ctx.strokeStyle = o.def.color;
        ctx.fillStyle = o.def.color;
        ctx.beginPath();
        ctx.arc(o.x, o.y, OBJECTIVE_RADIUS, 0, Math.PI * 2);
        ctx.stroke();
        if (o.captureProgress > 0) {
          const frac = Math.min(1, o.captureProgress / CAPTURE_TIME);
          ctx.lineWidth = 8;
          ctx.beginPath();
          ctx.arc(o.x, o.y, OBJECTIVE_RADIUS - 6, -Math.PI / 2, -Math.PI / 2 + frac * Math.PI * 2);
          ctx.stroke();
          ctx.lineWidth = 4;
        }
        ctx.fillText(o.def.name, o.x, o.y);
      }
    }
    function render() {
      ctx.fillStyle = '#3d6b2f';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      const shakeX = screenShake > 0 ? rand(-screenShake, screenShake) : 0;
      const shakeY = screenShake > 0 ? rand(-screenShake, screenShake) : 0;
      ctx.translate(canvas.width / 2 + shakeX - player.x, canvas.height / 2 + shakeY - player.y);
      drawGround();
      const camX = player.x - canvas.width / 2;
      const camY = player.y - canvas.height / 2;
      const margin = 100;
      drawBackgroundDecor(camX, camY, margin);
      drawMapObjectives();
      drawArenaWalls();

      for (const d of itemDrops) {
        if (d.x < camX - margin || d.x > camX + canvas.width + margin || d.y < camY - margin || d.y > camY + canvas.height + margin) continue;
        ctx.font = '28px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        const r = d.item.rarity;
        const strokeColor = { uncommon: '#4a9eff', rare: '#ffd700', epic: '#9333ea', legendary: '#f97316' }[r];
        if (strokeColor) {
          ctx.shadowColor = strokeColor;
          ctx.shadowBlur = 6;
          ctx.fillText(d.item.emoji, d.x, d.y);
          ctx.shadowColor = 'transparent';
          ctx.shadowBlur = 0;
        }
        ctx.fillText(d.item.emoji, d.x, d.y);
      }

      for (const e of enemies) {
        if (e.x < camX - margin || e.x > camX + canvas.width + margin || e.y < camY - margin || e.y > camY + canvas.height + margin) continue;
        const r = e.stats.size ?? 10;
        const corpseAlpha = e.dead ? Math.max(0, 1 - (e.deathAge || 0) / (e.deathMaxAge || CORPSE_FADE_SECONDS)) : 1;
        if (!e.dead && e.hp < e.maxHp) {
          const barW = r * 2.5;
          const barH = 4;
          const barY = e.y - r - barH - 4;
          ctx.fillStyle = '#333';
          ctx.fillRect(e.x - barW / 2, barY, barW, barH);
          ctx.fillStyle = e.hp > e.maxHp * 0.3 ? '#4a4' : '#a44';
          ctx.fillRect(e.x - barW / 2, barY, barW * Math.max(0, e.hp / e.maxHp), barH);
          ctx.strokeStyle = '#555';
          ctx.strokeRect(e.x - barW / 2, barY, barW, barH);
        }
        ctx.save();
        ctx.globalAlpha = corpseAlpha;
        ctx.beginPath();
        ctx.arc(e.x, e.y, r, 0, Math.PI * 2);
        const light = 50 - e.darkness * 40;
        const sat = 70;
        ctx.fillStyle = `hsl(${e.hue}, ${sat}%, ${light}%)`;
        ctx.fill();
        ctx.strokeStyle = '#000';
        ctx.lineWidth = 2;
        ctx.stroke();
        ctx.restore();
      }

      for (const p of projectiles) {
        ctx.fillStyle = p.enemy ? '#fff' : '#ffcc00';
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fill();
        if (p.enemy) {
          ctx.strokeStyle = '#c00';
          ctx.lineWidth = 2;
          ctx.stroke();
        }
      }

      for (const p of worldParticles) {
        const life = 1 - (p.age / p.maxAge);
        ctx.fillStyle = hexToRgba(p.color, 0.2 + life * 0.8);
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.radius * (0.5 + life * 0.8), 0, Math.PI * 2);
        ctx.fill();
      }

      const activePrimaryAbility = mouseRmbDown && !gamePaused ? getPrimaryAbility() : null;
      const primaryItem = player.equipment[PRIMARY_HAND_SLOT];
      if (primaryItem?.baseId === 'shield' && activePrimaryAbility) {
        const pulse = 0.75 + Math.sin(gameTime * 12) * 0.15;
        ctx.strokeStyle = `rgba(160, 220, 255, ${0.75 * pulse})`;
        ctx.lineWidth = 5;
        ctx.beginPath();
        ctx.arc(player.x, player.y, SHIELD_REFLECT_RADIUS, 0, Math.PI * 2);
        ctx.stroke();
        ctx.fillStyle = `rgba(140, 210, 255, ${0.08 * pulse})`;
        ctx.beginPath();
        ctx.arc(player.x, player.y, SHIELD_REFLECT_RADIUS, 0, Math.PI * 2);
        ctx.fill();
      }

      for (const v of abilityVisuals) {
        const t = 1 - (v.age / v.maxAge);
        if (v.kind === 'aoe') {
          const aoeColor = v.color || '#ffd54a';
          ctx.strokeStyle = hexToRgba(aoeColor, 0.9 * t);
          ctx.lineWidth = 8 * Math.max(0.45, t);
          ctx.beginPath();
          ctx.arc(v.x, v.y, v.radius * (1 - 0.28 * (1 - t)), 0, Math.PI * 2);
          ctx.stroke();
          ctx.fillStyle = hexToRgba(aoeColor, 0.2 * t);
          ctx.beginPath();
          ctx.arc(v.x, v.y, v.radius * (0.82 + 0.18 * t), 0, Math.PI * 2);
          ctx.fill();
          ctx.strokeStyle = hexToRgba('#fff3b0', 0.65 * t);
          ctx.lineWidth = 3;
          ctx.beginPath();
          ctx.arc(v.x, v.y, v.radius * (0.56 + 0.22 * t), 0, Math.PI * 2);
          ctx.stroke();
        } else if (v.kind === 'flash') {
          const len = 46 + 24 * t;
          const side = 16 + 12 * t;
          const px = -v.dirY;
          const py = v.dirX;
          const tipX = v.x + v.dirX * len;
          const tipY = v.y + v.dirY * len;
          const baseX = v.x + v.dirX * 12;
          const baseY = v.y + v.dirY * 12;
          ctx.fillStyle = hexToRgba(v.color || '#ffd54a', 0.9 * t);
          ctx.beginPath();
          ctx.moveTo(tipX, tipY);
          ctx.lineTo(baseX + px * side, baseY + py * side);
          ctx.lineTo(baseX - px * side, baseY - py * side);
          ctx.closePath();
          ctx.fill();
          ctx.strokeStyle = 'rgba(255, 250, 210, 0.9)';
          ctx.lineWidth = 2;
          ctx.stroke();
        } else if (v.kind === 'arc') {
          const angle = Math.atan2(v.dirY, v.dirX);
          const arcColor = v.color || '#fff1a8';
          ctx.strokeStyle = hexToRgba(arcColor, 0.95 * t);
          ctx.lineWidth = 12 * Math.max(0.45, t);
          ctx.beginPath();
          ctx.arc(v.x, v.y, v.range, angle - v.halfArc, angle + v.halfArc);
          ctx.stroke();
          ctx.fillStyle = hexToRgba(arcColor, 0.14 * t);
          ctx.beginPath();
          ctx.moveTo(v.x, v.y);
          ctx.arc(v.x, v.y, v.range, angle - v.halfArc, angle + v.halfArc);
          ctx.closePath();
          ctx.fill();
        } else if (v.kind === 'whirlwind') {
          ctx.strokeStyle = hexToRgba(v.color || '#ff9a3c', 0.58 * t);
          ctx.lineWidth = 6;
          for (let i = 0; i < 3; i++) {
            const spin = gameTime * 9 + i * (Math.PI * 2 / 3) + v.age * 14;
            ctx.beginPath();
            ctx.arc(v.x, v.y, v.radius * (0.45 + i * 0.2), spin, spin + Math.PI * 0.9);
            ctx.stroke();
          }
        }
      }

      ctx.fillStyle = player.hitInvuln > 0 ? '#88aaff' : '#4488ff';
      ctx.beginPath();
      ctx.arc(player.x, player.y, player.stats.size, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 2;
      ctx.stroke();
      ctx.beginPath();
      const endX = player.x + player.dirX * (player.stats.size + 8);
      const endY = player.y + player.dirY * (player.stats.size + 8);
      ctx.moveTo(player.x, player.y);
      ctx.lineTo(endX, endY);
      ctx.stroke();

      const pr = player.stats.size ?? 16;
      const barW = pr * 5;
      const barH = 8;
      const barY = player.y - pr - barH - 8;
      ctx.fillStyle = '#333';
      ctx.fillRect(player.x - barW / 2, barY, barW, barH);
      ctx.fillStyle = player.hp > player.maxHp * 0.3 ? '#4a4' : '#a44';
      ctx.fillRect(player.x - barW / 2, barY, barW * (player.hp / player.maxHp), barH);
      ctx.strokeStyle = '#555';
      ctx.strokeRect(player.x - barW / 2, barY, barW, barH);

      ctx.restore();

      ctx.fillStyle = '#fff';
      ctx.font = '12px monospace';
      ctx.textAlign = 'left';
      ctx.fillText(`Time ${Math.floor(gameTime)}s  [E] Inventory`, 10, 24);
      const primaryAbility = getPrimaryAbility();
      if (primaryItem && primaryAbility) {
        const primaryInfo = getAbilityInfo(primaryItem, PRIMARY_HAND_SLOT);
        const cd = Math.max(0, player.abilityCooldowns[PRIMARY_HAND_SLOT] || 0);
        const status = primaryAbility.kind === 'cooldown'
          ? (cd > 0 ? `${cd.toFixed(1)}s` : 'ready')
          : (mouseRmbDown && !gamePaused ? 'active' : 'idle');
        ctx.fillText(`RMB ${primaryItem.name} (${status})`, 10, 42);
        ctx.fillText(`${primaryInfo.name} (${primaryItem.name.toLowerCase()})`, 10, 58);
      }

      if (primaryItem && primaryAbility) {
        const widgetW = 208;
        const widgetH = 88;
        const x = canvas.width - widgetW - 16;
        const y = canvas.height - widgetH - 16;
        const cd = Math.max(0, player.abilityCooldowns[PRIMARY_HAND_SLOT] || 0);
        const cooldownFrac = primaryAbility.kind === 'cooldown' && primaryAbility.cooldown > 0
          ? Math.min(1, cd / primaryAbility.cooldown)
          : 0;
        const pressed = mouseRmbDown && !gamePaused;
        const primaryInfo = getAbilityInfo(primaryItem, PRIMARY_HAND_SLOT);
        ctx.save();
        ctx.fillStyle = pressed ? '#3e4f29' : '#1d1d1d';
        ctx.fillRect(x, y, widgetW, widgetH);
        ctx.fillStyle = '#2a2a2a';
        ctx.fillRect(x, y, widgetW, widgetH);
        if (cooldownFrac > 0) {
          const barH = widgetH * cooldownFrac;
          ctx.fillStyle = 'rgba(15, 15, 15, 0.72)';
          ctx.fillRect(x, y, widgetW, barH);
        }
        ctx.strokeStyle = pressed ? '#d9ff8a' : '#666';
        ctx.lineWidth = 3;
        ctx.strokeRect(x, y, widgetW, widgetH);
        ctx.fillStyle = '#fff';
        ctx.textAlign = 'left';
        ctx.textBaseline = 'top';
        ctx.font = '36px sans-serif';
        ctx.fillText(primaryItem.emoji, x + 12, y + 12);
        ctx.font = 'bold 16px monospace';
        ctx.fillText('RMB', x + 64, y + 12);
        ctx.font = 'bold 15px monospace';
        ctx.fillText(primaryInfo.name, x + 64, y + 34);
        ctx.font = '12px monospace';
        ctx.fillStyle = '#d8d8d8';
        ctx.fillText(primaryItem.name, x + 64, y + 54);
        if (primaryAbility.kind === 'cooldown' && cd > 0) {
          ctx.fillStyle = '#fff';
          ctx.textAlign = 'right';
          ctx.fillText(cd.toFixed(1) + 's', x + widgetW - 12, y + 12);
        }
        ctx.restore();
      }

      if (gameOver) {
        document.getElementById('game-over').classList.add('show');
        document.getElementById('survived-time').textContent = `Survived ${Math.floor(gameTime)} seconds`;
      }
    }

    // ========== 11. INVENTORY UI ==========
    let dragInvIndex = -1;
    let dragEquipSlot = null;
    let dragStationSource = null;
    function clearDragState() {
      dragInvIndex = -1;
      dragEquipSlot = null;
      dragStationSource = null;
    }
    function clearDragHighlights() {
      document.querySelectorAll('.inv-slot, .equip-slot').forEach(s => s.classList.remove('drag-over'));
    }
    function parseStationDragData(data) {
      const match = /^station:(input|output):(\d+)$/.exec(data || '');
      if (!match) return null;
      return { kind: match[1], index: parseInt(match[2], 10) };
    }
    function getStationSourceItem(source) {
      if (!activeStation || !source) return null;
      if (source.kind === 'input') return getStationEntryItem(activeStation.inputs?.[source.index]);
      if (source.kind === 'output') return activeStation.output?.[source.index] || null;
      return null;
    }
    function canPutItemInStationSource(source, item) {
      if (!activeStation || !source || !item) return false;
      if (source.kind === 'output') return true;
      return !activeStation.used && activeStation.canAcceptInput(source.index, item);
    }
    function setStationSourceItem(source, item) {
      if (!activeStation || !source) return;
      if (source.kind === 'input') activeStation.inputs[source.index] = item ? makeStationEntry(item, { kind: 'station' }) : null;
      else if (source.kind === 'output') activeStation.output[source.index] = item || null;
    }
    function moveStationItemToInventorySlot(source, index) {
      const item = getStationSourceItem(source);
      if (!item) return false;
      const existing = inventory[index];
      if (existing && !canPutItemInStationSource(source, existing)) return false;
      inventory[index] = item;
      setStationSourceItem(source, existing || null);
      selectedInvIndex = -1;
      return true;
    }
    function moveStationItemToEquipSlot(source, slot) {
      const item = getStationSourceItem(source);
      const base = ITEM_BASE_DEFS.find(b => b.id === item?.baseId);
      const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
      if (!canEquip) return false;
      const existing = player.equipment[slot];
      if (existing && !canPutItemInStationSource(source, existing)) return false;
      player.equipment[slot] = item;
      setStationSourceItem(source, existing || null);
      selectedInvIndex = -1;
      computePlayerStats();
      return true;
    }
    function buildInventoryUI() {
      buildStationUI();
      const grid = document.getElementById('inventory-grid');
      grid.innerHTML = '';
      for (let i = 0; i < INV_COLS * INV_ROWS; i++) {
        const slot = document.createElement('div');
        const item = inventory[i];
        slot.className = 'inv-slot' + (item ? ' ' + (item.rarity || 'common') : '') + (selectedInvIndex === i ? ' selected' : '');
        slot.dataset.index = String(i);
        slot.textContent = item ? (item.emoji + (item.count > 1 ? 'ร—' + item.count : '')) : '';
        slot.removeAttribute('title');
        if (item) {
          slot.draggable = true;
          slot.addEventListener('dragstart', (e) => { dragInvIndex = i; dragEquipSlot = null; dragStationSource = null; e.dataTransfer.setData('text/plain', 'inv:' + i); e.dataTransfer.effectAllowed = 'move'; });
        } else {
          slot.draggable = false;
        }
        slot.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragInvIndex >= 0 || dragEquipSlot || dragStationSource) slot.classList.add('drag-over'); });
        slot.addEventListener('dragleave', () => slot.classList.remove('drag-over'));
        slot.addEventListener('drop', (e) => {
          e.preventDefault();
          slot.classList.remove('drag-over');
          const data = e.dataTransfer.getData('text/plain');
          if (data.startsWith('inv:')) {
            const from = parseInt(data.slice(4), 10);
            if (from !== i) {
              const tmp = inventory[from];
              inventory[from] = inventory[i];
              inventory[i] = tmp;
            }
          } else if (data.startsWith('equip:')) {
            const eqSlot = data.slice(6);
            const eqItem = player.equipment[eqSlot];
            if (eqItem) {
              const invItem = inventory[i];
              const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
              const canEquip = base && (base.slot === eqSlot || (HAND_SLOTS.includes(eqSlot) && base.slot === 'leftHand'));
              if (canEquip) {
                player.equipment[eqSlot] = invItem;
                inventory[i] = eqItem;
                computePlayerStats();
              } else if (!invItem) {
                player.equipment[eqSlot] = null;
                inventory[i] = eqItem;
                computePlayerStats();
              }
            }
          } else {
            const stationSource = parseStationDragData(data);
            if (stationSource) moveStationItemToInventorySlot(stationSource, i);
          }
          clearDragState();
          buildInventoryUI();
        });
        slot.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
        slot.addEventListener('click', () => onInvSlotClick(i));
        slot.addEventListener('contextmenu', (e) => { e.preventDefault(); onInvSlotRightClick(i); });
        slot.addEventListener('mouseenter', (e) => showTooltip(e, item));
        slot.addEventListener('mouseleave', hideTooltip);
        grid.appendChild(slot);
      }
      const scrapSlotEl = document.getElementById('scrap-slot');
      scrapSlotEl.textContent = 'โ™ป๏ธ';
      scrapSlotEl.className = 'inv-slot scrap-slot';
      const scrapResourceEl = document.getElementById('scrap-resource');
      if (scrapResourceEl) scrapResourceEl.textContent = `Scrap: ${scrap}`;
      const equipSlots = document.querySelectorAll('.equip-slot');
      equipSlots.forEach(el => {
        const slot = el.dataset.slot;
        const item = player.equipment[slot];
        el.textContent = item?.emoji ?? '';
        el.className = 'equip-slot' + (item ? ' ' + item.rarity : '');
        el.draggable = !!item;
        if (item) el.removeAttribute('title'); else el.title = { helmet: 'Helmet', chest: 'Chest', pants: 'Pants', gloves: 'Gloves', leftHand: 'Primary', rightHand: 'Secondary', boots: 'Boots' }[slot] || '';
        el.onclick = () => onEquipSlotClick(slot);
        el.oncontextmenu = (e) => { e.preventDefault(); onEquipSlotRightClick(slot); };
        el.onmouseenter = (e) => showTooltip(e, item);
        el.onmouseleave = hideTooltip;
      });
      buildStatsPanel();
    }
    const STAT_LABELS = {
      damage: 'Damage', damagePercent: 'Damage %', fireRate: 'Fire Rate', projectileSize: 'Proj Size', projectileSpeed: 'Proj Speed', piercing: 'Pierce',
      aoeRadius: 'AOE Radius', aoePercent: 'AOE %', numProjectiles: 'Projectiles',
      knockback: 'Knockback', hp: 'HP', hpRegen: 'HP Regen', moveSpeed: 'Move Speed',
      size: 'Size', contactDamage: 'Contact Dmg'
    };
    const STAT_PCT = new Set(['aoePercent', 'damagePercent']);
    const STAT_LOWER_BETTER = new Set(['size']);
    function buildStatsPanel(previewItem) {
      const el = document.getElementById('stats-content');
      if (!el) return;
      el.innerHTML = '';
      const order = ['damage', 'damagePercent', 'fireRate', 'numProjectiles', 'piercing', 'projectileSize', 'projectileSpeed', 'aoeRadius', 'aoePercent', 'knockback', 'hp', 'hpRegen', 'moveSpeed', 'size', 'contactDamage'];
      let hypotheticalStats = null;
      if (previewItem && getEquipTargetSlot(previewItem)) {
        const targetSlot = getEquipTargetSlot(previewItem);
        const equip = { ...player.equipment, [targetSlot]: previewItem };
        hypotheticalStats = computeStatsFromEquipment(equip);
      }
      for (const key of order) {
        const val = player.stats[key];
        if (val == null) continue;
        const label = STAT_LABELS[key] || key;
        const suffix = STAT_PCT.has(key) ? '%' : '';
        const diff = hypotheticalStats ? (hypotheticalStats[key] ?? 0) - (val ?? 0) : 0;
        const invert = STAT_LOWER_BETTER.has(key);
        const isGood = diff === 0 ? null : (diff > 0 && !invert) || (diff < 0 && invert);
        let diffHtml = '';
        if (diff !== 0) {
          const sign = diff > 0 ? '+' : '';
          const cls = isGood ? 'stat-diff-good' : 'stat-diff-bad';
          diffHtml = `<span class="${cls}">(${sign}${fmtNum(diff)}${suffix})</span>`;
        }
        const row = document.createElement('div');
        row.className = 'stat-row';
        row.innerHTML = `<span class="stat-label">${label}</span><span class="stat-value">${fmtNum(val)}${suffix}</span><span class="stat-diff">${diffHtml}</span>`;
        el.appendChild(row);
      }
    }
    function getTooltipText(item) {
      if (!item) return '';
      const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
      if (!base) return item.name;
      let s = item.name + '\n';
      if (base.baseStat) s += `+${base.baseStat.value} ${base.baseStat.stat}\n`;
      for (const mod of item.modifiers || []) {
        const def = MODIFIER_DEFS.find(m => m.id === mod.modId);
        if (def) s += (def.pct ? `+${mod.value}%` : `+${mod.value}`) + ` ${def.stat}\n`;
      }
      return s.trim();
    }
    function populateTooltipEl(el, item, label) {
      if (!item) return;
      const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
      const equippedSlot = EQUIP_SLOTS.find(slot => player.equipment[slot] === item) ?? item.slot;
      const abilityInfo = getAbilityInfo(item, equippedSlot);
      el.innerHTML = '';
      if (label) {
        const labelEl = document.createElement('div');
        labelEl.className = 'tooltip-label';
        labelEl.textContent = label;
        el.appendChild(labelEl);
      }
      const nameEl = document.createElement('div');
      nameEl.className = 'tooltip-name ' + (item.rarity || 'common');
      nameEl.textContent = item.name + (item.count > 1 ? ' ร—' + item.count : '');
      el.appendChild(nameEl);
      if (abilityInfo) {
        const abilityEl = document.createElement('div');
        abilityEl.className = 'tooltip-stat';
        abilityEl.textContent = `A ${abilityInfo.name} ${abilityInfo.inactiveText}`.trim();
        el.appendChild(abilityEl);
      }
      if (base?.baseStat) {
        const statEl = document.createElement('div');
        statEl.className = 'tooltip-stat';
        statEl.textContent = `+${fmtNum(base.baseStat.value)} ${base.baseStat.stat}`;
        el.appendChild(statEl);
      }
      for (const mod of item.modifiers || []) {
        const def = MODIFIER_DEFS.find(m => m.id === mod.modId);
        if (def) {
          const modEl = document.createElement('div');
          modEl.className = 'tooltip-mod';
          modEl.textContent = def.name.replace('#', fmtNum(mod.value));
          el.appendChild(modEl);
        }
      }
    }
    function getEquippedForSlot(slot) {
      if (!slot) return null;
      const eq = player.equipment[slot];
      if (eq) return eq;
      if (HAND_SLOTS.includes(slot)) {
        for (const s of HAND_SLOTS) {
          const e = player.equipment[s];
          if (e) return e;
        }
      }
      return null;
    }
    function showTooltip(e, item) {
      if (!item) return;
      const tt = document.getElementById('tooltip');
      const ttEq = document.getElementById('tooltip-equipped');
      const wrapper = document.getElementById('tooltip-wrapper');
      populateTooltipEl(tt, item);
      tt.classList.remove('common', 'uncommon', 'rare', 'epic', 'legendary');
      tt.classList.add(item.rarity || 'common');
      const equippedItem = item.slot && item !== getEquippedForSlot(item.slot) ? getEquippedForSlot(item.slot) : null;
      if (equippedItem) {
        ttEq.innerHTML = '';
        const label = document.createElement('div');
        label.className = 'tooltip-equipped-label';
        label.textContent = 'Equipped';
        ttEq.appendChild(label);
        populateTooltipEl(ttEq, equippedItem);
        ttEq.classList.remove('common', 'uncommon', 'rare', 'epic', 'legendary');
        ttEq.classList.add(equippedItem.rarity || 'common');
        ttEq.classList.add('show');
      } else {
        ttEq.innerHTML = '';
        ttEq.classList.remove('show', 'common', 'uncommon', 'rare', 'epic', 'legendary');
      }
      wrapper.classList.add('show');
      const gap = 12;
      const eqWidth = equippedItem ? (ttEq.getBoundingClientRect().width + gap) : 0;
      wrapper.style.left = (e.clientX - eqWidth) + 'px';
      wrapper.style.top = e.clientY + 'px';
      const rect = wrapper.getBoundingClientRect();
      if (rect.right > window.innerWidth) wrapper.style.left = (window.innerWidth - rect.width - 5) + 'px';
      if (rect.bottom > window.innerHeight) wrapper.style.top = (window.innerHeight - rect.height - 5) + 'px';
      if (rect.left < 0) wrapper.style.left = '5px';
      const isEquippable = item?.slot && getEquipTargetSlot(item);
      const isAlreadyEquipped = EQUIP_SLOTS.some(s => player.equipment[s] === item);
      buildStatsPanel(isEquippable && !isAlreadyEquipped ? item : null);
    }
    function hideTooltip() {
      const wrapper = document.getElementById('tooltip-wrapper');
      const ttEq = document.getElementById('tooltip-equipped');
      wrapper.classList.remove('show');
      ttEq.classList.remove('show');
      ttEq.innerHTML = '';
      buildStatsPanel();
    }
    function getStationEntryItem(entry) {
      return entry?.item || null;
    }
    function makeStationEntry(item, origin) {
      return { item, origin };
    }
    function restoreStationEntry(entry) {
      if (!entry?.item) return true;
      const origin = entry.origin;
      if (origin?.kind === 'equipment' && !player.equipment[origin.slot]) {
        player.equipment[origin.slot] = entry.item;
        computePlayerStats();
        return true;
      }
      if (origin?.kind === 'inventory' && inventory[origin.index] == null) {
        inventory[origin.index] = entry.item;
        return true;
      }
      return addItemToInventory(entry.item);
    }
    function clearStationSlot(index) {
      if (!activeStation?.inputs?.[index]) return;
      const entry = activeStation.inputs[index];
      if (entry && restoreStationEntry(entry)) {
        activeStation.inputs[index] = null;
        activeStation.selectedInput = index;
      }
      buildInventoryUI();
    }
    function moveDraggedItemToStationInput(index, data) {
      if (!activeStation?.inputs || activeStation.used || activeStation.inputs[index]) return false;
      let item = null;
      let origin = null;
      let commit = null;
      if (data.startsWith('inv:')) {
        const from = parseInt(data.slice(4), 10);
        item = inventory[from];
        origin = { kind: 'inventory', index: from };
        commit = () => { inventory[from] = null; };
      } else if (data.startsWith('equip:')) {
        const eqSlot = data.slice(6);
        item = player.equipment[eqSlot];
        origin = { kind: 'equipment', slot: eqSlot };
        commit = () => {
          player.equipment[eqSlot] = null;
          computePlayerStats();
        };
      }
      if (!item || !commit || !activeStation.canAcceptInput(index, item)) return false;
      activeStation.inputs[index] = makeStationEntry(item, origin);
      activeStation.selectedInput = activeStation.inputs.findIndex((entry, idx) => !entry && activeStation.canAcceptInput(idx, item));
      if (activeStation.selectedInput < 0) activeStation.selectedInput = null;
      commit();
      selectedInvIndex = -1;
      return true;
    }
    let selectedInvIndex = -1;
    function onInvSlotClick(i) {
      const item = inventory[i];
      if (selectedInvIndex >= 0) {
        if (selectedInvIndex === i) { selectedInvIndex = -1; buildInventoryUI(); return; }
        const other = inventory[selectedInvIndex];
        inventory[selectedInvIndex] = item;
        inventory[i] = other;
        selectedInvIndex = -1;
      } else if (item) {
        selectedInvIndex = i;
      }
      buildInventoryUI();
    }
    function onInvSlotRightClick(i) {
      const item = inventory[i];
      if (!item || !item.slot) return;
      const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
      if (!base || !base.slot) return;
      let targetSlot = base.slot;
      if (HAND_SLOTS.includes(base.slot)) {
        targetSlot = !player.equipment[PRIMARY_HAND_SLOT] ? PRIMARY_HAND_SLOT : !player.equipment[SECONDARY_HAND_SLOT] ? SECONDARY_HAND_SLOT : PRIMARY_HAND_SLOT;
      } else if (!EQUIP_SLOTS.includes(targetSlot)) return;
      const equipped = player.equipment[targetSlot];
      player.equipment[targetSlot] = item;
      inventory[i] = equipped;
      selectedInvIndex = -1;
      computePlayerStats();
      buildInventoryUI();
    }
    function onEquipSlotRightClick(slot) {
      const item = player.equipment[slot];
      if (!item) return;
      const free = inventory.findIndex(x => !x);
      if (free < 0) return;
      inventory[free] = item;
      player.equipment[slot] = null;
      computePlayerStats();
      buildInventoryUI();
    }
    function onEquipSlotClick(slot) {
      const item = player.equipment[slot];
      if (selectedInvIndex >= 0) {
        const invItem = inventory[selectedInvIndex];
        const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
        const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
        if (canEquip) {
          const old = player.equipment[slot];
          player.equipment[slot] = invItem;
          inventory[selectedInvIndex] = old;
          selectedInvIndex = -1;
          computePlayerStats();
        }
      } else if (item) {
        const free = inventory.findIndex(x => !x);
        if (free >= 0) {
          inventory[free] = item;
          player.equipment[slot] = null;
          computePlayerStats();
        }
      }
      buildInventoryUI();
    }

    // ========== 11b. TRADER UI ==========
    function countInventoryScrap() {
      return scrap;
    }
    function addScrapToInventory(amount) {
      if (amount <= 0) return false;
      scrap += amount;
      return true;
    }
    function consumeScrap(amount) {
      if (scrap < amount) return false;
      scrap -= amount;
      return true;
    }
    function addItemToInventory(item) {
      const free = inventory.findIndex(x => !x);
      if (free >= 0) { inventory[free] = item; return true; }
      return false;
    }
    function makeSlotEl(item, opts) {
      const slot = document.createElement('div');
      slot.className = 'inv-slot' + (item ? ' ' + (item.rarity || 'common') : '');
      slot.textContent = item ? (item.emoji + (item.count > 1 ? 'ร—' + item.count : '')) : '';
      if (item) {
        slot.addEventListener('click', () => opts.onClick && opts.onClick());
        slot.addEventListener('contextmenu', (e) => {
          e.preventDefault();
          if (opts.onRightClick) opts.onRightClick();
          else if (opts.onClick) opts.onClick();
        });
        slot.addEventListener('mouseenter', (e) => showTooltip(e, item));
        slot.addEventListener('mouseleave', hideTooltip);
      }
      return slot;
    }
    function stationDropOrInventory(item, x, y) {
      if (!item) return;
      if (!addItemToInventory(item)) {
        const angle = Math.random() * Math.PI * 2;
        const r = 20 + Math.random() * 20;
        itemDrops.push({ x: x + Math.cos(angle) * r, y: y + Math.sin(angle) * r, item, pickupRadius: 24 });
      }
    }
    function createOutputArray(size = 1) {
      return Array(size).fill(null);
    }
    function stationHasFreeOutput(station) {
      return station.output.some(x => !x);
    }
    function stationPushOutput(station, item) {
      const idx = station.output.findIndex(x => !x);
      if (idx < 0) return false;
      station.output[idx] = item;
      return true;
    }
    function getStationInputItem(index) {
      return getStationEntryItem(activeStation?.inputs?.[index]);
    }
    function closeActiveStation() {
      const dropX = activeStation?.objective?.x ?? player.x;
      const dropY = activeStation?.objective?.y ?? player.y;
      if (activeStation?.inputs) {
        for (let i = 0; i < activeStation.inputs.length; i++) {
          const entry = activeStation.inputs[i];
          if (!entry?.item) continue;
          if (!restoreStationEntry(entry)) stationDropOrInventory(entry.item, dropX, dropY);
          activeStation.inputs[i] = null;
        }
      }
      if (activeStation?.output) {
        for (let i = 0; i < activeStation.output.length; i++) {
          const it = activeStation.output[i];
          if (!it) continue;
          stationDropOrInventory(it, dropX, dropY);
          activeStation.output[i] = null;
        }
      }
      traderOpen = false;
      activeTraderObjective = null;
      activeStation = null;
      gamePaused = false;
      selectedInvIndex = -1;
      document.getElementById('inventory-overlay').classList.remove('open');
      buildInventoryUI();
    }
    function openStation(station) {
      activeStation = station;
      gamePaused = true;
      selectedInvIndex = -1;
      document.getElementById('inventory-overlay').classList.add('open');
      buildInventoryUI();
    }
    function buildStationUI() {
      const panel = document.getElementById('station-panel');
      const titleEl = document.getElementById('station-title');
      const descEl = document.getElementById('station-description');
      const inputsEl = document.getElementById('station-inputs');
      const outputEl = document.getElementById('station-output-grid');
      const statusEl = document.getElementById('station-status');
      const actionBtn = document.getElementById('station-action-btn');
      const closeBtn = document.getElementById('station-close-btn');
      if (!panel || !titleEl || !descEl || !inputsEl || !outputEl || !statusEl || !actionBtn || !closeBtn) return;
      if (!activeStation) {
        panel.classList.remove('open');
        descEl.textContent = '';
        inputsEl.innerHTML = '';
        outputEl.innerHTML = '';
        statusEl.textContent = '';
        actionBtn.textContent = '';
        actionBtn.onclick = null;
        closeBtn.onclick = null;
        return;
      }
      panel.classList.add('open');
      panel.style.borderColor = activeStation.accent;
      titleEl.style.color = activeStation.accent;
      titleEl.textContent = activeStation.title;
      descEl.textContent = activeStation.description || '';
      inputsEl.innerHTML = '';
      for (let i = 0; i < (activeStation.inputLabels?.length || 0); i++) {
        const wrap = document.createElement('div');
        wrap.className = 'station-input-wrap';
        const label = document.createElement('div');
        label.className = 'station-input-label';
        label.textContent = activeStation.inputLabels[i];
        const entry = activeStation.inputs[i];
        const item = getStationEntryItem(entry);
        const slot = makeSlotEl(item, {
          onClick: () => clearStationSlot(i)
        });
        slot.classList.add('station-input-slot');
        if (item) {
          slot.draggable = true;
          slot.addEventListener('dragstart', (e) => {
            dragInvIndex = -1;
            dragEquipSlot = null;
            dragStationSource = { kind: 'input', index: i };
            e.dataTransfer.setData('text/plain', `station:input:${i}`);
            e.dataTransfer.effectAllowed = 'move';
          });
          slot.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
        }
        if (activeStation.selectedInput === i) slot.classList.add('selected');
        if (!item) {
          slot.textContent = '+';
          slot.style.color = '#777';
          slot.addEventListener('click', () => {
            activeStation.selectedInput = activeStation.selectedInput === i ? null : i;
            buildInventoryUI();
          });
          slot.addEventListener('contextmenu', (e) => e.preventDefault());
        }
        slot.addEventListener('dragover', (e) => {
          if (activeStation.used || activeStation.inputs[i]) return;
          const dragged = dragInvIndex >= 0 ? inventory[dragInvIndex] : dragEquipSlot ? player.equipment[dragEquipSlot] : null;
          if (!dragged || !activeStation.canAcceptInput(i, dragged)) return;
          e.preventDefault();
          e.dataTransfer.dropEffect = 'move';
          slot.classList.add('drag-over');
        });
        slot.addEventListener('dragleave', () => slot.classList.remove('drag-over'));
        slot.addEventListener('drop', (e) => {
          e.preventDefault();
          slot.classList.remove('drag-over');
          if (moveDraggedItemToStationInput(i, e.dataTransfer.getData('text/plain'))) {
            clearDragState();
            buildInventoryUI();
          }
        });
        wrap.appendChild(label);
        wrap.appendChild(slot);
        inputsEl.appendChild(wrap);
      }
      outputEl.innerHTML = '';
      for (let i = 0; i < activeStation.output.length; i++) {
        const item = activeStation.output[i];
        const slot = makeSlotEl(item, {
          onClick: () => activeStation.onTakeOutput?.(i)
        });
        if (item) {
          slot.draggable = true;
          slot.addEventListener('dragstart', (e) => {
            dragInvIndex = -1;
            dragEquipSlot = null;
            dragStationSource = { kind: 'output', index: i };
            e.dataTransfer.setData('text/plain', `station:output:${i}`);
            e.dataTransfer.effectAllowed = 'move';
          });
          slot.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
        }
        outputEl.appendChild(slot);
      }
      statusEl.textContent = activeStation.getStatusText();
      actionBtn.textContent = activeStation.getActionLabel();
      if (activeStation.used) statusEl.textContent = 'This station has been used.';
      actionBtn.disabled = activeStation.used || !activeStation.canAction();
      actionBtn.style.background = activeStation.buttonBg;
      actionBtn.style.border = `2px solid ${activeStation.accent}`;
      actionBtn.style.color = '#fff';
      actionBtn.onclick = () => {
        if (activeStation.used || !activeStation.canAction()) return;
        const didUse = activeStation.onAction();
        if (didUse && !activeStation.reusable) activeStation.used = true;
        buildInventoryUI();
      };
      closeBtn.onclick = () => closeActiveStation();
    }
    function makeBaseStation(kind, objective, cfg) {
      return {
        kind,
        objective,
        title: cfg.title,
        description: cfg.description,
        accent: cfg.accent,
        buttonBg: cfg.buttonBg,
        inputLabels: cfg.inputLabels || [],
        inputs: Array((cfg.inputLabels || []).length).fill(null),
        selectedInput: (cfg.inputLabels || []).length ? 0 : null,
        output: cfg.output || createOutputArray(cfg.outputSize || 1),
        reusable: !!cfg.reusable,
        used: false,
        getStatusText: cfg.getStatusText,
        getActionLabel: cfg.getActionLabel,
        canAction: cfg.canAction,
        canAcceptInput: cfg.canAcceptInput || (() => false),
        onAction: cfg.onAction,
        onTakeOutput: cfg.onTakeOutput || ((i) => {
          const it = cfg.outputRef ? cfg.outputRef()[i] : activeStation.output[i];
          if (!it) return;
          if (addItemToInventory(it)) {
            if (cfg.outputRef) cfg.outputRef()[i] = null;
            else activeStation.output[i] = null;
            buildInventoryUI();
          }
        })
      };
    }
    function makeTraderStation(objective) {
      traderOpen = true;
      activeTraderObjective = objective;
      return makeBaseStation('trader', objective, {
        title: 'Scrap Trader',
        description: 'Trade scrap for a freshly rolled item. Inventory and scrapping stay fully usable below.',
        accent: '#c08040',
        buttonBg: '#5a4030',
        output: traderOutput,
        outputRef: () => traderOutput,
        outputSize: TRADER_OUTPUT_SLOTS,
        reusable: true,
        getStatusText: () => `Scrap: ${countInventoryScrap()}`,
        getActionLabel: () => `Trade ${TRADE_SCRAP_COST} Scrap`,
        canAction: () => countInventoryScrap() >= TRADE_SCRAP_COST && traderOutput.findIndex(x => !x) >= 0,
        onAction: () => traderTrade()
      });
    }
    function makeModsTransferStation(objective) {
      return makeBaseStation('mods_transfer', objective, {
        title: 'Mods Transfer',
        description: 'Pick a target item and a sacrifice item. The target keeps its base item, but rarity and modifiers are overwritten by the sacrifice. The sacrifice is destroyed.',
        accent: '#8b5cf6',
        buttonBg: '#55308f',
        inputLabels: ['Target', 'Sacrifice'],
        getStatusText: () => 'Drag items into the slots.',
        getActionLabel: () => 'Transfer Mods',
        canAcceptInput: (idx, item) => item.baseId !== 'scrap',
        canAction: () => getStationInputItem(0) && getStationInputItem(1) && stationHasFreeOutput(activeStation),
        onAction: () => {
          const target = getStationInputItem(0);
          const sacrifice = getStationInputItem(1);
          if (!target || !sacrifice || !stationHasFreeOutput(activeStation)) return false;
          const out = cloneItem(target);
          out.rarity = sacrifice.rarity;
          out.modifiers = (sacrifice.modifiers || []).map(mod => ({ ...mod }));
          if (stationPushOutput(activeStation, out)) {
            activeStation.inputs[0] = null;
            activeStation.inputs[1] = null;
            return true;
          }
          return false;
        }
      });
    }
    function makeMergeStation(objective) {
      return makeBaseStation('merge', objective, {
        title: 'Merge',
        description: 'Pick a target and a sacrifice. The target keeps its base item, gains the highest rarity of the two, and rolls a new modifier set from both items combined. Matching modifiers add together before selection.',
        accent: '#f59e0b',
        buttonBg: '#7a4a12',
        inputLabels: ['Target', 'Sacrifice'],
        getStatusText: () => 'Drag two items into the slots.',
        getActionLabel: () => 'Merge Items',
        canAcceptInput: (idx, item) => item.baseId !== 'scrap',
        canAction: () => getStationInputItem(0) && getStationInputItem(1) && stationHasFreeOutput(activeStation),
        onAction: () => {
          const target = getStationInputItem(0);
          const sacrifice = getStationInputItem(1);
          if (!target || !sacrifice || !stationHasFreeOutput(activeStation)) return false;
          if (stationPushOutput(activeStation, mergeItemsLike(target, sacrifice))) {
            activeStation.inputs[0] = null;
            activeStation.inputs[1] = null;
            return true;
          }
          return false;
        }
      });
    }
    function makeReforgeStation(objective) {
      return makeBaseStation('reforge', objective, {
        title: 'Reforge',
        description: 'Reroll modifiers while keeping the base item and rarity.',
        accent: '#06b6d4',
        buttonBg: '#0d5b69',
        inputLabels: ['Item'],
        getStatusText: () => 'Drag an item into the slot.',
        getActionLabel: () => 'Reforge Item',
        canAcceptInput: (idx, item) => item.baseId !== 'scrap',
        canAction: () => getStationInputItem(0) && stationHasFreeOutput(activeStation),
        onAction: () => {
          const target = getStationInputItem(0);
          if (!target || !stationHasFreeOutput(activeStation)) return false;
          if (stationPushOutput(activeStation, rerollItemLike(target))) {
            activeStation.inputs[0] = null;
            return true;
          }
          return false;
        }
      });
    }
    function makeUpgradeStation(objective) {
      return makeBaseStation('upgrade', objective, {
        title: 'Upgrade',
        description: 'Upgrade an item by one rarity tier. Existing modifiers stay, and new ones are rolled only if the next rarity needs more.',
        accent: '#ec4899',
        buttonBg: '#7b1e4f',
        inputLabels: ['Item'],
        getStatusText: () => {
          const item = getStationInputItem(0);
          if (!item) return 'Drag an item into the slot.';
          const next = getNextRarity(item.rarity);
          return next ? `${item.rarity} -> ${next}` : 'Legendary items cannot be upgraded further.';
        },
        getActionLabel: () => 'Upgrade Item',
        canAcceptInput: (idx, item) => item.baseId !== 'scrap',
        canAction: () => {
          const item = getStationInputItem(0);
          if (!item || !stationHasFreeOutput(activeStation)) return false;
          return !!getNextRarity(item.rarity);
        },
        onAction: () => {
          const item = getStationInputItem(0);
          if (!item || !stationHasFreeOutput(activeStation)) return false;
          const upgraded = upgradeItemLike(item);
          if (upgraded && stationPushOutput(activeStation, upgraded)) {
            activeStation.inputs[0] = null;
            return true;
          }
          return false;
        }
      });
    }
    function generateTraderItem() {
      const goodness = traderTimeToGoodness(gameTime);
      const n = normalizeGoodness(goodness);
      const base = pickBaseFromTieredPool(n);
      if (!base) return null;
      return generateItem(base.id, n, goodness);
    }
    function traderTrade() {
      const free = traderOutput.findIndex(x => !x);
      if (free < 0) return false;
      if (!consumeScrap(TRADE_SCRAP_COST)) return false;
      const item = generateTraderItem();
      if (item) traderOutput[free] = item;
      buildInventoryUI();
      return !!item;
    }
    function openTrader(objective) {
      openStation(makeTraderStation(objective));
    }

    // ========== 12. INPUT ==========
    function updateInput(dt) {
      let speed = (player.stats.moveSpeed ?? 150) * (gamePaused ? 0 : 1);
      let moveX = 0, moveY = 0;
      let faceX = player.dirX, faceY = player.dirY;
      let moving = false;
      let forceFacing = false;
      if (mouseLmbDown) {
        const tox = mouseWorldX - player.x;
        const toy = mouseWorldY - player.y;
        const d = Math.hypot(tox, toy);
        if (d > 0) {
          moveX = tox / d;
          moveY = toy / d;
          faceX = moveX; faceY = moveY;
          moving = true;
        }
      } else {
        const dx = (keys['KeyD'] ? 1 : 0) - (keys['KeyA'] ? 1 : 0);
        const dy = (keys['KeyS'] ? 1 : 0) - (keys['KeyW'] ? 1 : 0);
        if (dx !== 0 || dy !== 0) {
          const [nx, ny] = normalize(dx, dy);
          moveX = nx; moveY = ny;
          faceX = nx; faceY = ny;
          moving = true;
        }
      }
      if (mouseRmbDown && !gamePaused) {
        let speedMult = 1;
        let backward = false;
        let cursorOverride = false;
        const ab = getPrimaryAbility();
        const cursorDx = mouseWorldX - player.x;
        const cursorDy = mouseWorldY - player.y;
        const cursorDist = Math.hypot(cursorDx, cursorDy);
        if (cursorDist > 0) {
          const aimMult = ab?.shootAwayFromCursor ? -1 : 1;
          faceX = (cursorDx / cursorDist) * aimMult;
          faceY = (cursorDy / cursorDist) * aimMult;
          forceFacing = true;
        }
        if (ab?.kind === 'continuous' && ab.moveTowardCursor) cursorOverride = true;
        if (ab?.kind === 'toggle') {
          if (ab.speedMult != null) speedMult = Math.min(speedMult, ab.speedMult);
          if (ab.moveBackward) backward = true;
          if (ab.moveTowardCursor && cursorDist > 0) {
            moveX = cursorDx / cursorDist;
            moveY = cursorDy / cursorDist;
            moving = true;
          }
          if (cursorDist > 0 && backward) {
            moveX = -faceX;
            moveY = -faceY;
            moving = true;
          }
        }
        if (cursorOverride) {
          if (cursorDist > 0) {
            moveX = cursorDx / cursorDist;
            moveY = cursorDy / cursorDist;
            faceX = moveX; faceY = moveY;
            moving = true;
          }
        }
        if (backward && moving && !ab?.moveBackward) { moveX = -moveX; moveY = -moveY; }
        speed *= speedMult;
      }
      if (moving) {
        player.vx = moveX * speed;
        player.vy = moveY * speed;
      } else {
        player.vx = 0;
        player.vy = 0;
      }
      if (moving || forceFacing) {
        player.dirX = faceX;
        player.dirY = faceY;
      }
    }

    // ========== 13. GAME LOOP ==========
    function update(dt) {
      if (gameOver) return;
      for (let i = worldParticles.length - 1; i >= 0; i--) {
        const p = worldParticles[i];
        p.age += dt;
        if (p.age >= p.maxAge) {
          worldParticles.splice(i, 1);
          continue;
        }
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.vx *= Math.pow(0.12, dt);
        p.vy = p.vy * Math.pow(0.16, dt) - 180 * dt;
      }
      screenShake = Math.max(0, screenShake - 38 * dt);
      if (gamePaused) {
        render();
        requestAnimationFrame(gameLoop);
        return;
      }
      gameTime += dt;
      updateInput(dt);
      tickAbilities(dt);
      if (fireCooldown <= 0) {
        spawnProjectile();
        fireCooldown = 1 / (player.stats.fireRate ?? 1);
      }
      fireCooldown -= dt;
      player.hp = Math.min(player.maxHp, player.hp + (player.stats.hpRegen ?? 0) * dt);
      runSpawner(dt);
      updateSummoners(dt);
      updateRangedEnemies(dt);
      updateMapObjectives(dt);
      updateCollisions(dt);
      if (player.hp <= 0) gameOver = true;
      render();
      requestAnimationFrame(gameLoop);
    }
    function gameLoop(now) {
      const dt = Math.min(0.05, (now - lastTime) / 1000);
      lastTime = now;
      update(dt);
    }

    // ========== 14. INIT ==========
    function init() {
      canvas = document.getElementById('game');
      ctx = canvas.getContext('2d');
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      window.addEventListener('resize', () => {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
      });
      document.addEventListener('keydown', e => {
        keys[e.code] = true;
        if (e.code === 'Escape' && gamePaused) {
          e.preventDefault();
          if (activeStation) {
            closeActiveStation();
          } else {
            gamePaused = false;
            document.getElementById('inventory-overlay').classList.remove('open');
          }
        }
        if (e.code === 'KeyP') {
          e.preventDefault();
          const equipBases = ITEM_BASE_DEFS.filter(b => b.slot != null);
          for (let i = 0; i < INV_SIZE; i++) {
            const base = equipBases[randInt(0, equipBases.length - 1)];
            const goodness = rand(1, 6);
            const n = normalizeGoodness(goodness);
            inventory[i] = generateItem(base.id, n, goodness);
          }
          if (gamePaused) buildInventoryUI();
        }
      });
      window.addEventListener('blur', () => { for (const k of Object.keys(keys)) keys[k] = false; mouseLmbDown = false; mouseRmbDown = false; });
      document.addEventListener('keyup', e => {
        keys[e.code] = false;
        if (e.code === 'KeyE') {
          e.preventDefault();
          if (activeStation) {
            closeActiveStation();
            return;
          }
          gamePaused = !gamePaused;
          document.getElementById('inventory-overlay').classList.toggle('open', gamePaused);
          if (gamePaused) {
            selectedInvIndex = -1;
            buildInventoryUI();
          }
        }
      });
      canvas.addEventListener('mousemove', e => {
        mouseX = e.clientX;
        mouseY = e.clientY;
        mouseWorldX = player.x - canvas.width / 2 + mouseX;
        mouseWorldY = player.y - canvas.height / 2 + mouseY;
      });
      canvas.addEventListener('mousedown', e => {
        if (e.button === 0) mouseLmbDown = true;
        if (e.button === 2) mouseRmbDown = true;
      });
      document.addEventListener('contextmenu', e => e.preventDefault());
      window.addEventListener('mouseup', e => {
        if (e.button === 0) mouseLmbDown = false;
        if (e.button === 2) mouseRmbDown = false;
      });
      document.getElementById('restart-btn').addEventListener('click', () => {
        gameOver = false;
        gameTime = 0;
        pityCounters = {};
        dropsReceived = 0;
        gamePaused = false;
        document.getElementById('inventory-overlay').classList.remove('open');
        document.getElementById('game-over').classList.remove('show');
        player.x = 0; player.y = 0; player.vx = 0; player.vy = 0; player.impulseVx = 0; player.impulseVy = 0;
        player.hp = 100; player.maxHp = 100;
        player.equipment = { helmet: null, chest: null, pants: null, gloves: null, leftHand: null, rightHand: null, boots: null };
        player.hitInvuln = 0;
        player.abilityCooldowns = { leftHand: 0, rightHand: 0 };
        abilityVisuals.length = 0;
        worldParticles.length = 0;
        screenShake = 0;
        mouseRmbDown = false;
        computePlayerStats();
        enemies.length = 0;
        projectiles.length = 0;
        itemDrops.length = 0;
        mapObjectives.length = 0;
        for (let i = 0; i < traderOutput.length; i++) traderOutput[i] = null;
        traderOpen = false;
        activeTraderObjective = null;
        activeStation = null;
        inventory = Array(INV_SIZE).fill(null);
        scrap = 0;
        fireCooldown = 0.5;
        spawnAccum = 0;
        spawnInitialObjectives();
        lastTime = performance.now();
        requestAnimationFrame(gameLoop);
      });
      const scrapSlotEl = document.getElementById('scrap-slot');
      scrapSlotEl.addEventListener('dragover', (e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        const stationItem = getStationSourceItem(dragStationSource);
        const canScrap = (dragInvIndex >= 0 && inventory[dragInvIndex]?.baseId !== 'scrap') || (dragEquipSlot && player.equipment[dragEquipSlot]) || (stationItem && stationItem.baseId !== 'scrap');
        if (canScrap) scrapSlotEl.classList.add('drag-over');
      });
      scrapSlotEl.addEventListener('dragleave', () => scrapSlotEl.classList.remove('drag-over'));
      scrapSlotEl.addEventListener('drop', (e) => {
        e.preventDefault();
        scrapSlotEl.classList.remove('drag-over');
        const data = e.dataTransfer.getData('text/plain');
        let itemToScrap = null;
        if (data.startsWith('inv:')) {
          const from = parseInt(data.slice(4), 10);
          if (inventory[from]?.baseId !== 'scrap') {
            itemToScrap = inventory[from];
            inventory[from] = null;
          }
        } else if (data.startsWith('equip:')) {
          const eqSlot = data.slice(6);
          itemToScrap = player.equipment[eqSlot];
          if (itemToScrap) {
            player.equipment[eqSlot] = null;
            computePlayerStats();
          }
        } else {
          const stationSource = parseStationDragData(data);
          itemToScrap = getStationSourceItem(stationSource);
          if (itemToScrap?.baseId === 'scrap') itemToScrap = null;
          if (itemToScrap) setStationSourceItem(stationSource, null);
        }
        if (itemToScrap) {
          addScrapToInventory(getScrapValue(itemToScrap));
          clearDragState();
          selectedInvIndex = -1;
          buildInventoryUI();
        }
      });
      document.getElementById('trash-all-btn').addEventListener('click', () => {
        let scrapCount = 0;
        let itemsToScrap = 0;
        for (let i = 0; i < inventory.length; i++) {
          const it = inventory[i];
          if (!it) continue;
          if (it.baseId === 'scrap') {
            scrapCount += it.count || 1;
          } else {
            itemsToScrap += getScrapValue(it);
          }
        }
        const totalScrap = scrapCount + itemsToScrap;
        inventory = Array(INV_SIZE).fill(null);
        addScrapToInventory(totalScrap);
        selectedInvIndex = -1;
        buildInventoryUI();
      });
      document.querySelectorAll('.equip-slot').forEach(el => {
        el.addEventListener('dragstart', (e) => {
          const slot = el.dataset.slot;
          if (player.equipment[slot]) {
            dragInvIndex = -1;
            dragEquipSlot = slot;
            dragStationSource = null;
            e.dataTransfer.setData('text/plain', 'equip:' + slot);
            e.dataTransfer.effectAllowed = 'move';
          }
        });
        el.addEventListener('dragover', (e) => {
          e.preventDefault();
          e.dataTransfer.dropEffect = 'move';
          const slot = el.dataset.slot;
          if (dragInvIndex >= 0) {
            const invItem = inventory[dragInvIndex];
            const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
            const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
            if (canEquip) el.classList.add('drag-over');
          } else if (dragEquipSlot) el.classList.add('drag-over');
          else if (dragStationSource) {
            const stationItem = getStationSourceItem(dragStationSource);
            const base = ITEM_BASE_DEFS.find(b => b.id === stationItem?.baseId);
            const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
            if (canEquip) el.classList.add('drag-over');
          }
        });
        el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
        el.addEventListener('drop', (e) => {
          e.preventDefault();
          el.classList.remove('drag-over');
          const data = e.dataTransfer.getData('text/plain');
          const slot = el.dataset.slot;
          if (data.startsWith('inv:')) {
            const from = parseInt(data.slice(4), 10);
            const invItem = inventory[from];
            const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
            const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
            if (canEquip) {
              const old = player.equipment[slot];
              player.equipment[slot] = invItem;
              inventory[from] = old;
              computePlayerStats();
            }
          } else if (data.startsWith('equip:')) {
            const fromSlot = data.slice(6);
            const eqItem = player.equipment[fromSlot];
            if (eqItem && fromSlot !== slot) {
              const base = ITEM_BASE_DEFS.find(b => b.id === eqItem.baseId);
              const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
              if (canEquip) {
                const tmp = player.equipment[slot];
                player.equipment[slot] = eqItem;
                player.equipment[fromSlot] = tmp;
                computePlayerStats();
              }
            }
          } else {
            const stationSource = parseStationDragData(data);
            if (stationSource) moveStationItemToEquipSlot(stationSource, slot);
          }
          clearDragState();
          buildInventoryUI();
        });
        el.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
      });
      computePlayerStats();
      spawnInitialObjectives();
      lastTime = performance.now();
      requestAnimationFrame(gameLoop);
    }
    init();
  </script>
</body>
</html>

remixes

no remixes yet... email to remix@gameslop.net