/* ============================================================
   STORE — shared game state, cross-tab sync, event bus
   ============================================================ */
const { useState, useEffect, useRef, useCallback, createContext, useContext } = React;

const STORAGE_KEY = 'torchlit-state-v11';
const CHANNEL = 'torchlit-bus';

/* ---------- initial data ---------- */
const STAT_KEYS = ['STR','ATH','MAG','STH','SUR','HIS','INV','NAT','MED','PRS','PRT'];

function mkStats(arr){ const o={}; STAT_KEYS.forEach((k,i)=>o[k]=arr[i]); return o; }

/* ---------- action-card deck — tiered & shuffled ----------
   Every hero's deck is the same six cards: three +1 (common),
   two +2 (rare) and a single +5 (premium). It is shuffled at the
   start; players only ever see how many cards remain on top, never
   their values, until each is played. */
const CARD_TIERS = [
  { value:1, tier:'common',  count:3, label:'+1' },
  { value:2, tier:'rare',    count:2, label:'+2' },
  { value:5, tier:'premium', count:1, label:'+5' },
];
let __cardSeq = 0;
function makeCard(value, tier){ return { id:'c'+(++__cardSeq)+'-'+Date.now().toString(36).slice(-3), value, tier }; }
function makeDeck(){
  const deck = [];
  CARD_TIERS.forEach(t=>{ for(let i=0;i<t.count;i++) deck.push(makeCard(t.value, t.tier)); });
  for(let i=deck.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [deck[i],deck[j]]=[deck[j],deck[i]]; }
  return deck;
}
// locked:true by default — a hero can only play once the moderator switches
// them ON. Each play re-locks them until the moderator allows the next.
function makeCardState(){ return { deck: makeDeck(), played: [], locked:true }; }
window.makeDeck = makeDeck;
window.makeCard = makeCard;
window.CARD_TIERS = CARD_TIERS;

/* ---------- shop items — moderator-authored, with stat requirements ----------
   An item carries: a type (weapon / magic / item — decides which inventory
   section it lands in), a cost in coins, a description, and up to THREE
   stat requirements (e.g. 2 STR, or 2 MED + 1 ATH) needed to wield it.
   `showDesc` reveals the description on the shared Main screen.            */
let __itemSeq = 0;
function mkItem(name, type, cost, desc, reqs){
  return { id:'it'+(++__itemSeq)+Date.now().toString(36).slice(-2),
           name, type:type||'item', cost:Number(cost)||0, desc:desc||'',
           reqs:(reqs||[]).slice(0,3), showDesc:false, stock:5, img:'' };
}
// strip a shop item down to an inventory entry (loses cost/showDesc)
function invFromItem(it){
  return { id:'inv'+(++__itemSeq)+Date.now().toString(36).slice(-2),
           name:it.name, type:it.type||'item', desc:it.desc||'', reqs:it.reqs||[] };
}
window.mkItem = mkItem;
window.invFromItem = invFromItem;

function defaultShops(){
  // shared catalog pieces — reused across the five shops
  const W = {
    ironSword:    ()=>mkItem('Iron Sword','weapon',15,'A reliable soldier\u2019s blade, honest and sharp.',[{stat:'STR',n:1}]),
    longsword:    ()=>mkItem('Steel Longsword','weapon',28,'Balanced steel that rewards a strong arm.',[{stat:'STR',n:2}]),
    greatsword:   ()=>mkItem('Greatsword','weapon',38,'Two hands, one devastating arc.',[{stat:'STR',n:3}]),
    battleAxe:    ()=>mkItem('Battle Axe','weapon',24,'Cleaves shields as easily as bone.',[{stat:'STR',n:2}]),
    warHammer:    ()=>mkItem('War Hammer','weapon',30,'Crushing force that ignores armor.',[{stat:'STR',n:3}]),
    dagger:       ()=>mkItem('Dagger','weapon',6,'Quick, quiet, and easy to hide.',[{stat:'STH',n:1}]),
    twinDaggers:  ()=>mkItem('Twin Daggers','weapon',16,'A blade for each hand, a flurry of cuts.',[{stat:'STH',n:2}]),
    longbow:      ()=>mkItem('Longbow','weapon',20,'Strikes from afar before they close in.',[{stat:'SUR',n:1},{stat:'ATH',n:1}]),
    spear:        ()=>mkItem('Spear','weapon',12,'Reach and a keen point — the soldier\u2019s friend.',[{stat:'STR',n:1}]),
    leather:      ()=>mkItem('Leather Armor','weapon',14,'Light protection that never slows you.',[{stat:'PRT',n:1}]),
    chain:        ()=>mkItem('Chainmail','weapon',30,'Interlocked rings turn aside the blade.',[{stat:'PRT',n:2}]),
    plate:        ()=>mkItem('Plate Armor','weapon',55,'A wall of steel — heavy, but unyielding.',[{stat:'PRT',n:3}]),
    tower:        ()=>mkItem('Tower Shield','weapon',22,'A moving wall to hide the whole party behind.',[{stat:'PRT',n:2},{stat:'STR',n:1}]),
  };
  const M = {
    emberStaff:   ()=>mkItem('Ember Staff','magic',34,'Channels fire from the wielder\u2019s will.',[{stat:'MAG',n:2}]),
    frostWand:    ()=>mkItem('Frost Wand','magic',30,'Locks foes in creeping ice.',[{stat:'MAG',n:2}]),
    stormOrb:     ()=>mkItem('Storm Orb','magic',42,'A caged tempest, loosed on command.',[{stat:'MAG',n:3}]),
    warding:      ()=>mkItem('Amulet of Warding','magic',38,'Turns the first blow each day to nothing.',[{stat:'MAG',n:1},{stat:'PRT',n:1}]),
    cloak:        ()=>mkItem('Cloak of Shadows','item',40,'Wrap yourself in dusk and slip away.',[{stat:'STH',n:2}]),
    warmth:       ()=>mkItem('Ring of Warmth','item',25,'Cold and frost can no longer touch you.',[{stat:'SUR',n:1}]),
    mana:         ()=>mkItem('Mana Vial','item',12,'A swallow of starlight restores the spark.',[]),
  };
  const S = {
    scrFire:      ()=>mkItem('Scroll of Fire','magic',16,'One reading looses a gout of flame.',[{stat:'MAG',n:1}]),
    scrFrost:     ()=>mkItem('Scroll of Frost','magic',16,'Words that sheathe the world in rime.',[{stat:'MAG',n:1}]),
    scrHeal:      ()=>mkItem('Scroll of Healing','magic',18,'Words that knit flesh and ease pain.',[{stat:'MED',n:1}]),
    scrLight:     ()=>mkItem('Scroll of Light','magic',10,'A spoken sun for the deepest dark.',[]),
    scrTeleport:  ()=>mkItem('Scroll of Teleport','magic',30,'Fold the world and step across it.',[{stat:'MAG',n:2}]),
    scrArcana:    ()=>mkItem('Scroll of Arcana','magic',24,'Borrowed genius for a single casting.',[{stat:'MAG',n:1}]),
  };
  const P = {
    heal:         ()=>mkItem('Healing Draught','item',8,'A warm red tonic that closes small wounds.',[]),
    greaterHeal:  ()=>mkItem('Greater Healing Draught','item',18,'A deep restorative for grievous hurts.',[{stat:'MED',n:1}]),
    antidote:     ()=>mkItem('Antidote','item',10,'Purges venom and rot from the blood.',[]),
    vigor:        ()=>mkItem('Elixir of Vigor','item',20,'Fire in the veins — fatigue forgotten.',[]),
    rations:      ()=>mkItem('Trail Rations','item',4,'Dried meat and hardtack for the long road.',[]),
    rope:         ()=>mkItem('Rope (50ft)','item',3,'Strong hemp — always more useful than you think.',[]),
    torch:        ()=>mkItem('Torch','item',2,'Light against the dark, for a while.',[]),
    lantern:      ()=>mkItem('Lantern','item',8,'Steady light that the wind cannot steal.',[]),
    lockpicks:    ()=>mkItem('Lockpicks','item',9,'For doors that would rather stay shut.',[{stat:'STH',n:1}]),
    grapple:      ()=>mkItem('Grappling Hook','item',12,'Turns a sheer wall into a stairway.',[{stat:'ATH',n:1}]),
    bandages:     ()=>mkItem('Bandages','item',5,'Clean linen to bind and stop the bleeding.',[{stat:'MED',n:1}]),
    tent:         ()=>mkItem('Tent','item',14,'Shelter for a cold night in the wild.',[]),
  };
  return {
    weapons: { name:'The Weaponsmith', sub:'Blades, hafts, bows & armor', bg:'',
      items:[ W.ironSword(), W.longsword(), W.greatsword(), W.battleAxe(), W.warHammer(), W.dagger(),
        W.twinDaggers(), W.longbow(), W.spear(), W.leather(), W.chain(), W.plate(), W.tower() ] },
    armory_magic: { name:'Armory & Magic', sub:'Steel and sorcery under one roof', bg:'',
      items:[ W.longsword(), W.greatsword(), W.battleAxe(), W.plate(), W.tower(),
        M.emberStaff(), M.frostWand(), M.stormOrb(), M.warding(), M.cloak(), S.scrFire(), S.scrHeal() ] },
    provisioner: { name:'The Provisioner', sub:'Food, potions & sundry gear', bg:'',
      items:[ P.heal(), P.greaterHeal(), P.antidote(), P.vigor(), P.rations(), P.rope(),
        P.torch(), P.lantern(), P.lockpicks(), P.grapple(), P.bandages(), P.tent() ] },
    mega: { name:'The Grand Bazaar', sub:'Every ware the realm can offer', bg:'',
      items:[ W.longsword(), W.greatsword(), W.twinDaggers(), W.longbow(), W.plate(), W.tower(),
        M.emberStaff(), M.frostWand(), M.stormOrb(), M.warding(), M.cloak(), M.warmth(),
        S.scrFire(), S.scrHeal(), S.scrTeleport(), P.greaterHeal(), P.vigor(), P.lantern() ] },
    scroll: { name:'The Scrollkeeper', sub:'Sealed words of power', bg:'',
      items:[ S.scrFire(), S.scrFrost(), S.scrHeal(), S.scrLight(), S.scrTeleport(), S.scrArcana(),
        M.mana(), M.emberStaff(), M.frostWand(), M.warding() ] },
  };
}
// a palette of UNIQUE finds the moderator can drop into any shop in Setup
const RANDOM_ITEMS = [
  { name:'Flametongue Blade', type:'weapon', cost:60, desc:'Bursts into flame on command, searing all it cuts.', reqs:[{stat:'STR',n:2},{stat:'MAG',n:1}] },
  { name:'Vampiric Dagger', type:'weapon', cost:50, desc:'Each wound it deals heals the one who holds it.', reqs:[{stat:'STH',n:2},{stat:'STR',n:1}] },
  { name:'Elven Longbow', type:'weapon', cost:52, desc:'Sings as it looses — its arrows never wander.', reqs:[{stat:'SUR',n:2},{stat:'ATH',n:1}] },
  { name:'Dwarven Plate', type:'weapon', cost:90, desc:'Forged under the mountain — almost nothing pierces it.', reqs:[{stat:'PRT',n:3},{stat:'STR',n:1}] },
  { name:'Gauntlets of Ogre Power', type:'item', cost:48, desc:'Your grip splinters stone and bends iron.', reqs:[{stat:'STR',n:3}] },
  { name:'Boots of Speed', type:'item', cost:28, desc:'Click the heels and double your every stride.', reqs:[{stat:'ATH',n:1}] },
  { name:'Cloak of Invisibility', type:'item', cost:80, desc:'Pull up the hood and vanish from all eyes.', reqs:[{stat:'STH',n:3}] },
  { name:'Ring of Regeneration', type:'item', cost:45, desc:'Slowly knits even the worst wounds whole.', reqs:[{stat:'MED',n:1}] },
  { name:'Amulet of Health', type:'item', cost:40, desc:'A steady heartbeat of vitality you can feel.', reqs:[{stat:'PRT',n:2}] },
  { name:'Bag of Holding', type:'item', cost:30, desc:'A small sack that swallows a room\u2019s worth of gear.', reqs:[] },
  { name:'Staff of Storms', type:'magic', cost:70, desc:'Calls down lightning and howling wind.', reqs:[{stat:'MAG',n:3}] },
  { name:'Wand of Lightning', type:'magic', cost:55, desc:'A bolt of white fire leaps from its tip.', reqs:[{stat:'MAG',n:2}] },
  { name:'Horn of Blasting', type:'magic', cost:35, desc:'One blast shatters wood, glass and nerve alike.', reqs:[{stat:'STR',n:1}] },
  { name:'Scroll of Resurrection', type:'magic', cost:100, desc:'Calls a fallen soul back across the threshold.', reqs:[{stat:'MED',n:2},{stat:'MAG',n:1}] },
  { name:'Potion of Giant Strength', type:'item', cost:26, desc:'For one fight, lift and strike like a titan.', reqs:[] },
];
window.RANDOM_ITEMS = RANDOM_ITEMS;

/* ---------- XP curve ---------- */
function xpNeeded(level){ return 60 + (Math.max(1,level)-1)*40; }   // L1→2:60, L2→3:100…
window.xpNeeded = xpNeeded;

/* ---------- haptics — buzz the player's handset ---------- */
function haptic(pattern){ try{ if(navigator.vibrate) navigator.vibrate(pattern); }catch(_){} }
const HAPTIC = {
  damage:  [55, 40, 80],
  death:   [130, 70, 130, 70, 240],
  intel:   18,
  card:    [12, 24, 12],
  heal:    [10, 30, 18],
  premium: [10, 30, 10, 30, 10, 30, 70],
};
window.haptic = haptic;
window.HAPTIC = HAPTIC;

const INITIAL = {
  players: [],   // players forge their own heroes at the start
  scene: 'forest',
  shop: { open:false, type:'weapons', page:0, shops: defaultShops(), purchases:[] },
  shopSummary: null,   // {purchases:[{playerId,name,items,total,at}], shopName, at} — host-dismissed close-out
  levelup: null,   // transient {playerId, level, coins, card, items, at} — celebration on Main + that player
  dice: { public:false, last:null, main:null, allow:{}, results:{} },   // 2d6 — per-player allow toggle + global public flag
  cardPlay: { public:true },   // card-play result visibility: public (Main) | private (player only)
  gate: { open:false,
    seq:[],              // target order of note indices (0..3), length 4/8/10
    progress:[],         // note indices entered correctly so far
    replays:3, maxReplays:3,   // "chances" — lost when the timer runs out
    solved:false,
    deadline:0,          // ms timestamp the current chance expires (0 = idle)
    timeLimit:0,         // ms allowed per chance
    demo:false,          // true while the Main screen is demonstrating the order
    demoId:0,            // bumps each time a demo should play (state-driven, race-proof)
    nextScene:'' },      // scene the moderator sends the room to once the gate opens
  // ---- CIRCUIT LOCK — the SECOND gate type: a clockwork ring of 4 concentric
  //      rotatable conductors. Power enters Ring 0 at the top and must chain
  //      inward through every ring to the core. State is tiny & fully synced. ----
  circuit: { open:false,
    rings:[],            // [{entrySlot,exitSlot,rotation,owner,solution-less}] — 4 rings, outer→inner
    powerSlot:0,         // slot where power enters Ring 0 (12 o'clock)
    coreSlot:0,          // slot Ring 3's exit must hit to lock the core
    solution:[],         // rotation value (per ring) that solves the chain — used for hints/reset
    complete:false,
    difficulty:'normal', // easy · normal · hard
    surge:false,         // surge timer active (re-scrambles a ring on a cadence)
    surgeAt:0,           // ms timestamp of the next surge (Main drives it)
    surgeInterval:30000, // ms between surges
    hint:-1,             // ringIndex currently flashing its solved position (-1 = none)
    hintId:0,
    nextScene:'' },      // scene entered once the circuit completes
  // ---- THE DEFUSER — a "Keep Talking" comms trap. Operators hold a module's
  //      live controls; Readers hold the matching disarm rule. The two are
  //      NEVER the same player, so the table must describe & direct out loud. ----
  defuser: { open:false, status:'idle',  // 'idle'|'live'|'disarmed'|'boom'
    modules:[],          // [{id,kind,label,state,solution,locked,operator}]
    rules:[],            // [{id,moduleId,reader,title,text}]  — manual fragments
    endsAt:0, duration:300000,
    strikes:0, maxStrikes:3,
    hintRuleId:'', struckId:0, struckModule:'',
    nextScene:'' },
  // ---- CONSTELLATION — a calm spatial alignment puzzle. Each player rotates a
  //      transparent layer of stars; align every layer to ignite the sky. ----
  constellation: { open:false,
    target:'wolf',       // which constellation
    tolerance:14,        // degrees of slop allowed
    layers:[],           // [{owner, rot, color, starIdx:[..]}]
    stars:[],            // [{x,y,layer}] global star field (normalized 0..100)
    lines:[],            // [[i,j]] star-index pairs
    complete:false,
    hintId:0, hintLayer:-1,
    nextScene:'' },
  // ---- LOOT AUCTION — sealed-bid bidding when a rare item drops. ----
  auction: { open:false,
    item:null,           // {id,name,type,desc,reqs}
    endsAt:0, duration:30000,
    sealed:true, reserve:0,
    bids:{},             // playerId -> {amount, passed}
    status:'live',       // 'live'|'closed'
    winner:'', tie:[] },
  // ---- BRANCHING VOTES — story-fork moments the group owns. ----
  vote: { open:false,
    prompt:'', options:[],   // [{id,label}]
    secret:true,
    ballots:{},          // playerId -> optionId
    weights:{},          // playerId -> number (default 1; 2 = counts double)
    status:'live',       // 'live'|'closed'
    result:'' },
  // ---- SECRET ROLES — private hidden objectives; one player may be the
  //      traitor. Secrets render ONLY on the owning device + the host. ----
  secrets: { active:false, revealed:false,
    roles:{},            // playerId -> {role, faction:'party'|'traitor', objectives:[{id,text,met}]}
    cue:'Something among you feels wrong.' },
  // ---- PRIVATE GM CHANNEL — a two-way text thread between ONE player and the
  //      moderator. Renders ONLY on that player's device + the host inbox.
  //      Backbone for private wagers & secret-reward delivery. ----
  gm: { threads:{} },    // playerId -> [{ id, from:'player'|'gm', text, t, read }]
  // ---- PRIVATE WAGER — a 1:1 bet the moderator offers a single player
  //      through the GM channel. Terms, stake & result stay private; the
  //      reward (coins / item / secret boon) is delivered on-device only. ----
  privateWagers: {},     // playerId -> { id, terms, outcomes:[{id,label}], reward:{type,amount,item,role},
                         //              chosen, stake, status:'offered'|'placed'|'resolved', won }
  // ====== PACK #2 — NINE MORE MECHANICS ======
  // ---- 1 · LIGHT-BENDER — beam redirection; each player rotates one mirror ----
  lightbender: { open:false,
    emitter:{ x:6, y:50, dir:0 },     // entry point + initial heading (deg, 0 = →)
    target:{ x:94, y:50, tol:7 },     // receptacle + hit radius (board units, 0..100)
    mirrors:[],          // [{owner, x, y, angle, sol}]  angle in degrees
    walls:[],            // [{x1,y1,x2,y2}] reflective/blocking segments (board edges added at trace)
    complete:false, hintMirror:-1, hintId:0, tolerance:7, nextScene:'' },
  // ---- 2 · POSSESSED ALLY — enemy charms one player; party cleanses ----
  charm: { active:false, victim:'', endsAt:0, duration:25000,
    cleanse:0, threshold:100, broken:false, expired:false,
    contrib:{},          // playerId -> taps contributed
    hijackTarget:'' },   // the ally the possessed actions harm (host pick or random)
  // ---- 3 · RITUAL INTERRUPT — a cast bar you race ----
  ritual: { active:false, spell:'The Unmaking', startAt:0, castMs:30000,
    interrupt:0, threshold:100, status:'live',  // 'live'|'interrupted'|'complete'
    tasks:{},            // playerId -> {kind:'tap'|'hold'|'seq', ...}
    catastrophe:'all-5' },
  // ---- 4 · PHASE TOTEMS — boss invulnerable until 4 break together ----
  totems: { active:false, shieldDown:false, window:2500,
    items:[],            // [{id, owner, hp, maxHp, primed, broken, brokeAt}]
    lastBreakResolve:0 },
  // ---- 5 · BREATH METER — one air pocket, many lungs ----
  breath: { active:false, drainRate:5, refillRate:22, pocket:'', queue:[],
    bars:{},             // playerId -> 0..100
    objective:'Reach the far grate', safe:false },
  // ---- 6 · SPECTRAL GUIDE — the dead whisper one word ----
  spectral: { enabled:false, cooldownMs:20000, useBank:false,
    bank:['LEFT','RIGHT','WAIT','RUN','BLUE','RED','HIDE','NOW','BACK','TRAP'],
    whispers:[],         // [{id, from, fromName, word, scope, t}] scope='' = all, else playerId
    lastSent:{},         // playerId -> ts
    muted:{} },          // playerId -> true
  // ---- 7 · BONDS — pair players for a small co-op buff ----
  bonds: { list:[] },    // [{id, a, b, condition:'co-target'|'co-location', buff, active}]
  // ---- 8 · THE WAGER — private side-bets on an outcome ----
  wager: { open:false, prompt:'', outcomes:[],   // [{id,label}]
    mode:'house',        // 'house' (±stake) | 'pot' (shared pot split among winners)
    bets:{},             // playerId -> {outcomeId, stake, passed}
    status:'live',       // 'live'|'resolved'
    actual:'' },
  // ---- 9 · ALCHEMY SEQUENCE — order-matters brewing, split clues ----
  alchemy: { open:false, status:'live',  // 'live'|'done'|'failed'
    recipe:[],           // ordered reagentIds (the solution)
    pool:[],             // [{id,name,glyph,color}] all reagents in play
    added:[],            // reagentIds added so far (correct ones)
    holders:{},          // playerId -> [reagentId,...] who can add what
    clues:{},            // playerId -> clue fragment text
    step:0, backfireId:0, backfireBy:'', resultItem:'Elixir of the Deep', nextScene:'' },
  npc: null,            // {name, line}
  enemy: { active:false, name:'Blackroot Warg', hp:18, maxHp:18, intel:false,
           stats:{AC:14,STR:'+4',Speed:'40ft',Trait:'Pack tactics'} },
  // ACTION SEQUENCE — moderator pushes enemies onto the field
  battle: {
    active:false,
    enemies:[],          // {id,name,hp,maxHp,intel,stats}
    turn:null,           // {kind:'player'|'enemy', id} whose turn it is
    round:1,
    acted:null,          // playerId who has spent their action card this turn
    lastAction:null,     // {playerId, action} most recent card played
    banner:null,         // transient {text, kind} shown on main
  },
  intelCard: null,      // {text, at} shown on main — auto-clears after 30s
  maps: [],             // uploaded map/handout library: {id,name,src}
  mainMap: null,        // {id,name,src} currently shown on the Main screen
  // ---- moderator codex pushes (ambient + overlays on the Main screen) ----
  mainNpc: null,        // {id,name,src} — portrait on the LEFT, no text box
  mainEnemies: [],      // [{id,kind,name,src,count}] — figures shown across the MIDDLE/sides
  stagedFoes: [],       // [{id,kind,name,src,count}] — moderator's SELECTED working set (NOT shown until "Show")
  confirmSwitch: null,  // {fromLabel,toLabel} — pending "are you sure?" when switching off a live puzzle
  mainCodex: null,      // {id,kind,name,src,desc} — Pokédex-style reveal overlay
  shopItemSpotlight: null,  // a shop item the moderator spotlights on the Main screen (Dex-style)
  moon: 1,              // watch 1..5
  entry: { artifact: 'amulet' },   // which focal artifact the ignite-to-enter gate shows
  // cinematic story scenes (videos on Cloudflare Stream). scenes:
  //   [{id, title, order, assetId, src(hls), poster(thumb), status, shown}]
  // `playing` = the scene id currently shown full-screen on the Main display.
  story: { scenes: [], playing: null },
  log: [],
};

/* ---------- realtime sync (free public MQTT-over-WebSocket broker) ----------
   All devices that open the same link share one game. State is published
   retained, so a device joining late immediately receives the current game.
   Override the room with ?room=YOURCODE in the URL. */
const NET_BROKER = 'wss://broker.emqx.io:8084/mqtt';
const NET_ROOM_DEFAULT = 'torchlit-7Qk2Zp9Lm4v';

/* ---------- room identity helpers ----------
   The "backend" is the MQTT broker; each room is a topic namespace
   torchlit/{CODE}/{state|event|meta|presence}. A swappable transport
   lives behind window.Room (see room.jsx) so it can move to another
   backend later without touching game code. */
const DEVICE_KEY = 'torchlit-device';
function getDeviceId(){
  try{
    let id = localStorage.getItem(DEVICE_KEY);
    if(!id){ id = (crypto.randomUUID ? crypto.randomUUID() : 'dev-'+Math.random().toString(36).slice(2)+Date.now().toString(36));
      localStorage.setItem(DEVICE_KEY, id); }
    return id;
  }catch(_){ return 'dev-'+Math.random().toString(36).slice(2); }
}
async function shaHash(s){
  try{
    const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(s)));
    return [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,'0')).join('');
  }catch(_){ // fallback: tiny non-crypto hash (still just a soft gate)
    let h=5381; const str=String(s); for(let i=0;i<str.length;i++) h=((h<<5)+h+str.charCodeAt(i))>>>0; return 'f'+h.toString(16);
  }
}
// short, non-ambiguous room code (no 0/O/1/I)
function makeRoomCode(len=5){
  const A='23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; let s='';
  for(let i=0;i<len;i++) s+=A[Math.floor(Math.random()*A.length)];
  return s;
}
window.getDeviceId = getDeviceId;
window.shaHash = shaHash;
window.makeRoomCode = makeRoomCode;

/* ---------- singleton bus ---------- */
class GameStore {
  constructor(){
    this.listeners = new Set();
    this.eventListeners = new Set();
    this.netListeners = new Set();
    this.clientId = 'tl_'+Math.random().toString(36).slice(2,10);
    this.net = { status:'off', room:'', meta:null };
    this.deviceId = getDeviceId();
    this.presence = {};            // deviceId -> {t, role, name}
    this.presenceListeners = new Set();
    this.state = this._load();
    try {
      this.bc = new BroadcastChannel(CHANNEL);
      this.bc.onmessage = (e)=>{
        const m = e.data;
        if(m.kind==='state'){ if(this._accept(m.state)) this._applyRemote(m.state); }
        else if(m.kind==='event'){ this._emit(m.event); }
      };
    } catch(err){ this.bc = null; }
    // also listen to storage as fallback
    window.addEventListener('storage', (e)=>{
      if(e.key===STORAGE_KEY && e.newValue){
        try{ const raw = JSON.parse(e.newValue); if(this._accept(raw)) this._applyRemote(raw); }catch(_){}
      }
    });
    // flush a coalesced write before the tab hides/closes, so the persist
    // throttle (see _scheduleSync) can never lose the last action.
    const flush = ()=>{ if(this._syncTimer){ clearTimeout(this._syncTimer); this._syncTimer=null; } this._persist(); };
    window.addEventListener('pagehide', flush);
    document.addEventListener('visibilitychange', ()=>{ if(document.visibilityState==='hidden') flush(); });
    this._initNet();
  }
  _initNet(){
    if(typeof mqtt==='undefined'){ this.net.status='off'; return; }
    // Only auto-connect when a room is pinned in the URL (shareable links).
    // Otherwise we wait for the landing to create/join a room.
    let room=null;
    try{ room = new URLSearchParams(location.search).get('room'); }catch(_){}
    if(room) this.connectRoom(room, { role:'player' });
    else { this.net.status='idle'; this._notifyNet(); }
    // presence heartbeat — every client announces itself; stale > 6s = gone
    this._presenceTimer = setInterval(()=>{
      this._publishPresence();
      // prune + notify (so dots flip to disconnected even with no new messages)
      const now=Date.now(); let changed=false;
      Object.keys(this.presence).forEach(id=>{ if(now-this.presence[id].t>9000){ delete this.presence[id]; changed=true; } });
      this._notifyPresence();
    }, 3000);
  }
  // (re)connect to a specific room namespace; tears down any prior client
  connectRoom(room, opts){
    opts = opts || {};
    room = (room||'').toString().trim();
    if(!room){ return; }
    this.role = opts.role || this.role || 'player';
    this.presenceName = opts.name || this.presenceName || '';
    if(this.mqtt){ try{ this.mqtt.end(true); }catch(_){} this.mqtt=null; }
    this.presence = {}; this._notifyPresence();
    this.room = room; this.net.room = room; this.net.status='connecting'; this.net.meta=null; this._notifyNet();
    const base = 'torchlit/'+room;
    this.topicState = base+'/state';
    this.topicEvent = base+'/event';
    this.topicMeta  = base+'/meta';
    this.topicPres  = base+'/presence';
    let client;
    try{
      client = mqtt.connect(NET_BROKER, { clientId:this.clientId, clean:true, reconnectPeriod:4000, connectTimeout:9000, keepalive:30 });
    }catch(e){ this.net.status='off'; this._notifyNet(); return; }
    this.mqtt = client;
    client.on('connect', ()=>{
      this.net.status='online'; this._notifyNet();
      this._needSync = true;
      client.subscribe(this.topicState, {qos:0});
      client.subscribe(this.topicEvent, {qos:0});
      client.subscribe(this.topicMeta,  {qos:0});
      client.subscribe(this.topicPres,  {qos:0});
      this._publishPresence();
      if(this._pendingMeta){ this._publishMeta(this._pendingMeta); }   // host: (re)assert room meta on reconnect
    });
    client.on('reconnect', ()=>{ this.net.status='connecting'; this._notifyNet(); });
    client.on('offline', ()=>{ this.net.status='off'; this._notifyNet(); });
    client.on('close', ()=>{ if(this.net.status==='online'){ this.net.status='off'; this._notifyNet(); } });
    client.on('error', ()=>{ this.net.status='off'; this._notifyNet(); });
    client.on('message', (topic, payload)=>{
      let msg; try{ msg = JSON.parse(payload.toString()); }catch(_){ return; }
      if(!msg) return;
      if(topic===this.topicMeta){ this.net.meta = msg.meta || msg; this._notifyNet(); return; }
      if(topic===this.topicPres){
        if(msg.deviceId && msg.deviceId!==this.deviceId){ this.presence[msg.deviceId] = {t:Date.now(), role:msg.role, name:msg.name}; this._notifyPresence(); }
        return;
      }
      if(msg.sender===this.clientId) return;
      if(topic===this.topicState && msg.state){
        if(this._needSync){ this._needSync=false; this._applyRemote(msg.state); }
        else if(this._accept(msg.state)) this._applyRemote(msg.state);
      }
      else if(topic===this.topicEvent && msg.event){ this._emit(msg.event); }
    });
  }
  disconnectRoom(){
    if(this.mqtt){ try{ this.mqtt.end(true); }catch(_){} this.mqtt=null; }
    this._pendingMeta=null; this.presence={}; this._notifyPresence();
    this.net.status='idle'; this.net.room=''; this.net.meta=null; this._notifyNet();
  }
  // host writes the room's meta retained so late joiners can verify the password
  publishRoomMeta(meta){ this._pendingMeta = meta; this._publishMeta(meta); }
  _publishMeta(meta){
    if(this.mqtt && this.topicMeta){
      try{ this.mqtt.publish(this.topicMeta, JSON.stringify({meta}), {retain:true, qos:0}); }catch(_){}
    }
  }
  _publishPresence(){
    if(this.mqtt && this.net.status==='online' && this.topicPres){
      try{ this.mqtt.publish(this.topicPres, JSON.stringify({deviceId:this.deviceId, role:this.role, name:this.presenceName, t:Date.now()}), {qos:0}); }catch(_){}
    }
  }
  setPresenceMeta(role, name){ if(role) this.role=role; if(name!=null) this.presenceName=name; this._publishPresence(); }
  _notifyPresence(){ this.presenceListeners.forEach(l=>l(this.presence)); }
  subscribePresence(fn){ this.presenceListeners.add(fn); return ()=>this.presenceListeners.delete(fn); }
  // list of connected device ids (presence < 6s old)
  connectedDevices(){ const now=Date.now(); return Object.keys(this.presence).filter(id=> now-this.presence[id].t<6000).concat([this.deviceId]); }
  _publishState(){
    if(this.mqtt && this.net.status==='online'){
      // qos:1 (at-least-once) so a tap-flood can't silently drop the state that
      // carries a rotation/progress — duplicates are harmless (rejected by _v).
      try{ this.mqtt.publish(this.topicState, JSON.stringify({sender:this.clientId, state:this.state}), {retain:true, qos:1}); }catch(_){}
    }
  }
  _publishEvent(ev){
    if(this.mqtt && this.net.status==='online'){
      try{ this.mqtt.publish(this.topicEvent, JSON.stringify({sender:this.clientId, event:ev}), {qos:0}); }catch(_){}
    }
  }
  _load(){
    try{ const s = localStorage.getItem(STORAGE_KEY); if(s) return this._migrate(JSON.parse(s)); }catch(_){}
    return structuredClone(INITIAL);
  }
  // bring any stale / older-shape state up to the current schema so new
  // fields (mainEnemies, per-player npc/knowledge, …) are never undefined
  _migrate(parsed){
    if(!parsed || typeof parsed!=='object') return structuredClone(INITIAL);
    const base = structuredClone(INITIAL);
    const st = { ...base, ...parsed };
    st.gate = { ...base.gate, ...(parsed.gate||{}) };
    if(typeof st.gate.nextScene!=='string') st.gate.nextScene = '';
    st.circuit = { ...base.circuit, ...(parsed.circuit||{}) };
    if(!Array.isArray(st.circuit.rings)) st.circuit.rings = [];
    if(!Array.isArray(st.circuit.solution)) st.circuit.solution = [];
    if(typeof st.circuit.nextScene!=='string') st.circuit.nextScene = '';
    st.defuser = { ...base.defuser, ...(parsed.defuser||{}) };
    if(!Array.isArray(st.defuser.modules)) st.defuser.modules = [];
    if(!Array.isArray(st.defuser.rules)) st.defuser.rules = [];
    st.constellation = { ...base.constellation, ...(parsed.constellation||{}) };
    if(!Array.isArray(st.constellation.layers)) st.constellation.layers = [];
    if(!Array.isArray(st.constellation.stars)) st.constellation.stars = [];
    if(!Array.isArray(st.constellation.lines)) st.constellation.lines = [];
    st.auction = { ...base.auction, ...(parsed.auction||{}) };
    if(!st.auction.bids || typeof st.auction.bids!=='object') st.auction.bids = {};
    if(!Array.isArray(st.auction.tie)) st.auction.tie = [];
    st.vote = { ...base.vote, ...(parsed.vote||{}) };
    if(!Array.isArray(st.vote.options)) st.vote.options = [];
    if(!st.vote.ballots || typeof st.vote.ballots!=='object') st.vote.ballots = {};
    if(!st.vote.weights || typeof st.vote.weights!=='object') st.vote.weights = {};
    st.secrets = { ...base.secrets, ...(parsed.secrets||{}) };
    if(!st.secrets.roles || typeof st.secrets.roles!=='object') st.secrets.roles = {};
    // ---- pack #2 migrations ----
    st.lightbender = { ...base.lightbender, ...(parsed.lightbender||{}) };
    if(!Array.isArray(st.lightbender.mirrors)) st.lightbender.mirrors = [];
    if(!Array.isArray(st.lightbender.walls)) st.lightbender.walls = [];
    st.charm = { ...base.charm, ...(parsed.charm||{}) };
    if(!st.charm.contrib || typeof st.charm.contrib!=='object') st.charm.contrib = {};
    st.ritual = { ...base.ritual, ...(parsed.ritual||{}) };
    if(!st.ritual.tasks || typeof st.ritual.tasks!=='object') st.ritual.tasks = {};
    st.totems = { ...base.totems, ...(parsed.totems||{}) };
    if(!Array.isArray(st.totems.items)) st.totems.items = [];
    st.breath = { ...base.breath, ...(parsed.breath||{}) };
    if(!st.breath.bars || typeof st.breath.bars!=='object') st.breath.bars = {};
    if(!Array.isArray(st.breath.queue)) st.breath.queue = [];
    st.spectral = { ...base.spectral, ...(parsed.spectral||{}) };
    if(!Array.isArray(st.spectral.whispers)) st.spectral.whispers = [];
    if(!Array.isArray(st.spectral.bank)) st.spectral.bank = base.spectral.bank;
    if(!st.spectral.lastSent || typeof st.spectral.lastSent!=='object') st.spectral.lastSent = {};
    if(!st.spectral.muted || typeof st.spectral.muted!=='object') st.spectral.muted = {};
    st.bonds = { ...base.bonds, ...(parsed.bonds||{}) };
    if(!Array.isArray(st.bonds.list)) st.bonds.list = [];
    st.wager = { ...base.wager, ...(parsed.wager||{}) };
    if(!Array.isArray(st.wager.outcomes)) st.wager.outcomes = [];
    if(!st.wager.bets || typeof st.wager.bets!=='object') st.wager.bets = {};
    st.alchemy = { ...base.alchemy, ...(parsed.alchemy||{}) };
    if(!Array.isArray(st.alchemy.recipe)) st.alchemy.recipe = [];
    if(!Array.isArray(st.alchemy.pool)) st.alchemy.pool = [];
    if(!Array.isArray(st.alchemy.added)) st.alchemy.added = [];
    if(!st.alchemy.holders || typeof st.alchemy.holders!=='object') st.alchemy.holders = {};
    if(!st.alchemy.clues || typeof st.alchemy.clues!=='object') st.alchemy.clues = {};
    st.enemy = { ...base.enemy, ...(parsed.enemy||{}) };
    // shop: keep open/type from saved state, but ensure the new `shops` model exists
    st.shop = { ...base.shop, ...(parsed.shop||{}) };
    if(!st.shop.shops || typeof st.shop.shops!=='object' || !st.shop.shops.mega){ st.shop.shops = base.shop.shops; }
    if(!['weapons','armory_magic','provisioner','mega','scroll'].includes(st.shop.type)) st.shop.type = 'weapons';
    if(!Array.isArray(st.shop.purchases)) st.shop.purchases = [];
    if(typeof st.shop.page!=='number') st.shop.page = 0;
    if(parsed.shopItemSpotlight===undefined) st.shopItemSpotlight = null;
    if(parsed.levelup===undefined) st.levelup = null;
    if(!st.dice || typeof st.dice!=='object') st.dice = { public:false, last:null, main:null, allow:{}, results:{} };
    if(typeof st.dice.public!=='boolean') st.dice.public = (st.dice.mode==='public');
    if(!st.dice.allow) st.dice.allow = {};
    if(!st.dice.results) st.dice.results = {};
    if(!st.cardPlay || typeof st.cardPlay!=='object') st.cardPlay = { public:true };
    if(typeof st.cardPlay.public!=='boolean') st.cardPlay.public = (st.cardPlay.mode!=='private');
    st.entry = { ...base.entry, ...(parsed.entry||{}) };
    st.story = { ...base.story, ...(parsed.story||{}) };
    if(!Array.isArray(st.story.scenes)) st.story.scenes = [];
    if(st.story.playing===undefined) st.story.playing = null;
    if(!Array.isArray(st.mainEnemies)) st.mainEnemies = [];
    if(!Array.isArray(st.stagedFoes)) st.stagedFoes = [];
    if(!Array.isArray(st.maps)) st.maps = [];
    if(!Array.isArray(st.log)) st.log = [];
    // inventory entries may be legacy strings — normalize to objects
    const normInv = (arr, fallbackType)=> (Array.isArray(arr)?arr:[]).map(x=>{
      if(typeof x==='string') return { id:'leg'+(__itemSeq++)+Math.random().toString(36).slice(2,6), name:x, type:fallbackType, desc:'', reqs:[] };
      return { type:fallbackType, desc:'', reqs:[], ...x };
    });
    st.players = Array.isArray(parsed.players) ? parsed.players.map(p=>({
      coins:0, dead:false, intel:0, intelMsgs:[], maps:[], knowledge:[], npc:null,
      weapons:[], items:[], requests:[], cart:[], xp:0, level:1, statPoints:0, spentStat:{}, ...p,
      weapons: normInv(p.weapons, 'weapon'),
      items: normInv(p.items, 'item'),
      cart: Array.isArray(p.cart) ? p.cart : [],
      xp: Number(p.xp)||0, level: Number(p.level)||1,
      cards: p.cards || makeCardState(),
    })) : [];
    return st;
  }
  storageStamp(){ return STORAGE_KEY; }
  _persist(){ try{ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state)); }catch(_){} }
  _notify(){ this.listeners.forEach(l=>l(this.state)); }
  _notifyNet(){ this.netListeners.forEach(l=>l(this.net)); }
  // dedup by event id — the SAME logical event can arrive twice on one device
  // (once via BroadcastChannel, once via MQTT) when Main/Host/Player tabs share
  // a browser. Without this the gate beat "double-plays". Each emit fires once.
  _emit(ev){
    const eid = ev && ev._eid;
    if(eid){
      if(!this._seenEids){ this._seenEids = new Set(); this._eidQueue = []; }
      if(this._seenEids.has(eid)) return;
      this._seenEids.add(eid); this._eidQueue.push(eid);
      if(this._eidQueue.length>240){ this._seenEids.delete(this._eidQueue.shift()); }
    }
    this.eventListeners.forEach(l=>l(ev));
  }

  subscribe(fn){ this.listeners.add(fn); return ()=>this.listeners.delete(fn); }
  onEvent(fn){ this.eventListeners.add(fn); return ()=>this.eventListeners.delete(fn); }
  subscribeNet(fn){ this.netListeners.add(fn); return ()=>this.netListeners.delete(fn); }

  getState(){ return this.state; }

  set(updater){
    const next = typeof updater==='function' ? updater(this.state) : updater;
    this.state = {...this.state, ...next};
    // stamp a monotonic version so out-of-order / stale network echoes can be
    // rejected instead of clobbering newer state (Lamport clock + client tiebreak)
    const v = Math.max(this._clock||0, this.state._v||0) + 1;
    this._clock = v; this.state._v = v; this.state._vc = this.clientId;
    this._notify();                                   // local UI: instant
    if(this.bc) this.bc.postMessage({kind:'state', state:this.state});  // same-browser tabs: instant
    this._scheduleSync();                             // disk + network: coalesced (latest wins)
  }
  // Coalesce disk + network writes. A dragged dial commits ~60×/s and rapid
  // button taps fire bursts; without this each one serialises the whole state
  // to localStorage and floods the broker, so the Main screen lags badly and
  // qos drops cause divergence. We instead push at most one sync per SYNC_MS,
  // always carrying the LATEST state, with a guaranteed trailing flush — local
  // UI stays at 60fps (it's updated synchronously above) while the network
  // sees a calm, ordered stream that the Main screen can track.
  _scheduleSync(){
    const SYNC_MS = 80;
    const now = Date.now();
    const since = now - (this._lastSync||0);
    if(since >= SYNC_MS){ this._lastSync = now; this._syncNow(); }       // leading edge: isolated taps publish immediately
    else if(!this._syncTimer){                                          // trailing edge: bursts coalesce to the latest
      this._syncTimer = setTimeout(()=>{ this._syncTimer=null; this._lastSync=Date.now(); this._syncNow(); }, SYNC_MS - since);
    }
  }
  _syncNow(){ this._persist(); this._publishState(); }
  // decide whether an incoming (networked) state is newer than what we hold.
  // accepts strictly-newer versions; for true concurrency (same version, different
  // author) breaks ties deterministically; rejects our own already-applied update.
  _accept(incoming){
    const iv = (incoming&&incoming._v)||0, cv = this.state._v||0;
    if(iv > cv){ return true; }
    if(iv === cv && iv > 0){
      const ic = (incoming&&incoming._vc)||'', mc = this.state._vc||'';
      if(ic && ic !== mc) return ic > mc;   // deterministic resolve of a real conflict
    }
    return false;
  }
  // apply an accepted remote state and keep our Lamport clock ahead of it
  _applyRemote(rawState){
    this.state = this._migrate(rawState);
    this._clock = Math.max(this._clock||0, this.state._v||0);
    this._persist();
    this._notify();
  }
  dispatch(event){
    // stamp a stable id so duplicate deliveries (BroadcastChannel + MQTT) fire once
    if(event && !event._eid){ event._eid = this.clientId+'-'+(this._eseq=(this._eseq||0)+1); }
    // transient effect — local + broadcast
    this._emit(event);
    if(this.bc) this.bc.postMessage({kind:'event', event});
    this._publishEvent(event);
  }
  reset(){ this.set(structuredClone(INITIAL)); }
}

const store = (window.__torchlit_store ||= new GameStore());

/* ---------- React glue ---------- */
function useGame(){
  const [state, setState] = useState(store.getState());
  useEffect(()=> store.subscribe(setState), []);
  return state;
}
function useGameEvents(handler){
  const ref = useRef(handler); ref.current = handler;
  useEffect(()=> store.onEvent((ev)=>ref.current(ev)), []);
}
function useNet(){
  const [n, setN] = useState(store.net);
  useEffect(()=> store.subscribeNet((s)=>setN({...s})), []);
  return n;
}
function usePresence(){
  const [p, setP] = useState(store.presence);
  useEffect(()=> store.subscribePresence((s)=>setP({...s})), []);
  return p;
}

/* ---------- ROOM API — swappable transport facade over the MQTT bus ----------
   createRoom : host generates a code, hashes the password, publishes meta,
                connects as authority.
   joinRoom   : connect, await retained meta, verify password.
   Returns {ok, code, error}. Wrap calls in await. */
const Room = {
  myDeviceId(){ return store.deviceId; },
  async createRoom({ password, name, role='host' }){
    const code = makeRoomCode(5);
    const passHash = await shaHash(password||'');
    store.connectRoom(code, { role, name });
    // wait until the broker connection is live, then assert meta
    await Room._waitOnline(6000);
    const meta = { code, passHash, hostDeviceId: store.deviceId, status:'lobby', createdAt: Date.now() };
    store.publishRoomMeta(meta);
    return { ok:true, code, meta };
  },
  async joinRoom({ code, password, role='player', name }){
    code = (code||'').toString().trim().toUpperCase();
    if(!code) return { ok:false, error:'Enter a room code.' };
    store.connectRoom(code, { role, name });
    const online = await Room._waitOnline(7000);
    if(!online) return { ok:false, error:'Could not reach the realm. Check your connection.' };
    const meta = await Room._waitMeta(4500);
    if(!meta) return { ok:false, error:'Room not found. Check the code.' };
    const hash = await shaHash(password||'');
    if(hash !== meta.passHash) return { ok:false, error:'Wrong password.' };
    store.setPresenceMeta(role, name);
    return { ok:true, code, meta };
  },
  leave(){ store.disconnectRoom(); },
  // moderator steps back into a game already in progress: connect as host,
  // adopt the retained world state, and re-assert presence. No password — the
  // master gate (Abzu1818) already guarded this path.
  async rejoinAsHost({ code, name }){
    code = (code||'').toString().trim().toUpperCase();
    if(!code) return { ok:false, error:'Enter the game code.' };
    store.connectRoom(code, { role:'host', name });
    const online = await Room._waitOnline(7000);
    if(!online) return { ok:false, error:'Could not reach the realm. Check your connection.' };
    // adopt whatever the room currently holds (retained state), then re-assert meta if present
    const meta = await Room._waitMeta(3000);
    if(meta) store.publishRoomMeta(meta);   // keep the room's password/meta alive
    store.setPresenceMeta('host', name);
    return { ok:true, code, meta };
  },
  _waitOnline(ms){
    return new Promise(res=>{
      if(store.net.status==='online') return res(true);
      const t0=Date.now();
      const un=store.subscribeNet(()=>{ if(store.net.status==='online'){ un(); res(true); } });
      const iv=setInterval(()=>{ if(store.net.status==='online'){ clearInterval(iv); un(); res(true); }
        else if(Date.now()-t0>ms){ clearInterval(iv); un(); res(false); } }, 200);
    });
  },
  _waitMeta(ms){
    return new Promise(res=>{
      if(store.net.meta) return res(store.net.meta);
      const t0=Date.now();
      const iv=setInterval(()=>{ if(store.net.meta){ clearInterval(iv); res(store.net.meta); }
        else if(Date.now()-t0>ms){ clearInterval(iv); res(null); } }, 200);
    });
  },
};
window.Room = Room;
window.usePresence = usePresence;

/* ---------- helpers ---------- */
const clamp = (v,min,max)=>Math.max(min,Math.min(max,v));
const findP = (st,id)=> st.players.find(p=>p.id===id);
function logLine(st, text){
  const entry = { t: Date.now(), text };
  return { log: [entry, ...st.log].slice(0,40) };
}
// turn OFF every full-screen "stage" takeover so exactly one thing owns the
// Main screen at a time. (Secret roles are NOT a stage — they layer silently.)
function stagesOff(st){
  return {
    npc:null, mainNpc:null, mainEnemies:[], mainMap:null, mainCodex:null,
    enemy:{...st.enemy, active:false},
    shop:{...st.shop, open:false},
    gate:{...st.gate, open:false, demo:false},
    circuit:{...st.circuit, open:false},
    battle:{...st.battle, active:false},
    defuser:{...st.defuser, open:false},
    constellation:{...st.constellation, open:false},
    auction:{...st.auction, open:false},
    vote:{...st.vote, open:false},
    lightbender:{...st.lightbender, open:false},
    ritual:{...st.ritual, active:false},
    totems:{...st.totems, active:false},
    wager:{...st.wager, open:false},
    alchemy:{...st.alchemy, open:false},
  };
}
// just the visual mains (NPC / figures / codex / map) — for mutual exclusion
// without disturbing an active mechanic/puzzle.
function visualsOff(){
  return { mainNpc:null, mainEnemies:[], mainCodex:null, mainMap:null };
}
let _pendingActivate = null;   // holds the deferred takeover while a confirm is pending (not synced)
// returns a human label if an interactive mechanic/puzzle is currently live on
// the Main screen (so the moderator can be warned before switching away).
function activeMechanic(st){
  if(st.gate && st.gate.open) return 'The Sealed Gate';
  if(st.circuit && st.circuit.open) return 'The Circuit Lock';
  if(st.defuser && st.defuser.open) return 'The Defuser';
  if(st.constellation && st.constellation.open) return 'Constellation';
  if(st.lightbender && st.lightbender.open) return 'The Light-Bender';
  if(st.alchemy && st.alchemy.open) return 'The Cauldron';
  if(st.auction && st.auction.open) return 'The Auction';
  if(st.vote && st.vote.open) return 'The Vote';
  if(st.wager && st.wager.open) return 'The Wager';
  if(st.ritual && st.ritual.active) return 'Ritual Interrupt';
  if(st.totems && st.totems.active) return 'Phase Totems';
  if(st.battle && st.battle.active) return 'the Action Sequence';
  return null;
}
window.activeMechanic = activeMechanic;

/* ============================================================
   PACK #2 — pure helpers (geometry, generators, evaluators)
   ============================================================ */
// ---- 1 · LIGHT-BENDER — ray tracing on a 100×100 board ----
// reflect heading vector across a mirror whose face normal is at `angle`° .
function reflectHeading(vx, vy, mirrorAngleDeg){
  // mirror line direction
  const a = mirrorAngleDeg*Math.PI/180;
  const dx = Math.cos(a), dy = Math.sin(a);
  // reflect v across the line with direction (dx,dy): v' = 2(v·d)d - v
  const dot = vx*dx + vy*dy;
  return [ 2*dot*dx - vx, 2*dot*dy - vy ];
}
// trace the beam; returns { points:[[x,y]...], hit:bool, hitOrder:[mirrorIdx...] }
// RELAY trace (per Patch Pack §B): the beam goes Emitter → M0 → M1 → … → Target
// in fixed order. Each mirror must be angled to send the beam to the NEXT node;
// the first mis-aimed mirror is the `breakAt` point, where a dim-red stray stub
// is drawn so the party sees exactly whose mirror is wrong. One function drives
// both logic and rendering, so they can never disagree.
const LB_TOL = 0.105;   // angular tolerance (~6°) — forgiving enough to be solvable
const LB_STUB = 42;     // length of the stray "wrong-way" beam shown at a break
function traceBeamPath(emitter, mirrors, target, tol){
  const pts = [[emitter.x, emitter.y]];
  const used = new Set();
  const a0 = (emitter.dir||0)*Math.PI/180;
  let dx = Math.cos(a0), dy = Math.sin(a0);
  for(let i=0; i<mirrors.length; i++){
    const m = mirrors[i];
    pts.push([m.x, m.y]);                              // beam reaches this mirror
    // reflect incoming dir off the mirror surface (angle = surface direction)
    const a = m.angle*Math.PI/180;
    let nx = -Math.sin(a), ny = Math.cos(a);           // normal
    if(dx*nx + dy*ny > 0){ nx=-nx; ny=-ny; }           // face the incoming beam
    const ddot = dx*nx + dy*ny;
    let ox = dx - 2*ddot*nx, oy = dy - 2*ddot*ny;      // reflected direction
    const ol = Math.hypot(ox,oy)||1; ox/=ol; oy/=ol;
    // desired direction toward the next node in the relay
    const next = (i<mirrors.length-1) ? mirrors[i+1] : target;
    let wx = next.x-m.x, wy = next.y-m.y; const wl = Math.hypot(wx,wy)||1; wx/=wl; wy/=wl;
    const ang = Math.acos(Math.max(-1, Math.min(1, ox*wx + oy*wy)));
    if(ang > LB_TOL){                                  // mis-aimed → break here
      pts.push([m.x + ox*LB_STUB, m.y + oy*LB_STUB]);
      return { points:pts, hit:false, solved:false, breakAt:i, used };
    }
    used.add(i);                                       // this mirror relays correctly
    dx = wx; dy = wy;                                  // snap to the ideal onward ray
  }
  pts.push([target.x, target.y]);
  return { points:pts, hit:true, solved:true, breakAt:-1, used };
}
function raySeg(px,py,vx,vy, x1,y1,x2,y2){
  const sx = x2-x1, sy = y2-y1;
  const denom = vx*sy - vy*sx;
  if(Math.abs(denom)<1e-9) return null;
  const t = ((x1-px)*sy - (y1-py)*sx)/denom;
  const u = ((x1-px)*vy - (y1-py)*vx)/denom;
  if(t>0 && u>=0 && u<=1) return { t, x:px+vx*t, y:py+vy*t };
  return null;
}
function rayCircle(px,py,vx,vy, cx,cy, r){
  const ox=px-cx, oy=py-cy;
  const b = 2*(ox*vx+oy*vy), c = ox*ox+oy*oy-r*r;
  const disc = b*b-4*c;
  if(disc<0) return null;
  const t = (-b - Math.sqrt(disc))/2;
  if(t>0) return { t };
  return null;
}
function nearestWall(px,py,vx,vy){
  // board bounds 0..100
  let best=null;
  const test=(t)=>{ if(t>0.04 && (!best||t<best.t)){ const x=px+vx*t,y=py+vy*t; if(x>=-1&&x<=101&&y>=-1&&y<=101) best={t}; } };
  if(vx>1e-9) test((100-px)/vx); if(vx<-1e-9) test((0-px)/vx);
  if(vy>1e-9) test((100-py)/vy); if(vy<-1e-9) test((0-py)/vy);
  return best;
}
function lbComplete(lb){
  const res = traceBeamPath(lb.emitter, lb.mirrors, lb.target, lb.tolerance||7);
  return res.hit && lb.mirrors.every((_,i)=>res.used.has(i));
}
window.traceBeamPath = traceBeamPath;
window.lbComplete = lbComplete;

// ---- 9 · ALCHEMY — reagent catalog ----
const REAGENTS = [
  { id:'fireroot', name:'Fire Root', glyph:'🜂', color:'#d9534f' },
  { id:'moonwater', name:'Moon Water', glyph:'🜄', color:'#5a8fd6' },
  { id:'ashbloom', name:'Ash Bloom', glyph:'🜔', color:'#9a8c98' },
  { id:'sunpetal', name:'Sun Petal', glyph:'🜚', color:'#f0a836' },
  { id:'deeproot', name:'Deep Root', glyph:'🜍', color:'#52b07a' },
  { id:'voidsalt', name:'Void Salt', glyph:'🜛', color:'#a06fd0' },
];
window.REAGENTS = REAGENTS;
function reagentById(id){ return REAGENTS.find(r=>r.id===id); }
window.reagentById = reagentById;

// label a private-wager reward for thread messages
function rewardLabel(reward){
  reward = reward||{};
  if(reward.type==='coins') return `${Number(reward.amount)||0} coins`;
  if(reward.type==='item')  return reward.item || 'a hidden item';
  if(reward.type==='boon')  return `a secret boon (${reward.role||'Hidden Boon'})`;
  return 'a reward';
}
window.rewardLabel = rewardLabel;

/* ---------- shared small RNG helpers ---------- */
function rnd(n){ return Math.floor(Math.random()*n); }
function shuffleArr(a){ a=a.slice(); for(let i=a.length-1;i>0;i--){ const j=rnd(i+1); [a[i],a[j]]=[a[j],a[i]]; } return a; }
// normalize an angle to (-180,180]
function angNorm(a){ a=((a%360)+360)%360; return a>180?a-360:a; }
window.angNorm = angNorm;

/* ---------- THE DEFUSER — module generation ---------- */
const DEFUSER_WIRE_COLORS = ['Red','Blue','Green','Gold','White'];
const DEFUSER_GLYPHS = ['Moon','Sun','Eye','Serpent','Thorn','Wave'];
const DEFUSER_RUNES = ['Aru','Vel','Tor','Sen','Kyn','Mor'];
const DEFUSER_KINDS = {
  wires:    { label:'Wire Bundle',   verb:'cut a wire' },
  glyphs:   { label:'Rune Dials',    verb:'turn the dials' },
  levers:   { label:'Lever Bank',    verb:'flip levers' },
  sequence: { label:'Sigil Sequence',verb:'press in order' },
};
window.DEFUSER_KINDS = DEFUSER_KINDS;
function makeDefuserModule(kind, idx){
  const id = 'mod'+idx+'_'+rnd(99999).toString(36);
  if(kind==='wires'){
    const count = 3 + rnd(3);
    const wires = Array.from({length:count}, ()=>({ color: DEFUSER_WIRE_COLORS[rnd(DEFUSER_WIRE_COLORS.length)] }));
    const targetColor = wires[rnd(wires.length)].color;
    const cutIndex = wires.findIndex(w=>w.color===targetColor);
    return { module:{ id, kind, label:'Wire Bundle', locked:false, operator:null,
        state:{ wires, cut:[] }, solution:{ cutIndex } },
      rule:{ title:'On the Wires', text:`Cut the first ${targetColor} wire. If there is no ${targetColor} wire, cut the last wire.` } };
  }
  if(kind==='glyphs'){
    const glyphs = shuffleArr(DEFUSER_GLYPHS).slice(0,4);
    const dials = Array.from({length:3}, ()=> rnd(glyphs.length));
    let target = rnd(glyphs.length);
    return { module:{ id, kind, label:'Rune Dials', locked:false, operator:null,
        state:{ dials, glyphs }, solution:{ target } },
      rule:{ title:'On the Dials', text:`Turn every dial until all three show the ${glyphs[target]} rune.` } };
  }
  if(kind==='levers'){
    const n=4;
    const pattern = Array.from({length:n}, ()=> Math.random()<0.5);
    if(!pattern.some(Boolean)) pattern[rnd(n)] = true;
    let levers; do { levers = Array.from({length:n}, ()=> Math.random()<0.5); } while(levers.every((v,i)=>v===pattern[i]));
    const ups = pattern.map((v,i)=>v?i+1:null).filter(Boolean);
    return { module:{ id, kind, label:'Lever Bank', locked:false, operator:null,
        state:{ levers }, solution:{ pattern } },
      rule:{ title:'On the Levers', text:`Raise levers ${ups.join(' & ')} and lower every other lever.` } };
  }
  const runes = shuffleArr(DEFUSER_RUNES).slice(0,4);
  const order = shuffleArr(runes.map((_,i)=>i));
  return { module:{ id, kind:'sequence', label:'Sigil Sequence', locked:false, operator:null,
      state:{ symbols:runes, pressed:[] }, solution:{ order } },
    rule:{ title:'On the Sigils', text:`Press the sigils in this exact order: ${order.map(i=>runes[i]).join(' → ')}.` } };
}

/* ---------- CONSTELLATION — fixed star fields (normalized 0..100) ---------- */
const CONSTELLATIONS = {
  wolf: { name:'The Wolf', layerCount:3,
    stars:[ {x:18,y:54,layer:0},{x:27,y:67,layer:1},{x:37,y:45,layer:2},{x:33,y:27,layer:0},
            {x:51,y:30,layer:1},{x:49,y:52,layer:2},{x:67,y:62,layer:0},{x:83,y:55,layer:1} ],
    lines:[[0,2],[2,5],[5,3],[5,4],[2,1],[1,0],[5,6],[6,7]] },
  crown: { name:'The Crown', layerCount:3,
    stars:[ {x:20,y:70,layer:0},{x:80,y:70,layer:1},{x:32,y:40,layer:2},{x:50,y:26,layer:0},
            {x:68,y:40,layer:1},{x:41,y:50,layer:2},{x:59,y:50,layer:2} ],
    lines:[[0,2],[2,5],[5,3],[3,6],[6,4],[4,1],[0,1]] },
  serpent: { name:'The Serpent', layerCount:3,
    stars:[ {x:14,y:42,layer:0},{x:27,y:30,layer:1},{x:41,y:40,layer:2},{x:54,y:30,layer:0},
            {x:67,y:42,layer:1},{x:79,y:32,layer:2},{x:88,y:48,layer:0} ],
    lines:[[0,1],[1,2],[2,3],[3,4],[4,5],[5,6]] },
};
window.CONSTELLATIONS = CONSTELLATIONS;
function scrambleRot(tol){ let r; do{ r = rnd(301)-150; }while(Math.abs(r)<=tol+12); return r; }
function constComplete(layers, tol){ return layers.length>0 && layers.every(l=>Math.abs(angNorm(l.rot))<=tol); }
window.constComplete = constComplete;

/* ============================================================
   ACTIONS — every effect is a callable function (player-scoped)
   ============================================================ */
const Game = {
  // a player forges their own hero at the start
  createPlayer(data){
    const id = 'p' + Date.now().toString(36) + Math.floor(Math.random()*900+100).toString(36);
    const player = {
      id, name:data.name, cls:data.cls||'Wanderer',
      deviceId: (data.deviceId || (window.getDeviceId&&getDeviceId()) || ''),   // owning device (for reconnection/reassign)
      mastery:data.mastery||'', weakness:data.weakness||'', backstory:data.backstory||'',
      hp:data.maxHp, maxHp:data.maxHp, coins:0, dead:false,
      cards: makeCardState(), intel:0, intelMsgs:[], maps:[], knowledge:[], npc:null,
      stats: data.stats,
      weapons:[], items:[], requests:[],
    };
    store.set(st=>({ players:[...st.players, player], ...logLine(st,`✦ ${data.name} the ${player.cls} joins the party`)}));
    store.dispatch({type:'join', playerId:id});
    return id;
  },
  // host rebinds a character to a different device (failure/reassignment path)
  reassignCharacter(playerId, deviceId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, deviceId} : p),
      ...logLine(st, `Reassigned ${(st.players.find(p=>p.id===playerId)||{}).name||'a hero'} to a new device`)}));
    store.dispatch({type:'reassign', playerId, deviceId});
  },
  triggerDamage(playerId, amount){
    amount = Number(amount)||0;
    store.set(st=>{
      const players = st.players.map(p=> p.id===playerId ? {...p, hp: clamp(p.hp-amount,0,p.maxHp)} : p);
      const p = players.find(x=>x.id===playerId);
      const out = {...logLine(st, `Damage ${amount} → ${p.name} (${p.hp}/${p.maxHp})`)};
      // auto-death at 0
      if(p.hp===0 && !p.dead){ p.dead=true; }
      return { players, ...out };
    });
    store.dispatch({type:'damage', playerId, amount});
    const p = findP(store.getState(), playerId);
    if(p && p.hp===0) store.dispatch({type:'death', playerId});
    audio.play('damage');
  },
  triggerHeal(playerId, amount){
    amount = Number(amount)||0;
    store.set(st=>{
      const players = st.players.map(p=> p.id===playerId ? {...p, hp: clamp(p.hp+amount,0,p.maxHp), dead: p.hp+amount>0 ? false : p.dead} : p);
      const p = players.find(x=>x.id===playerId);
      return { players, ...logLine(st, `Heal +${amount} → ${p.name} (${p.hp}/${p.maxHp})`)};
    });
    store.dispatch({type:'heal', playerId, amount});
    audio.play('heal');
  },
  triggerDeath(playerId){
    store.set(st=>{
      const players = st.players.map(p=> p.id===playerId ? {...p, dead:true, hp:0} : p);
      const p = players.find(x=>x.id===playerId);
      return { players, ...logLine(st, `☠ ${p.name} has fallen`)};
    });
    store.dispatch({type:'death', playerId});
    audio.play('death');
  },
  triggerRevive(playerId){
    store.set(st=>{
      const players = st.players.map(p=> p.id===playerId ? {...p, dead:false, hp: Math.max(1, Math.round(p.maxHp/2))} : p);
      const p = players.find(x=>x.id===playerId);
      return { players, ...logLine(st, `✦ ${p.name} revived (${p.hp}/${p.maxHp})`)};
    });
    store.dispatch({type:'revive', playerId});
    audio.play('revive');
  },
  setCoins(playerId, n){
    store.set(st=>{
      const players = st.players.map(p=> p.id===playerId ? {...p, coins: Math.max(0,n)} : p);
      return { players };
    });
    store.dispatch({type:'coins', playerId});
    audio.play('coin');
  },
  useActionCard(playerId){ Game.playCard(playerId); },
  resetCards(playerId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, cards: makeCardState()} : p), ...logLine(st,`Deck reshuffled → ${findP(st,playerId).name.split(' ')[0]}`)}));
    store.dispatch({type:'deck-reset', playerId});
  },
  // moderator opens the gate for this hero to play their next card
  allowCard(playerId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, cards:{...p.cards, locked:false}} : p), ...logLine(st,`Action unlocked → ${findP(st,playerId).name.split(' ')[0]}`)}));
    store.dispatch({type:'card-allow', playerId});
  },
  lockCard(playerId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, cards:{...p.cards, locked:true}} : p)}));
  },
  // moderator removes the top card from a hero's deck (it is discarded)
  removeCard(playerId){
    store.set(st=>{
      const players = st.players.map(p=>{
        if(p.id!==playerId || !p.cards.deck.length) return p;
        return {...p, cards:{...p.cards, deck:p.cards.deck.slice(1)}};
      });
      const had = findP(st,playerId).cards.deck.length>0;
      return { players, ...(had?logLine(st,`Card removed → ${findP(st,playerId).name.split(' ')[0]}`):{}) };
    });
    store.dispatch({type:'deck-change', playerId});
  },
  // moderator returns a card to a hero's deck — the most recently played one
  // is shuffled back, or a fresh +1 is granted if none were played
  addCard(playerId, value){
    store.set(st=>{
      const players = st.players.map(p=>{
        if(p.id!==playerId) return p;
        let played = p.cards.played, card;
        if(value){ card = makeCard(value, value>=5?'premium':value>=2?'rare':'common'); }
        else if(played.length){ card = played[0]; played = played.slice(1); }
        else { card = makeCard(1,'common'); }
        const deck = [...p.cards.deck];
        const pos = Math.floor(Math.random()*(deck.length+1));
        deck.splice(pos, 0, card);
        return {...p, cards:{...p.cards, deck, played}};
      });
      return { players, ...logLine(st,`Card returned → ${findP(st,playerId).name.split(' ')[0]}`)};
    });
    store.dispatch({type:'deck-change', playerId});
  },
  sleep(playerId){
    store.set(st=>{
      const players = st.players.map(p=> p.id===playerId ? {...p, cards: makeCardState(), hp: clamp(p.hp+2,0,p.maxHp)} : p);
      return { players, moon: clamp(st.moon+1,1,5), ...logLine(st,`${findP(st,playerId).name} sleeps · deck reshuffled · moon ${clamp(st.moon+1,1,5)}`)};
    });
    store.dispatch({type:'heal', playerId, amount:2});
    store.dispatch({type:'deck-reset', playerId});
    audio.play('heal');
  },
  // rest WITHOUT advancing the watch — used by the sleep→XP flow
  restPlayer(playerId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, cards: makeCardState(), hp: clamp(p.hp+2,0,p.maxHp)} : p),
      ...logLine(st,`${findP(st,playerId).name.split(' ')[0]} rests · deck reshuffled`)}));
    store.dispatch({type:'heal', playerId, amount:2});
    store.dispatch({type:'deck-reset', playerId});
    audio.play('heal');
  },
  pushIntel(target, text){
    const msg = text || 'A whisper reaches you from the dark...';
    if(target==='main'){
      store.set(st=>({ intelCard:{text:msg, at:Date.now()}, ...logLine(st,`Intel → Main screen`)}));
      store.dispatch({type:'main-intel'});
    } else if(target==='all'){
      store.set(st=>({ players: st.players.map(p=>({...p, intel:p.intel+1, intelMsgs:[msg, ...(p.intelMsgs||[])]})), ...logLine(st,`Intel → all players`)}));
      store.getState().players.forEach(p=> store.dispatch({type:'intel', playerId:p.id}));
    } else {
      store.set(st=>({ players: st.players.map(p=> p.id===target ? {...p, intel:p.intel+1, intelMsgs:[msg, ...(p.intelMsgs||[])]} : p), ...logLine(st,`Intel → ${findP(st,target).name}`)}));
      store.dispatch({type:'intel', playerId:target});
    }
  },
  clearIntel(playerId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, intel:0} : p)}));
  },
  dismissMainIntel(){ store.set({ intelCard:null }); },

  /* ============================================================
     CODEX — moderator pushes portraits & lore to the Main screen
     or to specific players (NPC left · Boss middle · Pokédex reveal).
     Entry shape: {id, kind:'npc'|'enemy'|'boss'|'knowledge', name, src, desc}
     ============================================================ */
  // NPC portrait, LEFT side of Main, no text box (ambient — sits over the scene).
  // If the entry carries a sound, the Main screen plays it once (player/mod devices ignore it).
  showNpcOnMain(entry){
    Game.activateMain('NPC: '+entry.name, ()=>{
      store.set(st=>({ ...stagesOff(st), mainNpc: {id:entry.id, name:entry.name, src:entry.src}, ...logLine(st,`Main → NPC ${entry.name}`)}));
      store.dispatch({type:'codex-main'});
      if(entry.sound) store.dispatch({type:'npc-sound', sound:entry.sound, npcId:entry.id});
    });
  },
  clearMainNpc(){ store.set(st=>({ mainNpc:null, ...logLine(st,'NPC left the Main screen')})); },
  // ---- guarded takeover: only ONE thing owns the Main screen. If a live
  // puzzle/mechanic is running, ask the moderator to confirm before switching.
  // `apply` performs the actual takeover (it should call stagesOff itself). ----
  activateMain(label, apply){
    const st = store.getState();
    const busy = activeMechanic(st);
    if(busy && busy !== label){ _pendingActivate = apply; store.set({ confirmSwitch:{ fromLabel:busy, toLabel:label } }); return false; }
    apply(); return true;
  },
  confirmSwitchYes(){ const fn=_pendingActivate; _pendingActivate=null; store.set({confirmSwitch:null}); if(fn){ try{ fn(); }catch(_){} } },
  confirmSwitchNo(){ _pendingActivate=null; store.set({confirmSwitch:null}); },
  // ---- FOE SELECTION (working set) — selecting a tile just adds it to the
  // moderator's staged list so the position/count row appears. It does NOT
  // display on the Main screen; the per-foe "Show" menu decides that. ----
  toggleStageFoe(entry){
    store.set(st=>{
      const exists = (st.stagedFoes||[]).some(e=>e.id===entry.id);
      const stagedFoes = exists
        ? st.stagedFoes.filter(e=>e.id!==entry.id)
        : [...(st.stagedFoes||[]), {id:entry.id, kind:entry.kind, name:entry.name, src:entry.src, desc:entry.desc||'', count:1}].slice(0,4);
      // deselecting also pulls it off the Main figures if it was shown
      const mainEnemies = exists ? st.mainEnemies.filter(e=>e.id!==entry.id) : st.mainEnemies;
      return { stagedFoes, mainEnemies, ...logLine(st, exists?`Deselected ${entry.name}`:`Selected ${entry.name}`) };
    });
  },
  setStagedFoeCount(id, n){
    n = Math.max(1, Math.min(99, Number(n)||1));
    store.set(st=>({ stagedFoes: (st.stagedFoes||[]).map(e=> e.id===id ? {...e, count:n} : e),
      mainEnemies: st.mainEnemies.map(e=> e.id===id ? {...e, count:n} : e) }));   // keep a live figure in sync
  },
  clearStagedFoes(){ store.set(st=>({ stagedFoes:[], mainEnemies:[], ...logLine(st,'Foe selection cleared')})); },
  // toggle THIS foe's figure on the Main stage (uses its staged count + order)
  toggleFoeFigure(entry){
    const cur = store.getState();
    const exists = cur.mainEnemies.some(e=>e.id===entry.id);
    if(exists){
      store.set(st=>({ mainEnemies: st.mainEnemies.filter(e=>e.id!==entry.id), ...logLine(st,`Removed ${entry.name} from stage`) }));
      store.dispatch({type:'codex-main', figure:true});
      return;
    }
    const addFig = (list, stagedList)=>{
      const staged = (stagedList||[]).find(e=>e.id===entry.id);
      const count = staged ? staged.count : 1;
      const merged = [...list, {id:entry.id, kind:entry.kind, name:entry.name, src:entry.src, count}];
      const order = (stagedList||[]).map(s=>s.id);
      merged.sort((a,b)=> order.indexOf(a.id) - order.indexOf(b.id));
      return merged.slice(0,4);
    };
    if(cur.mainEnemies.length>0){
      // already in figure mode — just add another, no takeover/confirm
      store.set(st=>({ mainEnemies: addFig(st.mainEnemies, st.stagedFoes), ...logLine(st,`Main → ${entry.name}`) }));
      store.dispatch({type:'codex-main', figure:true});
      return;
    }
    // first figure → guarded takeover (clears any live puzzle + other visuals)
    Game.activateMain('Enemy figures', ()=>{
      store.set(st=>({ ...stagesOff(st), mainEnemies: addFig([], st.stagedFoes), ...logLine(st,`Main → ${entry.name}`) }));
      store.dispatch({type:'codex-main', figure:true});
    });
  },
  // Enemy/Boss figures on the Main screen — multiple at once, positioned
  // left/centre/right by order, each with a count badge.
  toggleMainEnemy(entry){
    store.set(st=>{
      const exists = st.mainEnemies.some(e=>e.id===entry.id);
      const mainEnemies = exists
        ? st.mainEnemies.filter(e=>e.id!==entry.id)
        : [...st.mainEnemies, {id:entry.id, kind:entry.kind, name:entry.name, src:entry.src, count:1}].slice(0,4);
      return { mainEnemies, mainMap:null, ...logLine(st, exists?`Removed ${entry.name} from stage`:`Staged ${entry.name}`) };
    });
    store.dispatch({type:'codex-main', figure:true});
  },
  setMainEnemyCount(id, n){
    n = Math.max(1, Math.min(99, Number(n)||1));
    store.set(st=>({ mainEnemies: st.mainEnemies.map(e=> e.id===id ? {...e, count:n} : e) }));
  },
  clearMainEnemies(){ store.set(st=>({ mainEnemies:[], ...logLine(st,'Enemies cleared from Main')})); },
  // boss/enemy quick "show alone, centred"
  showFigureOnMain(entry){ store.set(st=>({ mainEnemies:[{id:entry.id, kind:entry.kind, name:entry.name, src:entry.src, count:1}], mainMap:null, ...logLine(st,`Main → ${entry.name}`)})); store.dispatch({type:'codex-main', figure:true}); },
  clearMainFigure(){ store.set(st=>({ mainEnemies:[], ...logLine(st,'Figure left the Main screen')})); },
  // Pokédex-style reveal overlay on Main (image + description). Caller decides
  // whether desc is included (intel toggle).
  showCodexOnMain(entry){
    Game.activateMain('Reveal: '+entry.name, ()=>{
      store.set(st=>({ ...stagesOff(st), mainCodex: entry, ...logLine(st,`Main → Codex: ${entry.name}`)}));
      store.dispatch({type:'codex-reveal', kind:entry.kind});
    });
  },
  clearMainCodex(){ store.set(st=>({ mainCodex:null, ...logLine(st,'Codex closed on Main')})); },
  // hand an NPC portrait to a player (or 'all') — image only, auto-pops on their screen
  pushNpcToPlayer(target, entry){
    const npc = {id:entry.id, name:entry.name, src:entry.src};
    const set = (p)=> ({...p, npc});
    if(target==='all'){
      store.set(st=>({ players: st.players.map(set), ...logLine(st,`NPC → all players: ${entry.name}`)}));
      store.getState().players.forEach(p=> store.dispatch({type:'npc-push', playerId:p.id, name:entry.name}));
    } else {
      store.set(st=>({ players: st.players.map(p=> p.id===target ? set(p) : p), ...logLine(st,`NPC → ${findP(st,target).name.split(' ')[0]}: ${entry.name}`)}));
      store.dispatch({type:'npc-push', playerId:target, name:entry.name});
    }
  },
  clearNpcFromPlayer(playerId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, npc:null} : p)}));
  },
  // hand a codex/knowledge entry to a player (or 'all') — appears in their Codex
  pushKnowledge(target, entry){
    const stamped = {...entry, at:Date.now()};
    const add = (p)=>{ const rest = (p.knowledge||[]).filter(k=>k.id!==entry.id); return {...p, knowledge:[stamped, ...rest].slice(0,16)}; };
    if(target==='all'){
      store.set(st=>({ players: st.players.map(add), ...logLine(st,`Codex → all players: ${entry.name}`)}));
      store.getState().players.forEach(p=> store.dispatch({type:'knowledge', playerId:p.id, kind:entry.kind, name:entry.name}));
    } else {
      store.set(st=>({ players: st.players.map(p=> p.id===target ? add(p) : p), ...logLine(st,`Codex → ${findP(st,target).name.split(' ')[0]}: ${entry.name}`)}));
      store.dispatch({type:'knowledge', playerId:target, kind:entry.kind, name:entry.name});
    }
  },
  revokeKnowledge(playerId, entryId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, knowledge:(p.knowledge||[]).filter(k=>k.id!==entryId)} : p)}));
  },

  /* ============================================================
     MAPS & HANDOUTS — upload images, share to Main or to players
     ============================================================ */
  addMap(name, src){
    const id = 'map'+Date.now().toString(36)+Math.floor(Math.random()*900+100).toString(36);
    store.set(st=>({ maps:[{id, name, src}, ...st.maps], ...logLine(st, `Map uploaded: ${name}`)}));
    return id;
  },
  removeMap(id){
    store.set(st=>({
      maps: st.maps.filter(m=>m.id!==id),
      mainMap: st.mainMap && st.mainMap.id===id ? null : st.mainMap,
      players: st.players.map(p=>({...p, maps:(p.maps||[]).filter(m=>m.id!==id)})),
    }));
  },
  renameMap(id, name){
    store.set(st=>({ maps: st.maps.map(m=> m.id===id ? {...m, name} : m),
      mainMap: st.mainMap && st.mainMap.id===id ? {...st.mainMap, name} : st.mainMap }));
  },
  // show a map full-bleed on the shared Main screen (clears other overlays)
  shareMapToMain(map){
    Game.activateMain('Map: '+map.name, ()=>{
      store.set(st=>({ ...stagesOff(st), mainMap: map, ...logLine(st, `Main → Map: ${map.name}`) }));
      store.dispatch({type:'scene'});
    });
  },
  clearMainMap(){ store.set(st=>({ mainMap:null, ...logLine(st,'Map hidden from Main')})); store.dispatch({type:'scene'}); },
  // hand a map to a specific player (or 'all') — arrives as intel-style handout
  shareMapToPlayer(target, map){
    const push = (p)=> ({...p, maps:[map, ...(p.maps||[]).filter(m=>m.id!==map.id)]});
    if(target==='all'){
      store.set(st=>({ players: st.players.map(push), ...logLine(st,`Map → all players: ${map.name}`)}));
      store.getState().players.forEach(p=> store.dispatch({type:'map', playerId:p.id, name:map.name}));
    } else {
      store.set(st=>({ players: st.players.map(p=> p.id===target ? push(p) : p), ...logLine(st,`Map → ${findP(st,target).name.split(' ')[0]}: ${map.name}`)}));
      store.dispatch({type:'map', playerId:target, name:map.name});
    }
  },
  // take a shared map back from a player
  revokeMapFromPlayer(playerId, mapId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, maps:(p.maps||[]).filter(m=>m.id!==mapId)} : p)}));
  },
  changeScene(name){
    store.set(st=>({ scene:name, npc:null, mainMap:null, ...logLine(st,`Scene → ${name}`)}));
    store.dispatch({type:'scene', name});
  },
  setNpc(npc){ store.set(st=>({ npc, mainMap:null, ...logLine(st, npc?`NPC → ${npc.name}`:'NPC cleared')})); },
  setShop(patch){ store.set(st=>({ shop:{...st.shop, ...patch}})); },
  setShopOpen(open){ store.set(st=>{
    if(open) return { ...stagesOff(st), shop:{...st.shop, open:true, purchases:[]}, shopSummary:null, ...logLine(st, `${st.shop.shops[st.shop.type].name} opened`) };
    // closing — surface a purchase summary the moderator dismisses (only if anyone bought)
    const purchases = st.shop.purchases||[];
    return { shop:{...st.shop, open:false}, shopItemSpotlight:null,
      shopSummary: purchases.length ? { purchases, shopName: st.shop.shops[st.shop.type].name, at:Date.now() } : st.shopSummary,
      ...logLine(st, 'Shop closed') };
  }); },
  dismissShopSummary(){ store.set({ shopSummary:null }); },
  setShopType(type){ store.set(st=>({ shop:{...st.shop, type, page:0}, ...logLine(st,`Shop → ${st.shop.shops[type].name}`)})); },
  setShopPage(page){ store.set(st=>({ shop:{...st.shop, page: Math.max(0, page)} })); },
  // spotlight a single shop item on the Main screen (Dota-2 dex panel)
  spotlightItem(item){ store.set(st=>({ shopItemSpotlight: item ? {...item} : null, ...(item?logLine(st,`Main → spotlight: ${item.name}`):{}) })); store.dispatch({type:'item-spotlight'}); },
  clearSpotlight(){ store.set({ shopItemSpotlight:null }); },

  /* ---- hero-to-hero gift: now needs the moderator's blessing ---- */
  requestGive(fromId, toId, entryId){
    store.set(st=>{
      const from = findP(st, fromId), to = findP(st, toId);
      if(!from || !to || fromId===toId) return {};
      const entry = [...(from.weapons||[]), ...(from.items||[])].find(x=>x.id===entryId);
      if(!entry) return {};
      const req = { id:'rq'+Date.now().toString(36)+Math.floor(Math.random()*900+100).toString(36),
                    kind:'give', icon:'bag', status:'pending', entryId, toId,
                    itemName:entry.name, itemType:entry.type||'item',
                    label:`Give ${entry.name} → ${to.name.split(' ')[0]}` };
      return { players: st.players.map(p=> p.id===fromId ? {...p, requests:[...(p.requests||[]), req]} : p),
        ...logLine(st, `${from.name.split(' ')[0]}: ${req.label} (awaiting Keeper)`) };
    });
    store.dispatch({type:'give-request', playerId:fromId});
  },
  // moderator resolves a GIVE request: accept→transfer, else decline
  resolveGive(fromId, reqId, accept){
    const st0 = store.getState();
    const p0 = findP(st0, fromId);
    const r0 = p0 && (p0.requests||[]).find(x=>x.id===reqId);
    if(!r0) return;
    if(!accept){
      store.set(st=>({ players: st.players.map(p=> p.id===fromId ? {...p, requests:p.requests.filter(r=>r.id!==reqId)} : p),
        ...logLine(st, `Declined: ${r0.label}`)}));
      Game.pushFeedback(fromId, { kind:'decline', icon:'bag', title:'Declined', text:`The Keeper denied: ${r0.label}` });
      return;
    }
    let movedName = r0.itemName;
    store.set(st=>{
      const from = findP(st, fromId), to = findP(st, r0.toId);
      if(!from || !to) return { players: st.players.map(p=> p.id===fromId ? {...p, requests:p.requests.filter(r=>r.id!==reqId)} : p) };
      const entry = [...(from.weapons||[]), ...(from.items||[])].find(x=>x.id===r0.entryId);
      const players = st.players.map(p=>{
        if(p.id===fromId) return { ...p, requests:p.requests.filter(r=>r.id!==reqId),
          weapons:(p.weapons||[]).filter(x=>x.id!==r0.entryId), items:(p.items||[]).filter(x=>x.id!==r0.entryId) };
        if(p.id===r0.toId && entry){ const clone={...entry, id:'inv'+Math.random().toString(36).slice(2,9)};
          return { ...p, ...(clone.type==='weapon' ? {weapons:[...(p.weapons||[]), clone]} : {items:[...(p.items||[]), clone]}) }; }
        return p;
      });
      return { players, ...logLine(st, `✦ ${from.name.split(' ')[0]} gives ${movedName} → ${to.name.split(' ')[0]}`) };
    });
    const fromP = findP(store.getState(), fromId), toP = findP(store.getState(), r0.toId);
    audio.play('coin');
    Game.pushFeedback(fromId, { kind:'give', icon:iconFor(movedName), title:'Gift sent', text:`${movedName} → ${toP?toP.name.split(' ')[0]:'a friend'}` });
    Game.pushFeedback(r0.toId, { kind:'receive', icon:iconFor(movedName), title:'A gift!', text:`${fromP?fromP.name.split(' ')[0]:'A friend'} gave you ${movedName}` });
  },
  // ask the moderator to apply an item — on self, or used on another hero.
  // Status starts 'pending'; the moderator may grant it free or require a card.
  requestItemUse(fromId, entry, targetId){
    store.set(st=>{
      const from = findP(st, fromId); if(!from) return {};
      const target = targetId ? findP(st, targetId) : null;
      const onOther = target && targetId!==fromId;
      const label = onOther
        ? `Use ${entry.name} on ${target.name.split(' ')[0]}`
        : `Use ${entry.name}`;
      const req = { id:'rq'+Date.now().toString(36)+Math.floor(Math.random()*900+100).toString(36),
                    kind:'use', icon:'use', status:'pending', itemName:entry.name, itemType:entry.type||'item',
                    targetId: targetId||fromId, label };
      return { players: st.players.map(p=> p.id===fromId ? {...p, requests:[...(p.requests||[]), req]} : p),
        ...logLine(st, `${from.name.split(' ')[0]}: ${label}`) };
    });
    store.dispatch({type:'use-request', playerId:fromId});
  },
  // moderator resolves a USE request: mode = 'free' | 'card' | 'decline'
  resolveUse(playerId, reqId, mode){
    const st0 = store.getState();
    const p0 = findP(st0, playerId);
    const r0 = p0 && (p0.requests||[]).find(x=>x.id===reqId);
    if(!r0) return;
    if(mode==='card'){
      store.set(st=>({ players: st.players.map(p=> p.id===playerId
        ? {...p, requests:p.requests.map(r=> r.id===reqId ? {...r, status:'awaiting-card'} : r)} : p),
        ...logLine(st, `${p0.name.split(' ')[0]}: ${r0.label} — needs a card`)}));
      store.dispatch({type:'use-needs-card', playerId});
      return;
    }
    if(mode==='decline'){
      store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, requests:p.requests.filter(r=>r.id!==reqId)} : p),
        ...logLine(st, `Declined: ${r0.label}`)}));
      Game.pushFeedback(playerId, { kind:'decline', icon:'use', title:'Declined', text:`The Keeper denied: ${r0.label}` });
      return;
    }
    // 'free' — perform now, no card spent
    Game._completeUse(playerId, r0);
  },
  // player fulfils an 'awaiting-card' use by spending their top card
  fulfillUseWithCard(playerId, reqId){
    const st = store.getState();
    const p = findP(st, playerId);
    const r = p && (p.requests||[]).find(x=>x.id===reqId);
    if(!r || r.status!=='awaiting-card') return;
    if(p.cards.locked || !p.cards.deck.length) return;   // need a playable card
    Game.playCard(playerId);                              // reveal + spend top card (locks)
    Game._completeUse(playerId, r);
  },
  declineUse(playerId, reqId){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, requests:p.requests.filter(r=>r.id!==reqId)} : p),
      ...logLine(st, `${findP(st,playerId).name.split(' ')[0]} cancels a use request`)}));
  },
  // shared: apply the use, clear the request, push feedback
  _completeUse(playerId, r){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, requests:p.requests.filter(x=>x.id!==r.id)} : p),
      ...logLine(st, `✦ ${findP(st,playerId).name.split(' ')[0]}: ${r.label}`)}));
    const st2 = store.getState();
    const onOther = r.targetId && r.targetId!==playerId;
    const target = onOther ? findP(st2, r.targetId) : null;
    Game.pushFeedback(playerId, { kind:'use', icon:'use', title: onOther?'Used on ally':'Item used',
      text: onOther ? `${r.itemName} on ${target?target.name.split(' ')[0]:'an ally'}` : `${r.itemName}` });
    if(onOther && target){
      Game.pushFeedback(r.targetId, { kind:'receive', icon:'use', title:'An ally aids you', text:`${findP(st2,playerId).name.split(' ')[0]} used ${r.itemName} on you` });
    }
    store.dispatch({type:'use-done', playerId});
  },
  // transient feedback toast pushed to a player's handset (+ Main glance)
  pushFeedback(playerId, payload){
    store.dispatch({type:'feedback', playerId, ...payload});
  },
  // a scroll summons a shop of the given kind onto the Main screen
  openShop(type){
    Game.activateMain('the Shop', ()=>{
      store.set(st=>({ ...stagesOff(st), shop:{...st.shop, open:true, type, page:0, purchases:[]}, shopSummary:null, ...logLine(st,`The ${st.shop.shops[type].name} opens its doors`)}));
      store.dispatch({type:'shop-summon', shopType:type});
    });
  },
  /* ---- shop editing (moderator) ---- */
  renameShop(type, name){ store.set(st=>{ const shops={...st.shop.shops}; shops[type]={...shops[type], name}; return { shop:{...st.shop, shops}}; }); },
  setShopBg(type, src){ store.set(st=>{ const shops={...st.shop.shops}; shops[type]={...shops[type], bg:src||''}; return { shop:{...st.shop, shops}}; }); },
  addShopItem(type, spec){
    store.set(st=>{
      const shops={...st.shop.shops};
      const it = spec && spec.name ? mkItem(spec.name, spec.type, spec.cost, spec.desc, spec.reqs) : mkItem('New item','item',5,'',[]);
      shops[type]={...shops[type], items:[...shops[type].items, it]};
      return { shop:{...st.shop, shops}};
    });
  },
  updateShopItem(type, id, patch){
    store.set(st=>{
      const shops={...st.shop.shops};
      shops[type]={...shops[type], items: shops[type].items.map(it=> it.id===id ? {...it, ...patch} : it)};
      return { shop:{...st.shop, shops}};
    });
  },
  // moderator stock control: qty (number) or null = unlimited
  setStock(type, id, qty){
    const q = qty==null ? null : Math.max(0, Math.floor(Number(qty)||0));
    store.set(st=>{
      const shops={...st.shop.shops};
      shops[type]={...shops[type], items: shops[type].items.map(it=> it.id===id ? {...it, stock:q} : it)};
      return { shop:{...st.shop, shops}};
    });
  },
  setShopItemImg(type, id, src){
    store.set(st=>{
      const shops={...st.shop.shops};
      shops[type]={...shops[type], items: shops[type].items.map(it=> it.id===id ? {...it, img:src||''} : it)};
      return { shop:{...st.shop, shops}};
    });
  },
  removeShopItem(type, id){
    store.set(st=>{
      const shops={...st.shop.shops};
      shops[type]={...shops[type], items: shops[type].items.filter(it=> it.id!==id)};
      return { shop:{...st.shop, shops}};
    });
  },
  toggleShopItemDesc(type, id){
    store.set(st=>{
      const shops={...st.shop.shops};
      shops[type]={...shops[type], items: shops[type].items.map(it=> it.id===id ? {...it, showDesc:!it.showDesc} : it)};
      return { shop:{...st.shop, shops}};
    });
  },

  /* ============================================================
     SHOPPING — players fill a cart (only what they can afford),
     then send a buy request. The moderator approves; gold is spent
     and the goods land in the hero's inventory.
     ============================================================ */
  cartAdd(playerId, item){
    store.set(st=>{
      const p = findP(st, playerId); if(!p) return {};
      // stock gate — can't cart more than remain (finite stock only; null/undefined = unlimited)
      if(item.stock!=null){
        const inCart = (p.cart||[]).filter(c=>c.id===item.id).length;
        if(inCart >= item.stock) return {};
      }
      const cartTotal = (p.cart||[]).reduce((s,c)=>s+(c.cost||0),0);
      if(cartTotal + (item.cost||0) > p.coins) return {};   // can't afford — block (gold is the only gate)
      const entry = { uid:'cq'+(++__itemSeq)+Date.now().toString(36).slice(-2),
                      id:item.id, name:item.name, type:item.type, cost:item.cost, desc:item.desc, reqs:item.reqs||[], img:item.img||'' };
      return { players: st.players.map(x=> x.id===playerId ? {...x, cart:[...(x.cart||[]), entry]} : x) };
    });
    store.dispatch({type:'cart', playerId});
  },
  cartRemove(playerId, uid){
    store.set(st=>({ players: st.players.map(x=> x.id===playerId ? {...x, cart:(x.cart||[]).filter(c=>c.uid!==uid)} : x) }));
  },
  cartClear(playerId){ store.set(st=>({ players: st.players.map(x=> x.id===playerId ? {...x, cart:[]} : x) })); },
  // turn the cart into a buy request the moderator can accept/decline
  requestBuy(playerId){
    store.set(st=>{
      const p = findP(st, playerId); if(!p || !(p.cart||[]).length) return {};
      const total = p.cart.reduce((s,c)=>s+(c.cost||0),0);
      const req = { id:'rq'+Date.now().toString(36)+Math.floor(Math.random()*900+100).toString(36),
                    kind:'buy', items:p.cart, total,
                    label:`Buy ${p.cart.length} item${p.cart.length>1?'s':''} · ${total}g` };
      return { players: st.players.map(x=> x.id===playerId ? {...x, requests:[...(x.requests||[]), req], cart:[]} : x),
        ...logLine(st, `${p.name.split(' ')[0]} requests to buy ${p.cart.length} item${p.cart.length>1?'s':''} (${total}g)`) };
    });
    store.dispatch({type:'buy-request', playerId});
  },
  resolveBuy(playerId, reqId, accept){
    const pBefore = findP(store.getState(), playerId);
    const reqBefore = pBefore && (pBefore.requests||[]).find(r=>r.id===reqId);
    const granted = (accept && reqBefore) ? (reqBefore.items||[]).map(it=>({ name:it.name, type:it.type, img:it.img||'' })) : [];
    store.set(st=>{
      const p = findP(st, playerId); if(!p) return {};
      const req = (p.requests||[]).find(r=>r.id===reqId);
      if(!req) return {};
      let players = st.players.map(x=> x.id===playerId ? {...x, requests:x.requests.filter(r=>r.id!==reqId)} : x);
      if(accept){
        const total = req.total || (req.items||[]).reduce((s,c)=>s+(c.cost||0),0);
        const weapons = (req.items||[]).filter(it=>it.type==='weapon').map(invFromItem);
        const goods   = (req.items||[]).filter(it=>it.type!=='weapon').map(invFromItem);
        players = players.map(x=> x.id===playerId
          ? {...x, coins: Math.max(0, x.coins-total), weapons:[...x.weapons, ...weapons], items:[...x.items, ...goods]}
          : x);
        // decrement stock for each bought item (search every shop; finite stock only)
        const sold = {}; (req.items||[]).forEach(it=>{ sold[it.id]=(sold[it.id]||0)+1; });
        const shops = {...st.shop.shops};
        Object.keys(shops).forEach(tk=>{
          if(shops[tk].items.some(it=>sold[it.id])){
            shops[tk] = {...shops[tk], items: shops[tk].items.map(it=>
              (sold[it.id] && it.stock!=null) ? {...it, stock: Math.max(0, it.stock - sold[it.id])} : it )};
          }
        });
        // record the line for the close-out purchase summary
        const record = { playerId, name: p.name.split(' ')[0],
          items: (req.items||[]).map(it=>({ name:it.name, type:it.type })), total, at:Date.now() };
        const purchases = [...(st.shop.purchases||[]), record];
        return { players, shop:{...st.shop, shops, purchases}, ...logLine(st, `✦ ${p.name.split(' ')[0]} bought ${(req.items||[]).length} item(s) for ${total}g`) };
      }
      return { players, ...logLine(st, `Declined ${p.name.split(' ')[0]}'s purchase`) };
    });
    if(accept){
      store.dispatch({type:'coins', playerId}); audio.play('coin');
      // tell the Main + player screens to fly the goods home
      store.dispatch({type:'purchase', playerId, items:granted});
      Game.pushFeedback(playerId, { kind:'buy', icon:'coin', title:'Purchase granted', text:`Your goods are in your packs` });
    }
  },
  setGate(patch){ store.set(st=>({ gate:{...st.gate, ...patch}})); },
  // generate a fresh beat — length 4·8·10 (moderator-chosen or random),
  // and a countdown the moderator sets (seconds). The beat plays first; the
  // timer only starts after the demonstration finishes (see armGate).
  generateGate(length, seconds){
    const len = [4,8,10].includes(Number(length)) ? Number(length) : [4,8,10][Math.floor(Math.random()*3)];
    const seq = Array.from({length:len}, ()=>Math.floor(Math.random()*4));
    const timeLimit = (Number(seconds)>0 ? Number(seconds) : len*2.5) * 1000;
    store.set(st=>({ gate:{...st.gate, seq, progress:[], solved:false, replays:st.gate.maxReplays, timeLimit, deadline:0, demo:false},
      ...logLine(st,`New beat · ${len} notes · ${Math.round(timeLimit/1000)}s`)}));
    return seq;
  },
  // arm the gate: put it on Main, demonstrate the order, then start the timer
  armGate(){
    let demoMs = 0;
    store.set(st=>{
      let seq = st.gate.seq;
      if(!seq || !seq.length){
        const lengths=[4,8,10]; const len=lengths[Math.floor(Math.random()*lengths.length)];
        seq = Array.from({length:len}, ()=>Math.floor(Math.random()*4));
      }
      const timeLimit = st.gate.timeLimit || seq.length*2500;
      demoMs = 700*seq.length + 700;
      return { ...stagesOff(st), gate:{...st.gate, open:true, seq, progress:[], solved:false, demo:true, demoId:Date.now(), deadline:0, timeLimit},
        ...logLine(st,`⛬ Gate armed — ${seq.length} beats play`) };
    });
    setTimeout(()=>store.set(s=>({ gate:{...s.gate, demo:false, progress:[], deadline: Date.now()+(s.gate.timeLimit||10000)} })), demoMs);
  },
  // replay the demonstration on the Main screen (free — doesn't cost a chance) and restart the timer
  playDemo(){
    let demoMs = 0;
    store.set(s=>{ demoMs = 700*((s.gate.seq&&s.gate.seq.length)||4) + 700; return { gate:{...s.gate, open:true, demo:true, demoId:Date.now(), progress:[], solved:false, deadline:0}}; });
    setTimeout(()=>store.set(s=>({ gate:{...s.gate, demo:false, progress:[], deadline: Date.now()+(s.gate.timeLimit||10000)} })), demoMs);
  },
  closeGate(){
    store.set(st=>({ gate:{...st.gate, open:false, demo:false, progress:[], solved:false, deadline:0}, ...logLine(st,'Gate hidden')}));
  },
  // moderator sets which scene the room enters once the gate opens
  setGateNextScene(scene){ store.set(st=>({ gate:{...st.gate, nextScene:scene||''} })); },
  // pass through the open gate → close it and transition to the chosen scene
  enterAfterGate(){
    const st = store.getState();
    const scene = st.gate.nextScene;
    store.set(s=>({ gate:{...s.gate, open:false, demo:false, progress:[], solved:false, deadline:0},
      ...(scene ? { scene } : {}), ...logLine(s, scene ? `The party passes through → ${scene}` : 'The party passes through the gate')}));
    if(scene) store.dispatch({type:'scene', scene});
  },
  // a player taps one of their assigned note buttons (note index 0..3).
  // wrong taps just reset progress (no chance lost) — only the timer costs a chance.
  gateInput(noteIndex, playerId){
    const st = store.getState();
    const g = st.gate;
    if(!g.open || g.solved || g.demo) return;
    if(g.deadline && Date.now() > g.deadline) return;   // timer already expired — wait for the next chance
    const expected = g.seq[g.progress.length];
    if(noteIndex===expected){
      const progress = [...g.progress, noteIndex];
      const solved = progress.length===g.seq.length;
      store.set(s=>({ gate:{...s.gate, progress, solved}}));
      store.dispatch({type:'gate-note', noteIndex, pos: progress.length-1, playerId});
      if(solved){
        store.set(s=>({ gate:{...s.gate, deadline:0}, ...logLine(s,'✨ The beat is true — the gate yields')}));
        store.dispatch({type:'gate-open'});
      }
    } else {
      // wrong beat — reset progress only; the order must be re-entered from the top.
      // firstBeat: a wrong tap on the very first note is forgiving — no red/shake feedback.
      const firstBeat = g.progress.length===0;
      store.set(s=>({ gate:{...s.gate, progress:[]}}));
      store.dispatch({type:'gate-wrong', noteIndex, playerId, out:false, firstBeat});
    }
  },
  // countdown ran out before the order was completed — costs one chance.
  // `deadline` is the value the caller saw expire (guards against double-firing).
  gateTimeout(deadline){
    const st = store.getState();
    const g = st.gate;
    if(!g.open || g.solved || g.demo) return;
    if(!g.deadline || g.deadline!==deadline || Date.now() < g.deadline) return;
    const replays = Math.max(0, g.replays-1);
    if(replays>0){
      const demoMs = 700*((g.seq&&g.seq.length)||4) + 700;
      store.set(s=>({ gate:{...s.gate, replays, progress:[], demo:true, demoId:Date.now(), deadline:0},
        ...logLine(s,`⌛ Time's up — beat lost · ${replays} chances left`)}));
      store.dispatch({type:'gate-timeout', out:false});
      setTimeout(()=>store.set(s=>({ gate:{...s.gate, demo:false, progress:[], deadline: Date.now()+(s.gate.timeLimit||10000)} })), demoMs);
    } else {
      store.set(s=>({ gate:{...s.gate, replays:0, progress:[], deadline:0},
        ...logLine(s,'⌛ Time\u2019s up — the seal holds fast')}));
      store.dispatch({type:'gate-timeout', out:true});
    }
  },
  resetGate(){
    store.set(st=>({ gate:{...st.gate, progress:[], solved:false, replays: st.gate.maxReplays, deadline:0, demo:false}, ...logLine(st,'Gate reset')}));
  },

  /* ============================================================
     STORY — cinematic video scenes. Videos live on Cloudflare Stream;
     only small scene metadata travels in the synced game state. The
     moderator plays a scene full-screen on the Main display.
     ============================================================ */
  // add a freshly-uploaded scene (still processing on Cloudflare). Returns its id.
  addStoryScene({ assetId=null } = {}){
    const id = 'sc'+Date.now().toString(36)+Math.floor(Math.random()*900+100).toString(36);
    store.set(st=>{
      const order = st.story.scenes.length;
      const scene = { id, title:`Scene ${order+1}`, order, assetId, src:'', poster:'', status:'processing', shown:false };
      return { story:{...st.story, scenes:[...st.story.scenes, scene]}, ...logLine(st,`Story · ${scene.title} added`) };
    });
    return id;
  },
  // Cloudflare finished processing → fill the playback (HLS) + poster URLs
  setStorySceneReady(id, src, poster){
    store.set(st=>({ story:{...st.story, scenes: st.story.scenes.map(s=> s.id===id ? {...s, src:src||s.src, poster:poster||s.poster, status:'ready'} : s)} }));
  },
  renameStoryScene(id, title){
    store.set(st=>({ story:{...st.story, scenes: st.story.scenes.map(s=> s.id===id ? {...s, title:(title||'').toString().slice(0,48)||s.title} : s)} }));
  },
  removeStoryScene(id){
    store.set(st=>{
      const scenes = st.story.scenes.filter(s=>s.id!==id).map((s,i)=>({...s, order:i}));
      return { story:{...st.story, scenes, playing: st.story.playing===id ? null : st.story.playing} };
    });
  },
  // move a scene up (-1) or down (+1) in the sequence
  reorderStoryScene(id, dir){
    store.set(st=>{
      const arr = [...st.story.scenes];
      const i = arr.findIndex(s=>s.id===id); if(i<0) return {};
      const j = i + (dir<0?-1:1); if(j<0 || j>=arr.length) return {};
      [arr[i],arr[j]] = [arr[j],arr[i]];
      return { story:{...st.story, scenes: arr.map((s,k)=>({...s, order:k}))} };
    });
  },
  // play a scene full-screen on the Main display (synced to all devices)
  playStoryScene(id){
    store.set(st=>{
      const sc = st.story.scenes.find(s=>s.id===id);
      if(!sc) return {};
      return { story:{...st.story, playing:id}, ...logLine(st,`▶ Story: ${sc.title} → Main screen`) };
    });
    store.dispatch({type:'story-play', id});
  },
  // stop playback. markShown=true (the clip ended) flags the scene as "Showed".
  stopStoryScene(markShown){
    store.set(st=>{
      const id = st.story.playing;
      const scenes = (markShown && id) ? st.story.scenes.map(s=> s.id===id ? {...s, shown:true} : s) : st.story.scenes;
      return { story:{...st.story, scenes, playing:null}, ...(markShown && id ? logLine(st,'Story scene finished') : {}) };
    });
    store.dispatch({type:'story-stop'});
  },

  /* ============================================================
     CIRCUIT LOCK — the second gate type (connecting-path puzzle)
     ============================================================ */
  // build a fresh lock: fixed entry/exit per ring, a guaranteed solution, then
  // scramble each ring off its solved rotation. Owners auto-distributed to the
  // players present. difficulty: 'easy' (symmetric) · 'normal' · 'hard' (+surge).
  pushCircuit(difficulty){
    const diff = ['easy','normal','hard'].includes(difficulty) ? difficulty : 'normal';
    // arc span (entry→exit offset) per ring — asymmetric so the lock isn't trivial
    const offsets = diff==='easy' ? [3,3,3,3] : diff==='hard' ? [3,5,2,6] : [2,5,3,4];
    const powerSlot = 0;
    // entry fixed at slot 0 on each ring; exit = entry + offset
    const base = CIRCUIT_META.map((m,i)=>({ entrySlot:0, exitSlot: mod8(0 + offsets[i]) }));
    // derive the solving rotations: align ring0 entry→power, then each entry→prev exit
    const solution = [];
    let upstreamExit = powerSlot;     // ring0 must catch power at powerSlot
    base.forEach((b,i)=>{
      const sol = mod8(upstreamExit - b.entrySlot);
      solution.push(sol);
      upstreamExit = mod8(b.exitSlot + sol);   // this ring's solved exit feeds the next
    });
    const coreSlot = upstreamExit;    // core intake sits where the solved inner ring exits
    const players = store.getState().players;
    const owners = ringOwners(players);
    const rings = base.map((b,i)=>{
      // scramble: a rotation that is NOT congruent to the solution (mod 8)
      let rot;
      do { rot = Math.floor(Math.random()*8); } while(mod8(rot - solution[i])===0);
      return { entrySlot:b.entrySlot, exitSlot:b.exitSlot, rotation:rot, owner:owners[i] };
    });
    const surge = diff==='hard';
    store.set(st=>({
      ...stagesOff(st),
      circuit:{ ...st.circuit, open:true, rings, powerSlot, coreSlot, solution,
        complete:false, difficulty:diff, surge, surgeAt: surge ? Date.now()+ (st.circuit.surgeInterval||30000) : 0,
        hint:-1, hintId:0 },
      ...logLine(st,`⛬ Circuit Lock armed · ${diff}${surge?' · surge on':''}`)
    }));
    store.dispatch({type:'scene'});
    store.dispatch({type:'circuit-push'});
  },
  // rotate one ring 45° (dir −1 left / +1 right); re-evaluate & detect completion
  rotateRing(ringIndex, dir){
    const c = store.getState().circuit;
    if(!c.open || c.complete) return;
    if(ringIndex<0 || ringIndex>=c.rings.length) return;
    const step = dir<0 ? -1 : 1;
    const rings = c.rings.map((r,i)=> i===ringIndex ? {...r, rotation: r.rotation + step} : r);
    const complete = circuitLiveness({...c, rings}).complete;
    store.set(s=>({ circuit:{...s.circuit, rings, complete} }));
    // (no circuit-rotate event: the Main screen's turn sound is state-driven now.)
    if(complete){
      store.set(s=>({ circuit:{...s.circuit, complete:true, surge:false, surgeAt:0, hint:-1},
        ...logLine(s,'⚡ The circuit completes — the gate yields')}));
      store.dispatch({type:'circuit-open'});
    }
  },
  // hand a ring to a player (a player may hold two when fewer than 4 are present)
  assignRing(ringIndex, playerId){
    store.set(s=>({ circuit:{...s.circuit, rings: s.circuit.rings.map((r,i)=> i===ringIndex ? {...r, owner:playerId} : r)},
      ...logLine(s,`Ring ${CIRCUIT_META[ringIndex]?CIRCUIT_META[ringIndex].label:ringIndex} → ${findP(s,playerId)?findP(s,playerId).name.split(' ')[0]:'—'}`)}));
    store.dispatch({type:'circuit-assign', ringIndex, playerId});
  },
  // expose the live evaluation to callers (returns whether the lock is complete)
  evaluateCircuit(){
    const c = store.getState().circuit;
    const live = circuitLiveness(c);
    if(live.complete && !c.complete){
      store.set(s=>({ circuit:{...s.circuit, complete:true, surge:false, surgeAt:0}}));
      store.dispatch({type:'circuit-open'});
    }
    return live.complete;
  },
  // re-scramble every ring off its solution (a fresh attempt, same lock)
  resetLock(){
    store.set(s=>{
      const sol = s.circuit.solution || [];
      const rings = s.circuit.rings.map((r,i)=>{
        let rot; do { rot = Math.floor(Math.random()*8); } while(sol.length && mod8(rot - sol[i])===0);
        return {...r, rotation: rot};
      });
      return { circuit:{...s.circuit, rings, complete:false, hint:-1,
        surgeAt: s.circuit.surge ? Date.now()+ (s.circuit.surgeInterval||30000) : 0},
        ...logLine(s,'The lock re-scrambles') };
    });
    store.dispatch({type:'circuit-reset'});
  },
  // surge: jolt one random ring to a new rotation (time pressure on hard runs)
  triggerSurge(){
    const c = store.getState().circuit;
    if(!c.open || c.complete || !c.rings.length) return;
    const idx = Math.floor(Math.random()*c.rings.length);
    const jump = 1 + Math.floor(Math.random()*6);   // 1..6 — a visible lurch
    const rings = c.rings.map((r,i)=> i===idx ? {...r, rotation: r.rotation + jump} : r);
    const complete = circuitLiveness({...c, rings}).complete;
    store.set(s=>({ circuit:{...s.circuit, rings, complete,
      surgeAt: Date.now()+ (s.circuit.surgeInterval||30000)},
      ...logLine(s,`⚡ Surge — the ${CIRCUIT_META[idx].label} ring lurches`)}));
    store.dispatch({type:'circuit-surge', ringIndex:idx});
    if(complete){ store.set(s=>({circuit:{...s.circuit, complete:true, surge:false, surgeAt:0}})); store.dispatch({type:'circuit-open'}); }
  },
  setCircuitSurge(on){
    store.set(s=>({ circuit:{...s.circuit, surge:!!on, surgeAt: on && !s.circuit.complete ? Date.now()+ (s.circuit.surgeInterval||30000) : 0}}));
  },
  // flash one ring's solved position on the Main screen (auto-clears)
  grantHint(ringIndex){
    const id = Date.now();
    store.set(s=>({ circuit:{...s.circuit, hint:ringIndex, hintId:id}, ...logLine(s,`Hint → ${CIRCUIT_META[ringIndex]?CIRCUIT_META[ringIndex].label:ringIndex} ring`)}));
    store.dispatch({type:'circuit-hint', ringIndex});
    setTimeout(()=>store.set(s=> (s.circuit.hintId===id ? { circuit:{...s.circuit, hint:-1} } : {})), 2400);
  },
  // skip the puzzle — force the lock open
  forceOpen(){
    store.set(s=>({ circuit:{...s.circuit, complete:true, surge:false, surgeAt:0, hint:-1}, ...logLine(s,'The Keeper forces the lock')}));
    store.dispatch({type:'circuit-open'});
  },
  closeCircuit(){
    store.set(st=>({ circuit:{...st.circuit, open:false, surge:false, surgeAt:0, hint:-1}, ...logLine(st,'Circuit Lock hidden')}));
  },
  setCircuitNextScene(scene){ store.set(st=>({ circuit:{...st.circuit, nextScene:scene||''} })); },
  enterAfterCircuit(){
    const st = store.getState();
    const scene = st.circuit.nextScene;
    store.set(s=>({ circuit:{...s.circuit, open:false, complete:false, surge:false, surgeAt:0, hint:-1},
      ...(scene ? { scene } : {}), ...logLine(s, scene ? `The party passes through → ${scene}` : 'The party passes through the gate')}));
    if(scene) store.dispatch({type:'scene', scene});
  },
  setEnemy(patch){ store.set(st=>({ enemy:{...st.enemy, ...patch}, ...(patch.active?{mainMap:null}:{})})); },
  damageEnemy(amount){
    store.set(st=>({ enemy:{...st.enemy, hp:clamp(st.enemy.hp-amount,0,st.enemy.maxHp)}, ...logLine(st,`Enemy −${amount}`)}));
    store.dispatch({type:'enemy-hit', dead: store.getState().enemy.hp<=0});
  },
  healEnemy(amount){ store.set(st=>({ enemy:{...st.enemy, hp:clamp(st.enemy.hp+amount,0,st.enemy.maxHp)}})); },

  /* ============================================================
     ACTION SEQUENCE — the battle field
     ============================================================ */
  startBattle(){
    store.set(st=>({ ...stagesOff(st), battle:{...st.battle, active:true, round:1, turn:null}, scene:'battle',
      ...logLine(st,'⚔ The action begins')}));
    store.dispatch({type:'scene'});
    store.dispatch({type:'battle-start'});
  },
  endBattle(){
    store.set(st=>({ battle:{...st.battle, active:false, turn:null, acted:null, lastAction:null, banner:null}, ...logLine(st,'Battle ends')}));
  },
  // moderator pushes an enemy onto the field
  pushEnemy(spec){
    const id = 'e'+Date.now().toString(36)+Math.floor(Math.random()*900+100).toString(36);
    const e = { id, name:spec.name||'Enemy', hp:spec.hp||10, maxHp:spec.hp||10,
                intel:false, stats:spec.stats || (spec.desc ? {} : {AC:12,STR:'+2'}),
                src:spec.src||'', desc:spec.desc||'' };
    store.set(st=>({ battle:{...st.battle, active:true, enemies:[...st.battle.enemies, e]},
      ...(st.scene!=='battle'?{scene:'battle'}:{}), ...logLine(st,`▶ ${e.name} enters the field`)}));
    store.dispatch({type:'enemy-enter', id, name:e.name});
    return id;
  },
  removeEnemy(id){
    store.set(st=>({ battle:{...st.battle, enemies:st.battle.enemies.filter(e=>e.id!==id),
      turn: st.battle.turn && st.battle.turn.id===id ? null : st.battle.turn}}));
  },
  damageBattleEnemy(id, amount){
    amount = Number(amount)||0;
    store.set(st=>({ battle:{...st.battle, enemies: st.battle.enemies.map(e=> e.id===id ? {...e, hp:clamp(e.hp-amount,0,e.maxHp)} : e)},
      ...logLine(st,`Enemy −${amount}`)}));
    const e = store.getState().battle.enemies.find(x=>x.id===id);
    store.dispatch({type:'battle-enemy-hit', id, dead: e && e.hp<=0});
  },
  healBattleEnemy(id, amount){
    amount = Number(amount)||0;
    store.set(st=>({ battle:{...st.battle, enemies: st.battle.enemies.map(e=> e.id===id ? {...e, hp:clamp(e.hp+amount,0,e.maxHp)} : e)}}));
  },
  toggleEnemyIntel(id){
    store.set(st=>({ battle:{...st.battle, enemies: st.battle.enemies.map(e=> e.id===id ? {...e, intel:!e.intel} : e)}}));
  },
  // whose turn — players or a specific enemy. Clears the per-turn action.
  setTurn(turn){
    store.set(st=>({ battle:{...st.battle, turn, acted:null, lastAction:null}}));
    if(turn) store.dispatch({type:'turn', turn});
  },
  nextRound(){
    store.set(st=>({ battle:{...st.battle, round:st.battle.round+1, turn:null, acted:null, lastAction:null}, ...logLine(st,`Round ${st.battle.round+1}`)}));
    store.dispatch({type:'round'});
  },
  // a player plays their top action card — usable ANY time, not only in
  // combat. After playing, the hero is LOCKED out of playing another until
  // the moderator allows it. The card's value (+1/+2/+5) is revealed on play.
  playCard(playerId){
    const st = store.getState();
    const p = st.players.find(x=>x.id===playerId);
    if(!p || p.dead) return;
    if(p.cards.locked) return;            // waiting on the moderator (per-player on/off)
    if(!p.cards.deck.length) return;      // deck spent
    const top = p.cards.deck[0];
    const first = p.name.split(' ')[0];
    const b = st.battle;
    const onMyTurn = b.active && b.turn && b.turn.kind==='player' && b.turn.id===playerId && b.acted!==playerId;
    const premium = top.tier==='premium';
    const cardScope = (st.cardPlay && st.cardPlay.public===false) ? 'private' : 'public';
    store.set(s=>({
      players: s.players.map(x=> x.id===playerId
        ? {...x, cards:{...x.cards, deck:x.cards.deck.slice(1), played:[top, ...x.cards.played], locked:true}}
        : x),
      ...(onMyTurn ? { battle:{...s.battle, acted:playerId, lastAction:{playerId, action:'+'+top.value, value:top.value, tier:top.tier},
        banner:{text:`${first} plays +${top.value}!`, kind: premium?'enemy':'neutral'}} } : {}),
      ...logLine(s, `▸ ${first} plays a +${top.value} card${premium?' ✦ PREMIUM':''}`)
    }));
    store.dispatch({type:'card', playerId, value:top.value, tier:top.tier, scope:cardScope});
    store.dispatch({type:'action-card', playerId, action:'+'+top.value, value:top.value, tier:top.tier});
    if(onMyTurn) setTimeout(()=>store.set(s=>({ battle:{...s.battle, banner:null}})), 2200);
  },
  // a player spends their top card on their turn (battle input).
  // action='Pass' holds without spending; anything else plays the top card.
  playerAction(playerId, action){
    const st = store.getState();
    const b = st.battle;
    if(!b.active || !b.turn || b.turn.kind!=='player' || b.turn.id!==playerId) return;
    if(b.acted===playerId) return;
    const p = st.players.find(x=>x.id===playerId);
    if(!p) return;
    const spend = action!=='Pass';
    if(spend && (p.cards.locked || !p.cards.deck.length)) return;   // can't spend → must Pass
    const top = spend ? p.cards.deck[0] : null;
    const first = p.name.split(' ')[0];
    const lbl = action==='Pass' ? `${first} holds their turn` : `${first} plays ${action} +${top.value}!`;
    store.set(s=>({
      players: spend ? s.players.map(x=> x.id===playerId
        ? {...x, cards:{...x.cards, deck:x.cards.deck.slice(1), played:[top, ...x.cards.played], locked:true}}
        : x) : s.players,
      battle:{...s.battle, acted:playerId, lastAction:{playerId, action: spend?`${action} +${top.value}`:'Pass', value: top?top.value:0},
        banner:{text: lbl, kind:'neutral'}},
      ...logLine(s, action==='Pass' ? `▸ ${first} passes` : `▸ ${first} plays ${action} (+${top.value})`)
    }));
    store.dispatch({type:'action-card', playerId, action});
    if(spend) store.dispatch({type:'card', playerId, value:top.value, tier:top.tier});
    audio.play(action==='Pass' ? 'beat' : 'card');
    setTimeout(()=>store.set(s=>({ battle:{...s.battle, banner:null}})), 1800);
  },
  // an enemy strikes — flash the battle + (optionally) damage a player
  enemyStrike(enemyId, playerId, amount){
    const e = store.getState().battle.enemies.find(x=>x.id===enemyId);
    store.set(st=>({ battle:{...st.battle, banner:{text:`${e?e.name:'Enemy'} strikes!`, kind:'enemy'}}}));
    store.dispatch({type:'enemy-strike', enemyId, playerId});
    if(playerId && amount){ setTimeout(()=>Game.triggerDamage(playerId, amount), 350); }
    setTimeout(()=>store.set(st=>({ battle:{...st.battle, banner:null}})), 1800);
  },
  setBattleBanner(text, kind='neutral'){
    store.set(st=>({ battle:{...st.battle, banner: text?{text,kind}:null}}));
    if(text) setTimeout(()=>store.set(st=>({ battle:{...st.battle, banner:null}})), 1800);
  },
  setMoon(n){ store.set({ moon: clamp(n,1,5) }); },
  setEntryArtifact(artifact){ store.set(st=>({ entry:{...st.entry, artifact}, ...logLine(st,`Entry artifact → ${artifact}`)})); },
  // ---- moderator inventory control: remove, break, or transfer a held item ----
  _findInv(p, itemId){
    const wi = (p.weapons||[]).find(x=>x.id===itemId);
    if(wi) return { item:wi, slot:'weapons' };
    const ii = (p.items||[]).find(x=>x.id===itemId);
    if(ii) return { item:ii, slot:'items' };
    return null;
  },
  removeInvItem(playerId, itemId){
    store.set(st=>({ players: st.players.map(p=> p.id!==playerId ? p
      : {...p, weapons:(p.weapons||[]).filter(x=>x.id!==itemId), items:(p.items||[]).filter(x=>x.id!==itemId)} ),
      ...logLine(st, `Removed an item from ${(findP(st,playerId)||{name:'?'}).name.split(' ')[0]}`) }));
    store.dispatch({type:'inv-change', playerId});
  },
  breakInvItem(playerId, itemId){
    store.set(st=>({ players: st.players.map(p=>{
      if(p.id!==playerId) return p;
      const flip = (arr)=> (arr||[]).map(x=> x.id===itemId ? {...x, broken:!x.broken} : x);
      return {...p, weapons:flip(p.weapons), items:flip(p.items)};
    }), ...logLine(st, `Toggled broken on a ${(findP(st,playerId)||{name:'?'}).name.split(' ')[0]} item`) }));
    store.dispatch({type:'inv-change', playerId});
  },
  transferInvItem(fromId, toId, itemId){
    if(fromId===toId) return;
    const fromP = findP(store.getState(), fromId);
    const found = fromP && Game._findInv(fromP, itemId);
    if(!found) return;
    const moved = {...found.item};
    store.set(st=>({ players: st.players.map(p=>{
      if(p.id===fromId) return {...p, weapons:(p.weapons||[]).filter(x=>x.id!==itemId), items:(p.items||[]).filter(x=>x.id!==itemId)};
      if(p.id===toId){ const slot = moved.type==='weapon' ? 'weapons' : 'items'; return {...p, [slot]:[...(p[slot]||[]), moved]}; }
      return p;
    }), ...logLine(st, `Moved ${moved.name} → ${(findP(st,toId)||{name:'?'}).name.split(' ')[0]}`) }));
    store.dispatch({type:'inv-change', playerId:toId});
  },
  grantItem(playerId, item){
    // item may be a plain name (host grant / craft) or a spec object
    const spec = typeof item==='string' ? { name:item, type:'item' } : item;
    const entry = invFromItem({ name:spec.name, type:spec.type||'item', desc:spec.desc||'', reqs:spec.reqs||[] });
    const toWeapon = entry.type==='weapon';
    store.set(st=>({ players: st.players.map(p=> p.id===playerId
      ? {...p, ...(toWeapon ? {weapons:[...p.weapons, entry]} : {items:[...p.items, entry]})} : p),
      ...logLine(st,`Granted ${entry.name} → ${findP(st,playerId).name.split(' ')[0]}`)}));
  },

  /* ============================================================
     XP · LEVELS · REWARDS — heroes earn XP when they rest. The
     moderator decides how much; crossing a threshold levels them up,
     and the moderator then awards coins, a card upgrade, and/or items.
     ============================================================ */
  // grant XP and roll any level-ups. Returns {gained, level} so the host UI
  // can decide whether to open the reward window.
  giveXp(playerId, amount){
    amount = Math.max(0, Number(amount)||0);
    const st = store.getState();
    const p = findP(st, playerId);
    if(!p) return { gained:0, level:1 };
    let xp = (p.xp||0) + amount;
    let level = p.level||1;
    let gained = 0;
    while(xp >= xpNeeded(level)){ xp -= xpNeeded(level); level++; gained++; if(level>50) break; }
    store.set(s=>({ players: s.players.map(x=> x.id===playerId ? {...x, xp, level} : x),
      ...logLine(s, `${p.name.split(' ')[0]} earns ${amount} XP${gained?` · Level ${level}!`:''}`)}));
    store.dispatch({type:'xp', playerId, amount, gained});
    return { gained, level, xp };
  },
  setXp(playerId, xp, level){
    store.set(s=>({ players: s.players.map(x=> x.id===playerId ? {...x, xp:Math.max(0,Number(xp)||0), level:Math.max(1,Number(level)||1)} : x) }));
  },
  // moderator awards a level-up bundle: coins, an optional card upgrade
  // ('1to2' | '2to5' | '1to5' | 'none'), and any items/weapons.
  applyLevelReward(playerId, reward){
    const coins = Math.max(0, Number(reward.coins)||0);
    const card = reward.card || 'none';
    const items = reward.items || [];
    store.set(s=>{
      const players = s.players.map(p=>{
        if(p.id!==playerId) return p;
        let np = {...p, coins: (p.coins||0) + coins};
        if(card!=='none'){
          const from = card.charAt(0)==='1' ? 1 : 2;
          const to = card.endsWith('5') ? 5 : 2;
          const tier = to>=5 ? 'premium' : 'rare';
          let done = false;
          let deck = np.cards.deck.map(c=>{ if(!done && c.value===from){ done=true; return {...c, value:to, tier}; } return c; });
          let played = np.cards.played;
          if(!done){ played = np.cards.played.map(c=>{ if(!done && c.value===from){ done=true; return {...c, value:to, tier}; } return c; }); }
          np = {...np, cards:{...np.cards, deck, played}};
        }
        if(items.length){
          const weapons = items.filter(it=>it.type==='weapon').map(invFromItem);
          const goods   = items.filter(it=>it.type!=='weapon').map(invFromItem);
          np = {...np, weapons:[...np.weapons, ...weapons], items:[...np.items, ...goods]};
        }
        return np;
      });
      return { players, ...logLine(s, `★ ${findP(s,playerId).name.split(' ')[0]} leveled up — reward granted`) };
    });
    const np = findP(store.getState(), playerId);
    Game.celebrateLevel(playerId, { level: np.level, coins, card, items: items.map(it=>({name:it.name, type:it.type})) });
    if(coins) store.dispatch({type:'coins', playerId});
  },
  // fire the shared level-up celebration (Main + that player), auto-clears
  celebrateLevel(playerId, payload){
    const at = Date.now();
    store.set(st=>({ levelup: { playerId, at, ...payload } }));
    store.dispatch({type:'levelup', playerId, level:payload.level});
    audio.play('revive');
    setTimeout(()=>{ const s=store.getState(); if(s.levelup && s.levelup.at===at) store.set({ levelup:null }); }, 9000);
  },
  clearLevelUp(){ store.set({ levelup:null }); },
  // ---- STAT POINTS — moderator grants N points on level-up; the player then
  //      spends them to raise stats of their choice (window pops on their phone). ----
  grantStatPoints(playerId, n){
    n = Math.max(0, Math.floor(Number(n)||0)); if(!n) return;
    store.set(s=>({ players: s.players.map(p=> p.id===playerId ? {...p, statPoints:(p.statPoints||0)+n} : p),
      ...logLine(s, `★ ${(findP(s,playerId)||{name:'?'}).name.split(' ')[0]} gains ${n} stat point${n>1?'s':''} to spend`) }));
    store.dispatch({type:'statgrant', playerId, n});
  },
  spendStatPoint(playerId, stat, delta){
    delta = delta>0?1:-1;
    store.set(s=>{
      const p = findP(s,playerId); if(!p) return {};
      const spent = p.spentStat || {};
      const cur = Number((p.stats||{})[stat])||0;
      const have = p.statPoints||0;
      if(delta>0){ if(have<=0 || cur>=5) return {}; }
      else { if((spent[stat]||0)<=0) return {}; }   // can only refund points spent THIS session
      const nextStats = {...(p.stats||{}), [stat]: Math.max(0, Math.min(5, cur+delta))};
      const nextSpent = {...spent, [stat]: Math.max(0, (spent[stat]||0)+delta)};
      return { players: s.players.map(x=> x.id===playerId
        ? {...x, stats:nextStats, statPoints: have - delta, spentStat: nextSpent } : x) };
    });
  },
  // player confirms — close the window, clear the session ledger
  finishStatSpend(playerId){
    store.set(s=>({ players: s.players.map(p=> p.id===playerId ? {...p, statPoints:0, spentStat:{}} : p) }));
    store.dispatch({type:'statdone', playerId});
  },
  // ---- DICE — the moderator flips a per-player toggle to let a hero roll (their
  //      dice then shines). Global public flag decides if the result also shows on
  //      the Main screen. Result is stored per player for the party panel. ----
  setDicePublic(pub){ store.set(s=>({ dice:{...s.dice, public: !!pub} })); },
  setCardPublic(pub){ store.set(s=>({ cardPlay:{ public: !!pub } })); },
  allowDice(playerId){   // toggle this hero's dice ON (one roll)
    store.set(s=>({ dice:{...s.dice, allow:{...(s.dice.allow||{}), [playerId]:true}} }));
    store.dispatch({type:'dice-allow', playerId});
  },
  denyDice(playerId){    // toggle OFF
    store.set(s=>{ const allow={...(s.dice.allow||{})}; delete allow[playerId];
      return { dice:{...s.dice, allow} }; });
    store.dispatch({type:'dice-deny', playerId});
  },
  rollDice(playerId){
    const d = store.getState().dice;
    if(!(d.allow||{})[playerId]) return null;        // not allowed → no roll
    const scope = d.public ? 'public' : 'private';
    const a = 1+Math.floor(Math.random()*6), b = 1+Math.floor(Math.random()*6);
    const at = Date.now();
    const p = findP(store.getState(), playerId);
    const roll = { playerId, name: p?p.name.split(' ')[0]:'A hero', a, b, sum:a+b, at, scope };
    store.set(s=>{ const allow={...(s.dice.allow||{})}; delete allow[playerId];   // consume the toggle
      return { dice:{...s.dice, last:roll, allow, results:{...(s.dice.results||{}), [playerId]:roll},
        main: scope==='public'?roll:s.dice.main} }; });
    store.dispatch({type:'dice', playerId, a, b, sum:a+b, scope });
    if(scope==='public'){
      setTimeout(()=>{ const s=store.getState(); if(s.dice.main && s.dice.main.at===at) store.set(st=>({ dice:{...st.dice, main:null} })); }, 5600);
    }
    return roll;
  },
  // requests (demo): host can also resolve
  resolveRequest(playerId, reqId, accept){
    store.set(st=>{
      const players = st.players.map(p=>{
        if(p.id!==playerId) return p;
        return {...p, requests: p.requests.filter(r=>r.id!==reqId)};
      });
      const p = findP(st, playerId);
      const r = p.requests.find(x=>x.id===reqId);
      return { players, ...logLine(st, `${accept?'Accepted':'Denied'} ${r?r.label:'request'} (${p.name})`)};
    });
  },
  addRequest(playerId, req){
    store.set(st=>({ players: st.players.map(p=> p.id===playerId ? {...p, requests:[...p.requests, {id:Date.now(), ...req}]} : p)}));
  },
  // ============================================================
  // MAIN STAGE — the moderator's authoritative control of what the
  // shared Main screen shows. Each call is exclusive: it clears every
  // other overlay so exactly one thing is on screen.
  /* ============================================================
     THE DEFUSER — comms trap puzzle
     ============================================================ */
  pushDefuser(opts){
    opts = opts||{};
    const kinds = (opts.kinds&&opts.kinds.length) ? opts.kinds : ['wires','glyphs','sequence'];
    const built = kinds.slice(0,4).map((k,i)=>makeDefuserModule(k,i));
    const modules = built.map(b=>b.module);
    const players = store.getState().players; const n = players.length;
    modules.forEach((m,i)=>{ m.operator = n ? players[i%n].id : null; });
    const rules = built.map((b,i)=>({ id:'rule'+i, moduleId:b.module.id,
      reader: n ? players[(i+1)%n].id : null, title:b.rule.title, text:b.rule.text }));
    const seconds = Math.max(30, Number(opts.seconds)||300);
    store.set(st=>({ ...stagesOff(st),
      defuser:{ ...st.defuser, open:true, status:'live', modules, rules,
        duration:seconds*1000, endsAt: Date.now()+seconds*1000, strikes:0,
        hintRuleId:'', struckId:0, struckModule:'', nextScene: st.defuser.nextScene||'' },
      ...logLine(st,`☠ The Defuser armed — ${modules.length} modules · ${seconds}s`)}));
    store.dispatch({type:'defuser-push'});
  },
  assignRole(playerId, role, moduleId){
    store.set(st=>{
      let modules = st.defuser.modules, rules = st.defuser.rules;
      if(role==='operator'){
        modules = modules.map(m=> m.id===moduleId ? {...m, operator:playerId} : m);
        rules = rules.map(r=> (r.moduleId===moduleId && r.reader===playerId) ? {...r, reader:null} : r);
      } else {
        rules = rules.map(r=> r.moduleId===moduleId ? {...r, reader:playerId} : r);
        modules = modules.map(m=> (m.id===moduleId && m.operator===playerId) ? {...m, operator:null} : m);
      }
      return { defuser:{...st.defuser, modules, rules} };
    });
    store.dispatch({type:'defuser-assign'});
  },
  operateModule(moduleId, action){
    const d = store.getState().defuser;
    if(d.status!=='live') return;
    const m = d.modules.find(x=>x.id===moduleId);
    if(!m || m.locked) return;
    let ns = {...m.state}; let wrong=false, nowLocked=false;
    if(m.kind==='wires' && action.type==='cut'){
      if(ns.cut.includes(action.index)) return;
      ns = {...ns, cut:[...ns.cut, action.index]};
      if(action.index===m.solution.cutIndex) nowLocked=true; else wrong=true;
    } else if(m.kind==='glyphs' && action.type==='rotate'){
      const dials = ns.dials.slice(); dials[action.dial] = (dials[action.dial]+1)%ns.glyphs.length;
      ns = {...ns, dials};
      if(dials.every(g=>g===m.solution.target)) nowLocked=true;
    } else if(m.kind==='levers' && action.type==='flip'){
      const levers = ns.levers.slice(); levers[action.index] = !levers[action.index];
      ns = {...ns, levers};
      if(levers.every((v,i)=>v===m.solution.pattern[i])) nowLocked=true;
    } else if(m.kind==='sequence' && action.type==='press'){
      const pos = ns.pressed.length;
      if(action.index===m.solution.order[pos]){
        const pressed=[...ns.pressed, action.index]; ns={...ns, pressed};
        if(pressed.length===m.solution.order.length) nowLocked=true;
      } else { wrong=true; ns={...ns, pressed:[]}; }
    } else return;
    store.set(s=>({ defuser:{...s.defuser, modules: s.defuser.modules.map(x=> x.id===moduleId ? {...x, state:ns, locked: nowLocked||x.locked} : x)} }));
    store.dispatch({type:'defuser-op', moduleId, wrong, locked:nowLocked});
    if(wrong){ Game.addStrike(moduleId); }
    else if(nowLocked){
      store.dispatch({type:'defuser-lock', moduleId});
      if(store.getState().defuser.modules.every(x=>x.locked)) Game.onDisarm();
    }
  },
  evaluateModule(moduleId){
    const m = store.getState().defuser.modules.find(x=>x.id===moduleId);
    return m ? m.locked : false;
  },
  addStrike(moduleId){
    const d = store.getState().defuser;
    if(d.status!=='live') return;
    const strikes = d.strikes+1;
    store.set(s=>({ defuser:{...s.defuser, strikes, struckId:Date.now(), struckModule:moduleId||''},
      ...logLine(s,`✖ Strike ${strikes}/${d.maxStrikes}`)}));
    store.dispatch({type:'defuser-strike', strikes});
    const living = store.getState().players.filter(p=>!p.dead);
    if(living.length){ const v=living[rnd(living.length)]; setTimeout(()=>Game.triggerDamage(v.id,1), 220); }
    if(strikes>=d.maxStrikes) Game.onBoom();
  },
  onDisarm(){
    store.set(s=>({ defuser:{...s.defuser, status:'disarmed'}, ...logLine(s,'✓ The trap is disarmed')}));
    store.dispatch({type:'defuser-disarm'});
  },
  onBoom(){
    store.set(s=>({ defuser:{...s.defuser, status:'boom'}, ...logLine(s,'💥 The trap triggers!')}));
    store.dispatch({type:'defuser-boom'});
  },
  grantDefuserHint(ruleId){
    store.set(s=>({ defuser:{...s.defuser, hintRuleId:ruleId}, ...logLine(s,'A rule fragment is revealed on the Main screen')}));
    store.dispatch({type:'defuser-hint'});
  },
  forceDisarm(){ Game.onDisarm(); },
  setDefuserNextScene(scene){ store.set(s=>({ defuser:{...s.defuser, nextScene:scene||''} })); },
  closeDefuser(){ store.set(s=>({ defuser:{...s.defuser, open:false}, ...logLine(s,'The Defuser is cleared')})); },
  enterAfterDefuser(){
    const sc = store.getState().defuser.nextScene;
    store.set(s=>({ ...stagesOff(s), defuser:{...s.defuser, open:false}, ...(sc?{scene:sc}:{}), ...logLine(s, sc?`The party proceeds → ${sc}`:'The party proceeds')}));
    if(sc) store.dispatch({type:'scene'});
  },

  /* ============================================================
     CONSTELLATION — cooperative star alignment
     ============================================================ */
  pushConstellation(opts){
    opts = opts||{};
    const key = CONSTELLATIONS[opts.target] ? opts.target : 'wolf';
    const def = CONSTELLATIONS[key];
    const tol = Math.max(4, Number(opts.tolerance)||14);
    const players = store.getState().players; const n = players.length;
    const colors = ['#f0a836','#5a8fd6','#52b07a','#d98fd0'];
    const layers = Array.from({length:def.layerCount}, (_,li)=>({
      owner: n ? players[li%n].id : null, rot: scrambleRot(tol), color: colors[li%colors.length] }));
    store.set(st=>({ ...stagesOff(st),
      constellation:{ ...st.constellation, open:true, target:key, tolerance:tol, layers,
        stars:def.stars, lines:def.lines, complete:false, hintId:0, hintLayer:-1, nextScene: st.constellation.nextScene||'' },
      ...logLine(st,`✦ Constellation — align ${def.name}`)}));
    store.dispatch({type:'constellation-push'});
  },
  moveLayer(layerIndex, rot){
    const c = store.getState().constellation;
    if(!c.open || c.complete) return;
    const layers = c.layers.map((l,i)=> i===layerIndex ? {...l, rot} : l);
    const complete = constComplete(layers, c.tolerance);
    store.set(s=>({ constellation:{...s.constellation, layers, complete} }));
    if(complete) Game.onConstellationComplete();
  },
  assignLayer(layerIndex, playerId){
    store.set(s=>({ constellation:{...s.constellation, layers: s.constellation.layers.map((l,i)=> i===layerIndex ? {...l, owner:playerId} : l)} }));
    store.dispatch({type:'constellation-assign'});
  },
  evaluateAlignment(){ const c=store.getState().constellation; return constComplete(c.layers, c.tolerance); },
  grantConstellationHint(layerIndex){
    const c = store.getState().constellation;
    const prev = c.layers[layerIndex] ? c.layers[layerIndex].rot : 0;
    store.set(s=>({ constellation:{...s.constellation, hintLayer:layerIndex, hintId:Date.now(),
      layers: s.constellation.layers.map((l,i)=> i===layerIndex ? {...l, rot:0} : l)} }));
    store.dispatch({type:'constellation-hint', layerIndex});
    setTimeout(()=>store.set(s=>{
      if(s.constellation.complete) return {};
      return { constellation:{...s.constellation, hintLayer:-1,
        layers: s.constellation.layers.map((l,i)=> i===layerIndex ? {...l, rot:prev} : l)} };
    }), 1500);
  },
  onConstellationComplete(){
    store.set(s=>({ constellation:{...s.constellation, complete:true, hintLayer:-1}, ...logLine(s,'✦ The constellation ignites')}));
    store.dispatch({type:'constellation-complete'});
  },
  setConstellationNextScene(scene){ store.set(s=>({ constellation:{...s.constellation, nextScene:scene||''} })); },
  closeConstellation(){ store.set(s=>({ constellation:{...s.constellation, open:false}, ...logLine(s,'The sky fades')})); },
  enterAfterConstellation(){
    const sc = store.getState().constellation.nextScene;
    store.set(s=>({ ...stagesOff(s), constellation:{...s.constellation, open:false}, ...(sc?{scene:sc}:{}), ...logLine(s, sc?`The party proceeds → ${sc}`:'The party proceeds')}));
    if(sc) store.dispatch({type:'scene'});
  },

  /* ============================================================
     LOOT AUCTION — sealed-bid bidding
     ============================================================ */
  openAuction(item, opts){
    opts = opts||{};
    const it = (item && item.name) ? { id:item.id||'auc'+Date.now(), name:item.name, type:item.type||'item', desc:item.desc||'', reqs:item.reqs||[] }
      : { id:'auc'+Date.now(), name:(typeof item==='string'&&item)||'Rare Relic', type:'item', desc:'', reqs:[] };
    const seconds = Math.max(10, Number(opts.seconds)||30);
    store.set(st=>({ ...stagesOff(st),
      auction:{ ...st.auction, open:true, item:it, duration:seconds*1000, endsAt:Date.now()+seconds*1000,
        sealed: opts.sealed!==false, reserve:Math.max(0,Number(opts.reserve)||0), bids:{}, status:'live', winner:'', tie:[] },
      ...logLine(st,`⚖ Auction opens — ${it.name}`)}));
    store.dispatch({type:'auction-open'});
  },
  placeBid(playerId, amount){
    const a = store.getState().auction;
    if(!a.open || a.status!=='live') return;
    const p = findP(store.getState(), playerId); if(!p) return;
    const amt = Math.max(0, Math.min(Math.floor(Number(amount)||0), p.coins));
    store.set(s=>({ auction:{...s.auction, bids:{...s.auction.bids, [playerId]:{amount:amt, passed:false}}}}));
    store.dispatch({type:'auction-bid', playerId});
  },
  passBid(playerId){
    const a = store.getState().auction;
    if(!a.open || a.status!=='live') return;
    store.set(s=>({ auction:{...s.auction, bids:{...s.auction.bids, [playerId]:{amount:0, passed:true}}}}));
    store.dispatch({type:'auction-bid', playerId});
  },
  closeAuction(){
    const a = store.getState().auction;
    if(!a.open || a.status==='closed') return;
    const entries = Object.entries(a.bids).filter(([id,b])=>!b.passed && b.amount>0 && b.amount>=(a.reserve||0));
    let winner='', tie=[];
    if(entries.length){
      const max = Math.max(...entries.map(([id,b])=>b.amount));
      const top = entries.filter(([id,b])=>b.amount===max).map(([id])=>id);
      if(top.length===1) winner=top[0]; else tie=top;
    }
    store.set(s=>({ auction:{...s.auction, status:'closed', winner, tie},
      ...logLine(s, winner?'The hammer falls':(tie.length?'A tie — the Keeper must break it':'No bid takes it'))}));
    store.dispatch({type:'auction-close', winner});
    if(winner) setTimeout(()=>Game.awardItem(winner), 1500);
  },
  awardItem(playerId){
    const a = store.getState().auction;
    if(!a.item) return;
    const bid = a.bids[playerId]; const amt = bid ? bid.amount : 0;
    store.set(s=>({ players: s.players.map(p=> p.id===playerId ? {...p, coins:Math.max(0,p.coins-amt)} : p),
      auction:{...s.auction, winner:playerId, tie:[], status:'closed'},
      ...logLine(s,`${(findP(s,playerId)||{name:'?'}).name.split(' ')[0]} wins ${a.item.name} for ${amt}c`)}));
    Game.grantItem(playerId, { name:a.item.name, type:a.item.type, desc:a.item.desc, reqs:a.item.reqs });
    store.dispatch({type:'coins', playerId});
    store.dispatch({type:'purchase', playerId, items:[{name:a.item.name, type:a.item.type}]});
    audio.play('coin');
  },
  breakTie(playerId){ Game.awardItem(playerId); },
  closeAuctionStage(){ store.set(s=>({ auction:{...s.auction, open:false}, ...logLine(s,'The auction closes')})); },

  /* ============================================================
     BRANCHING VOTES
     ============================================================ */
  pushVote(prompt, options, opts){
    opts = opts||{};
    const opt = (options||[]).filter(o=>o&&(o.label||o).toString().trim())
      .map((o,i)=>({ id:(o&&o.id)||('opt'+i), label:(o&&o.label||o).toString().trim() })).slice(0,4);
    if(opt.length<2) return;
    store.set(st=>({ ...stagesOff(st),
      vote:{ ...st.vote, open:true, prompt:(prompt||'A choice lies before you').toString(), options:opt,
        secret: opts.secret!==false, ballots:{}, weights: st.vote.weights||{}, status:'live', result:'' },
      ...logLine(st,'🗳 A vote is called')}));
    store.dispatch({type:'vote-open'});
  },
  castVote(playerId, optionId){
    const v = store.getState().vote;
    if(!v.open || v.status!=='live') return;
    store.set(s=>({ vote:{...s.vote, ballots:{...s.vote.ballots, [playerId]:optionId}}}));
    store.dispatch({type:'vote-cast', playerId});
  },
  setVoteWeight(playerId, w){ store.set(s=>({ vote:{...s.vote, weights:{...s.vote.weights, [playerId]:Math.max(1,Number(w)||1)}} })); },
  closeVote(){
    const v = store.getState().vote;
    if(!v.open || v.status==='closed') return;
    const tally = {};
    Object.entries(v.ballots).forEach(([pid,oid])=>{ const w=v.weights[pid]||1; tally[oid]=(tally[oid]||0)+w; });
    let result='', max=-1, tie=false;
    Object.entries(tally).forEach(([oid,c])=>{ if(c>max){ max=c; result=oid; tie=false; } else if(c===max){ tie=true; } });
    store.set(s=>({ vote:{...s.vote, status:'closed', result: tie?'':result}, ...logLine(s, tie?'The vote is tied':'The votes are counted')}));
    store.dispatch({type:'vote-close'});
  },
  resolveVote(optionId){ store.set(s=>({ vote:{...s.vote, result:optionId}, ...logLine(s,'The path is chosen')})); store.dispatch({type:'vote-result', optionId}); },
  closeVoteStage(){ store.set(s=>({ vote:{...s.vote, open:false}, ...logLine(s,'The vote is dismissed')})); },

  /* ============================================================
     SECRET ROLES — private hidden objectives
     ============================================================ */
  assignSecretRole(playerId, role){
    role = role||{};
    const objectives = (role.objectives||[]).map((o,i)=>({ id:'obj'+i+'_'+rnd(9999).toString(36),
      text: (typeof o==='string'?o:o.text)||'', met: null })).filter(o=>o.text);
    store.set(s=>({ secrets:{...s.secrets, active:true,
      roles:{...s.secrets.roles, [playerId]:{ role:role.role||'Cultist', faction:role.faction||'party', objectives }}}}));
    store.dispatch({type:'secret-assign', playerId});
  },
  clearSecretRole(playerId){
    store.set(s=>{ const roles={...s.secrets.roles}; delete roles[playerId];
      return { secrets:{...s.secrets, roles, active:Object.keys(roles).length>0, revealed: Object.keys(roles).length>0 && s.secrets.revealed} }; });
  },
  setSecretCue(text){ store.set(s=>({ secrets:{...s.secrets, cue:(text||'').toString()} })); },
  revealRoles(){ store.set(s=>({ secrets:{...s.secrets, revealed:true}, ...logLine(s,'🎭 The masks come off')})); store.dispatch({type:'secrets-reveal'}); },
  hideRoles(){ store.set(s=>({ secrets:{...s.secrets, revealed:false} })); },
  resolveObjective(playerId, objId, met){
    store.set(s=>{ const r=s.secrets.roles[playerId]; if(!r) return {};
      return { secrets:{...s.secrets, roles:{...s.secrets.roles, [playerId]:{...r, objectives:r.objectives.map(o=> o.id===objId ? {...o, met} : o)}}} }; });
  },
  clearSecrets(){ store.set(s=>({ secrets:{...s.secrets, active:false, revealed:false, roles:{}}, ...logLine(s,'Secrets are cleared')})); },

  /* ============================================================
     PRIVATE GM MESSAGES — on-device-only two-way channel
     (player ↔ moderator). Same privacy rule as secret roles:
     a message + its reply render ONLY on the two endpoints.
     ============================================================ */
  sendPrivateMessage(playerId, text){
    text = (text||'').toString().trim(); if(!text || !playerId) return;
    const msg = { id:'gm'+Date.now().toString(36)+rnd(9999).toString(36), from:'player', text, t:Date.now(), read:false };
    store.set(s=>({ gm:{...s.gm, threads:{...s.gm.threads, [playerId]:[...(s.gm.threads[playerId]||[]), msg]}} }));
    store.dispatch({type:'gm-msg', playerId});
  },
  modReply(playerId, text){
    text = (text||'').toString().trim(); if(!text || !playerId) return;
    const msg = { id:'gm'+Date.now().toString(36)+rnd(9999).toString(36), from:'gm', text, t:Date.now(), read:false };
    store.set(s=>({ gm:{...s.gm, threads:{...s.gm.threads, [playerId]:[...(s.gm.threads[playerId]||[]), msg]}} }));
    store.dispatch({type:'gm-reply', playerId});
  },
  // player opened their thread → mark the GM's messages read
  markRead(playerId){
    store.set(s=>{ const th=s.gm.threads[playerId]; if(!th||!th.some(m=>m.from==='gm'&&!m.read)) return {};
      return { gm:{...s.gm, threads:{...s.gm.threads, [playerId]: th.map(m=> m.from==='gm'?{...m,read:true}:m)}} }; });
  },
  // moderator opened a thread → mark the player's messages read
  markReadGM(playerId){
    store.set(s=>{ const th=s.gm.threads[playerId]; if(!th||!th.some(m=>m.from==='player'&&!m.read)) return {};
      return { gm:{...s.gm, threads:{...s.gm.threads, [playerId]: th.map(m=> m.from==='player'?{...m,read:true}:m)}} }; });
  },

  /* ============================================================
     PRIVATE WAGER — a 1:1 bet offered through the GM channel.
     Only the player & moderator ever see it; the reward is
     delivered privately (coins / item / secret boon).
     ============================================================ */
  openPrivateWager(playerId, terms, outcomes, reward){
    if(!playerId) return;
    const out = (outcomes||[]).map((o,i)=>({ id:'pw'+i, label:(typeof o==='string'?o:o.label||'').toString().trim() }))
      .filter(o=>o.label).slice(0,4);
    if(out.length<2) return;
    const rw = reward||{ type:'coins', amount:10 };
    const pw = { id:'pw'+Date.now().toString(36), terms:(terms||'A private wager').toString(),
      outcomes:out, reward:rw, chosen:'', stake:0, status:'offered', won:null };
    store.set(s=>({ privateWagers:{...s.privateWagers, [playerId]:pw} }));
    // a quiet note in their private thread
    Game.modReply(playerId, `🎲 I've offered you a private wager: "${pw.terms}". Open it below to place your bet.`);
    store.dispatch({type:'pwager-offer', playerId});
  },
  placePrivateWager(playerId, outcomeId, stake){
    const st = store.getState();
    const pw = st.privateWagers[playerId]; if(!pw || pw.status==='resolved') return;
    const player = st.players.find(p=>p.id===playerId); if(!player) return;
    const max = player.coins||0;
    const s2 = Math.max(0, Math.min(max, Math.floor(Number(stake)||0)));
    if(!outcomeId || s2<=0) return;
    // escrow the stake immediately
    Game.setCoins(playerId, max - s2);
    store.set(s=>({ privateWagers:{...s.privateWagers, [playerId]:{...pw, chosen:outcomeId, stake:s2, status:'placed'}} }));
    Game.sendPrivateMessage(playerId, `🎲 Wager placed: "${(pw.outcomes.find(o=>o.id===outcomeId)||{}).label}" for ${s2}c.`);
    store.dispatch({type:'pwager-place', playerId});
  },
  resolvePrivateWager(playerId, won){
    const st = store.getState();
    const pw = st.privateWagers[playerId]; if(!pw || pw.status!=='placed') return;
    store.set(s=>({ privateWagers:{...s.privateWagers, [playerId]:{...pw, status:'resolved', won:!!won}} }));
    const player = st.players.find(p=>p.id===playerId);
    if(won){
      // refund the stake, then deliver the secret reward privately
      if(player) Game.setCoins(playerId, (player.coins||0) + pw.stake);
      Game.grantReward(playerId, pw.reward);
      Game.modReply(playerId, `🎉 You won the wager. ${rewardLabel(pw.reward)} is yours — privately.`);
    } else {
      Game.modReply(playerId, `The wager didn't fall your way — your ${pw.stake}c stake is lost. Better luck next time.`);
    }
    store.dispatch({type:'pwager-resolve', playerId, won:!!won});
  },
  cancelPrivateWager(playerId){
    const st = store.getState();
    const pw = st.privateWagers[playerId]; if(!pw) return;
    // refund any escrowed stake if it was placed but not resolved
    if(pw.status==='placed'){ const player=st.players.find(p=>p.id===playerId); if(player) Game.setCoins(playerId, (player.coins||0)+pw.stake); }
    store.set(s=>{ const m={...s.privateWagers}; delete m[playerId]; return { privateWagers:m }; });
    store.dispatch({type:'pwager-cancel', playerId});
  },
  grantReward(playerId, reward){
    reward = reward||{}; const player = store.getState().players.find(p=>p.id===playerId); if(!player) return;
    if(reward.type==='coins'){
      Game.setCoins(playerId, (player.coins||0) + (Number(reward.amount)||0));
      store.dispatch({type:'coins', playerId});
    } else if(reward.type==='item'){
      Game.grantItem(playerId, { name:reward.item||'A mysterious gift', type:'item', desc:'A private reward from the Game Master.' });
      setTimeout(()=>store.dispatch({type:'purchase', playerId, items:[{name:reward.item||'A gift', type:'item'}]}), 120);
    } else if(reward.type==='boon'){
      // delivered like a secret role — lands in the player's private Secrets panel
      Game.assignSecretRole(playerId, { role:reward.role||'Hidden Boon', faction:'party',
        objectives:[ reward.item || 'A secret advantage only you know.' ] });
    }
  },

  /* ============================================================
     PACK #2 ACTIONS
     ============================================================ */

  /* ---- 1 · LIGHT-BENDER ---- */
  pushLightBender(opts){
    opts = opts||{};
    const tol = Math.max(4, Number(opts.tolerance)||7);
    const players = store.getState().players; const n = players.length;
    const colors = ['#f0a836','#5a8fd6','#52b07a','#d9534f','#a06fd0'];
    const count = Math.max(2, Math.min(5, opts.mirrors || Math.max(2, Math.min(4, n||3))));
    const target = { x:94, y:50, tol };
    const R2D = 180/Math.PI;
    const norm = (x,y)=>{ const d=Math.hypot(x,y)||1; return [x/d,y/d]; };
    // Build a PROVABLY-SOLVABLE layout: place mirrors in a zigzag, aim the
    // emitter at the first mirror, then compute each mirror's solution angle —
    // a flat mirror reflecting dirIn→dirOut has its line parallel to (dirIn+dirOut).
    // Verify the full route completes, then scramble the starting angles.
    let emitter, mirrors;
    for(let attempt=0; attempt<60 && !mirrors; attempt++){
      const em = { x:6, y:50, dir:0 };
      const pts = [];
      for(let i=0;i<count;i++){
        const t = (i+1)/(count+1);
        const jx = attempt? (Math.random()*8-4) : 0;
        const jy = attempt? (Math.random()*14-7) : 0;
        pts.push({ x: 16 + t*68 + jx, y: (i%2===0 ? 28 : 72) + jy });
      }
      // aim the emitter straight at the first mirror so the beam can enter the chain
      em.dir = Math.atan2(pts[0].y-em.y, pts[0].x-em.x)*R2D;
      // per-mirror solution angle = direction of (incoming + outgoing) unit vectors
      const wp = [em, ...pts, target];
      const sol = [];
      for(let i=0;i<count;i++){
        const prev=wp[i], here=wp[i+1], next=wp[i+2];
        const [ix,iy]=norm(here.x-prev.x, here.y-prev.y);
        const [ox,oy]=norm(next.x-here.x, next.y-here.y);
        sol.push(((Math.atan2(iy+oy, ix+ox)*R2D)%180+180)%180);
      }
      // accept only if the beam at solution angles truly threads every mirror to the lens
      const test = pts.map((p,i)=>({ x:p.x, y:p.y, angle:sol[i] }));
      if(lbComplete({ emitter:em, mirrors:test, target, tolerance:tol })){
        emitter = em;
        mirrors = pts.map((p,i)=>({
          owner: n? players[i%n].id : null, x:p.x, y:p.y,
          angle: (sol[i] + 45 + Math.random()*90)%180,   // scrambled away from the solution
          sol: sol[i], color: colors[i%colors.length] }));
      }
    }
    if(!mirrors){ // defensive fallback (should not happen): straight inline mirrors
      emitter = { x:6, y:50, dir:0 };
      mirrors = [];
      for(let i=0;i<count;i++){ const t=(i+1)/(count+1);
        mirrors.push({ owner:n?players[i%n].id:null, x:16+t*68, y:50, angle:Math.floor(Math.random()*180), sol:90, color:colors[i%colors.length] }); }
    }
    store.set(st=>({ ...stagesOff(st),
      lightbender:{ ...st.lightbender, open:true, emitter, target, mirrors, tolerance:tol,
        complete:false, hintMirror:-1, hintId:0, nextScene: st.lightbender.nextScene||'' },
      ...logLine(st,`✷ Light-Bender — ${count} mirrors`)}));
    store.dispatch({type:'lb-push'});
  },
  rotateMirror(mirrorIndex, deltaOrAngle, absolute){
    const lb = store.getState().lightbender;
    if(!lb.open || lb.complete) return;
    const mirrors = lb.mirrors.map((m,i)=>{
      if(i!==mirrorIndex) return m;
      const angle = absolute ? deltaOrAngle : (m.angle + deltaOrAngle);
      return {...m, angle: ((angle%360)+360)%360};
    });
    const complete = lbComplete({...lb, mirrors});
    store.set(s=>({ lightbender:{...s.lightbender, mirrors, complete} }));
    // (no lb-rotate event: the Main screen's turn sound is now state-driven, so
    //  we don't flood the broker with ~60 events/sec during a dial drag.)
    if(complete) Game.lbComplete();
  },
  assignMirror(mirrorIndex, playerId){
    store.set(s=>({ lightbender:{...s.lightbender, mirrors: s.lightbender.mirrors.map((m,i)=> i===mirrorIndex ? {...m, owner:playerId} : m)} }));
    store.dispatch({type:'lb-assign'});
  },
  lbComplete(){
    store.set(s=>({ lightbender:{...s.lightbender, complete:true, hintMirror:-1}, ...logLine(s,'✷ The beam strikes true')}));
    store.dispatch({type:'lb-complete'});
  },
  grantLbHint(mirrorIndex){
    // search a near-solution angle for this mirror that completes the circuit, flash toward it
    const lb = store.getState().lightbender;
    let found = lb.mirrors[mirrorIndex] ? lb.mirrors[mirrorIndex].angle : 0;
    for(let a=0;a<180;a+=2){
      const test = lb.mirrors.map((m,i)=> i===mirrorIndex ? {...m, angle:a} : m);
      const res = traceBeamPath(lb.emitter, test, lb.target, lb.tolerance);
      if(res.hit){ found = a; break; }
    }
    store.set(s=>({ lightbender:{...s.lightbender, hintMirror:mirrorIndex, hintId:Date.now()} }));
    store.dispatch({type:'lb-hint', mirrorIndex, angle:found});
    setTimeout(()=>store.set(s=> s.lightbender.hintMirror===mirrorIndex ? { lightbender:{...s.lightbender, hintMirror:-1} } : {}), 1800);
  },
  setLbNextScene(scene){ store.set(s=>({ lightbender:{...s.lightbender, nextScene:scene||''} })); },
  closeLightBender(){ store.set(s=>({ lightbender:{...s.lightbender, open:false}, ...logLine(s,'The chamber dims')})); },
  enterAfterLb(){
    const sc = store.getState().lightbender.nextScene;
    store.set(s=>({ ...stagesOff(s), lightbender:{...s.lightbender, open:false}, ...(sc?{scene:sc}:{}), ...logLine(s, sc?`The party advances → ${sc}`:'The party advances')}));
    if(sc) store.dispatch({type:'scene'});
  },

  /* ---- 2 · POSSESSED ALLY ---- */
  triggerCharm(victimId, durationMs, opts){
    opts = opts||{};
    const players = store.getState().players;
    const others = players.filter(p=>p.id!==victimId && !p.dead);
    const hijack = opts.hijackTarget || (others.length ? others[rnd(others.length)].id : '');
    const dur = Math.max(8000, Number(durationMs)||25000);
    store.set(st=>({ charm:{ ...st.charm, active:true, victim:victimId, endsAt:Date.now()+dur, duration:dur,
      cleanse:0, threshold:Math.max(20, Number(opts.threshold)||100), broken:false, expired:false, contrib:{}, hijackTarget:hijack },
      ...logLine(st,`👁 ${(findP(st,victimId)||{name:'A hero'}).name.split(' ')[0]} is possessed!`)}));
    store.dispatch({type:'charm-start', victimId});
  },
  hijackControls(victimId){ /* visual only — handled on the possessed screen */ store.dispatch({type:'charm-hijack', victimId}); },
  contributeCleanse(playerId, amount){
    const c0 = store.getState().charm;
    if(!c0.active || c0.broken) return;
    amount = Math.max(1, Number(amount)||1);
    let cleanse = 0;
    store.set(s=>{ cleanse = Math.min(s.charm.threshold, s.charm.cleanse + amount);
      return { charm:{...s.charm, cleanse, contrib:{...s.charm.contrib, [playerId]:(s.charm.contrib[playerId]||0)+amount}} }; });
    store.dispatch({type:'charm-cleanse', playerId});
    if(cleanse>=c0.threshold) Game.breakCharm(c0.victim);
  },
  evaluateCleanse(){ const c=store.getState().charm; return c.cleanse>=c.threshold; },
  breakCharm(victimId){
    const c = store.getState().charm;
    if(!c.active || c.broken) return;
    store.set(s=>({ charm:{...s.charm, broken:true, active:false}, ...logLine(s,`✦ The charm shatters — ${(findP(s,victimId)||{name:'the hero'}).name.split(' ')[0]} is free`)}));
    store.dispatch({type:'charm-break', victimId});
  },
  onCharmExpire(victimId){
    store.set(s=>({ charm:{...s.charm, expired:true, active:false}, ...logLine(s,`The charm runs its course`)}));
    store.dispatch({type:'charm-expire', victimId});
  },
  // the possessed player's hijacked action lashes out at the bound ally
  possessedLash(){
    const c = store.getState().charm;
    if(!c.active || !c.hijackTarget) return;
    Game.triggerDamage(c.hijackTarget, 1);
    store.dispatch({type:'charm-lash', target:c.hijackTarget});
  },
  clearCharm(){ store.set(s=>({ charm:{...s.charm, active:false, broken:false, expired:false, victim:''} })); },

  /* ---- 3 · RITUAL INTERRUPT ---- */
  startRitual(spell, castMs, threshold, opts){
    opts = opts||{};
    const players = store.getState().players; const n = players.length;
    const kinds = ['tap','hold','seq'];
    const tasks = {};
    players.forEach((p,i)=>{ tasks[p.id] = { kind: kinds[i%kinds.length] }; });
    store.set(st=>({ ...stagesOff(st),
      ritual:{ ...st.ritual, active:true, spell:(spell||'The Unmaking').toString(),
        startAt:Date.now(), castMs:Math.max(8000, Number(castMs)||30000),
        interrupt:0, threshold:Math.max(20,Number(threshold)||100), status:'live',
        tasks, catastrophe: opts.catastrophe||'all-5' },
      ...logLine(st,`☄ ${spell||'A ritual'} begins to cast`)}));
    store.dispatch({type:'ritual-start'});
  },
  contributeInterrupt(playerId, amount){
    const r0 = store.getState().ritual;
    if(!r0.active || r0.status!=='live') return;
    amount = Math.max(1, Number(amount)||1);
    let interrupt = 0;
    // compute INSIDE the updater so concurrent taps accumulate instead of clobbering
    store.set(s=>{ interrupt = Math.min(s.ritual.threshold, s.ritual.interrupt+amount); return { ritual:{...s.ritual, interrupt} }; });
    store.dispatch({type:'ritual-contrib', playerId});
    if(interrupt>=r0.threshold) Game.onInterrupt();
  },
  evaluateRitual(){ const r=store.getState().ritual; return r.interrupt>=r.threshold; },
  onInterrupt(){
    store.set(s=>({ ritual:{...s.ritual, status:'interrupted', active:true}, ...logLine(s,'⚡ The ritual is shattered!')}));
    store.dispatch({type:'ritual-interrupt'});
  },
  onRitualComplete(){
    const r = store.getState().ritual;
    store.set(s=>({ ritual:{...s.ritual, status:'complete', active:true}, ...logLine(s,'☄ The ritual completes — catastrophe!')}));
    store.dispatch({type:'ritual-cast'});
    // catastrophe: damage everyone
    const m = /all-(\d+)/.exec(r.catastrophe||'all-5');
    const dmg = m ? parseInt(m[1]) : 5;
    const living = store.getState().players.filter(p=>!p.dead);
    living.forEach((p,i)=> setTimeout(()=>Game.triggerDamage(p.id, dmg), 300 + i*180));
  },
  closeRitual(){ store.set(s=>({ ritual:{...s.ritual, active:false}, ...logLine(s,'The ritual fades from the Main screen')})); },

  /* ---- 4 · PHASE TOTEMS ---- */
  spawnTotems(opts){
    opts = opts||{};
    const players = store.getState().players; const n = players.length;
    const colors = ['#f0a836','#5a8fd6','#52b07a','#d9534f'];
    const hp = Math.max(2, Number(opts.hp)||4);
    const items = Array.from({length:4}, (_,i)=>({ id:'tot'+i, owner: n? players[i%n].id : null,
      hp, maxHp:hp, primed:false, broken:false, brokeAt:0 }));
    store.set(st=>({ ...stagesOff(st),
      totems:{ ...st.totems, active:true, shieldDown:false, window:Math.max(800,Number(opts.window)||2500), items, lastBreakResolve:0 },
      ...logLine(st,'▲ Four totems rise — the boss is shielded')}));
    store.dispatch({type:'totems-spawn'});
  },
  damageTotem(totemId, amount){
    const t = store.getState().totems;
    if(!t.active || t.shieldDown) return;
    amount = Math.max(1, Number(amount)||1);
    store.set(s=>({ totems:{...s.totems, items: s.totems.items.map(it=>{
      if(it.id!==totemId || it.broken) return it;
      const hp = Math.max(0, it.hp-amount);
      return {...it, hp, primed: hp<=Math.ceil(it.maxHp*0.34) && hp>0 };
    })} }));
    store.dispatch({type:'totem-dmg', totemId});
  },
  primeTotem(totemId){
    store.set(s=>({ totems:{...s.totems, items: s.totems.items.map(it=> it.id===totemId ? {...it, primed:true, hp:Math.min(it.hp, Math.ceil(it.maxHp*0.34))} : it)} }));
  },
  breakTotem(totemId){
    const t = store.getState().totems;
    if(!t.active || t.shieldDown) return;
    const it = t.items.find(x=>x.id===totemId);
    if(!it || !it.primed || it.broken) return;
    const now = Date.now();
    const items = t.items.map(x=> x.id===totemId ? {...x, broken:true, brokeAt:now, hp:0} : x);
    store.set(s=>({ totems:{...s.totems, items} }));
    store.dispatch({type:'totem-break', totemId});
    Game.checkSynchrony();
  },
  checkSynchrony(){
    const t = store.getState().totems;
    if(!t.active || t.shieldDown) return false;
    const broken = t.items.filter(x=>x.broken);
    if(broken.length===t.items.length){
      const times = broken.map(x=>x.brokeAt);
      if(Math.max(...times)-Math.min(...times) <= t.window){ Game.dropBossShield(); return true; }
    }
    // if all broken but out of window, OR window elapsed since first break with some (not all) broken → regen
    if(broken.length>0){
      const firstAt = Math.min(...broken.map(x=>x.brokeAt));
      if(Date.now()-firstAt > t.window && broken.length<t.items.length){ Game.regenTotems(); }
    }
    return false;
  },
  dropBossShield(){
    store.set(s=>({ totems:{...s.totems, shieldDown:true}, ...logLine(s,'▼ The totems shatter as one — the shield falls!')}));
    store.dispatch({type:'totems-shield-down'});
  },
  regenTotems(){
    store.set(s=>{
      if(Date.now()-s.totems.lastBreakResolve < 400) return {};
      return { totems:{...s.totems, lastBreakResolve:Date.now(),
        items: s.totems.items.map(it=>({...it, broken:false, brokeAt:0, primed:false, hp:it.maxHp}))},
        ...logLine(s,'The totems surge back — break them together!') };
    });
    store.dispatch({type:'totems-regen'});
  },
  assignTotem(totemId, playerId){
    store.set(s=>({ totems:{...s.totems, items: s.totems.items.map(it=> it.id===totemId ? {...it, owner:playerId} : it)} }));
    store.dispatch({type:'totem-assign'});
  },
  closeTotems(){ store.set(s=>({ totems:{...s.totems, active:false}, ...logLine(s,'The totem phase ends')})); },

  /* ---- 5 · BREATH METER ---- */
  startBreathPhase(opts){
    opts = opts||{};
    const players = store.getState().players;
    const bars = {}; players.forEach(p=>{ bars[p.id] = 100; });
    store.set(st=>({ breath:{ ...st.breath, active:true, bars, pocket:'', queue:[], safe:false,
      drainRate:Math.max(1,Number(opts.drainRate)||5), refillRate:Math.max(5,Number(opts.refillRate)||22),
      objective: opts.objective || st.breath.objective },
      ...logLine(st,'🫧 The air grows thin — breath drains')}));
    store.dispatch({type:'breath-start'});
  },
  claimAirPocket(playerId){
    const b = store.getState().breath;
    if(!b.active) return;
    if(b.pocket===playerId) return;
    if(b.pocket && b.pocket!==playerId){
      // queue if not already
      if(!b.queue.includes(playerId)) store.set(s=>({ breath:{...s.breath, queue:[...s.breath.queue, playerId]} }));
      return;
    }
    store.set(s=>({ breath:{...s.breath, pocket:playerId, queue:s.breath.queue.filter(q=>q!==playerId)} }));
    store.dispatch({type:'breath-claim', playerId});
  },
  releaseAirPocket(playerId){
    const b = store.getState().breath;
    if(b.pocket!==playerId) return;
    const next = b.queue[0] || '';
    store.set(s=>({ breath:{...s.breath, pocket:next, queue:s.breath.queue.slice(next?1:0)} }));
    store.dispatch({type:'breath-release', playerId});
  },
  breathTick(){
    const b = store.getState().breath;
    if(!b.active) return;
    const bars = {...b.bars};
    let drowning = [];
    Object.keys(bars).forEach(pid=>{
      const p = findP(store.getState(), pid);
      if(!p || p.dead) return;
      if(pid===b.pocket){ bars[pid] = Math.min(100, bars[pid] + b.refillRate*0.4); }
      else {
        bars[pid] = Math.max(0, bars[pid] - b.drainRate*0.4);
        if(bars[pid]<=0) drowning.push(pid);
      }
    });
    store.set(s=>({ breath:{...s.breath, bars} }));
    drowning.forEach(pid=> Game.triggerDamage(pid, 1));
  },
  setBreath(playerId, val){ store.set(s=>({ breath:{...s.breath, bars:{...s.breath.bars, [playerId]:clamp(val,0,100)}} })); },
  onAllSafe(){ store.set(s=>({ breath:{...s.breath, active:false, safe:true}, ...logLine(s,'🫧 The party surfaces — everyone breathes')})); store.dispatch({type:'breath-safe'}); },
  closeBreath(){ store.set(s=>({ breath:{...s.breath, active:false}, ...logLine(s,'The breath phase ends')})); },

  /* ---- 6 · SPECTRAL GUIDE ---- */
  setSpectral(on){ store.set(s=>({ spectral:{...s.spectral, enabled:!!on}, ...logLine(s, on?'Spectral whispers enabled':'Spectral whispers off')})); },
  setSpectralOpts(patch){ store.set(s=>({ spectral:{...s.spectral, ...patch} })); },
  sendWhisper(playerId, word, scope){
    const sp = store.getState().spectral;
    if(!sp.enabled || sp.muted[playerId]) return false;
    const last = sp.lastSent[playerId]||0;
    if(Date.now()-last < sp.cooldownMs) return false;
    const w = (word||'').toString().trim().split(/\s+/)[0].slice(0,12).toUpperCase();
    if(!w) return false;
    const p = findP(store.getState(), playerId);
    const entry = { id:Date.now()+'-'+rnd(9999), from:playerId, fromName:p?p.name.split(' ')[0]:'A ghost', word:w, scope:scope||'', t:Date.now() };
    store.set(s=>({ spectral:{...s.spectral, whispers:[...s.spectral.whispers.slice(-8), entry], lastSent:{...s.spectral.lastSent, [playerId]:Date.now()}} }));
    store.dispatch({type:'whisper', word:w, scope:scope||'', from:playerId});
    setTimeout(()=>store.set(s=>({ spectral:{...s.spectral, whispers:s.spectral.whispers.filter(x=>x.id!==entry.id)} })), 4600);
    return true;
  },
  muteGhost(playerId, on){ store.set(s=>({ spectral:{...s.spectral, muted:{...s.spectral.muted, [playerId]: on===undefined ? !s.spectral.muted[playerId] : !!on}} })); },

  /* ---- 7 · BONDS ---- */
  formBond(a, b, opts){
    opts = opts||{};
    if(!a || !b || a===b) return;
    store.set(s=>{
      // one bond per player — drop any existing involving a or b
      const list = s.bonds.list.filter(bd=> ![bd.a,bd.b].includes(a) && ![bd.a,bd.b].includes(b));
      list.push({ id:'bond'+Date.now(), a, b, condition: opts.condition||'co-target', buff: Math.max(1,Number(opts.buff)||1), active:false });
      return { bonds:{...s.bonds, list}, ...logLine(s, `🜲 ${(findP(s,a)||{name:'?'}).name.split(' ')[0]} & ${(findP(s,b)||{name:'?'}).name.split(' ')[0]} are bonded`) };
    });
    store.dispatch({type:'bond-form'});
  },
  setBondActive(bondId, active){
    store.set(s=>({ bonds:{...s.bonds, list: s.bonds.list.map(b=> b.id===bondId ? {...b, active:!!active} : b)} }));
    store.dispatch({type:'bond-active', bondId});
  },
  checkBondActive(bondId){ const b=store.getState().bonds.list.find(x=>x.id===bondId); return b?b.active:false; },
  applyBondBuff(bondId){
    const b = store.getState().bonds.list.find(x=>x.id===bondId);
    if(!b || !b.active) return;
    Game.triggerHeal(b.a, b.buff); Game.triggerHeal(b.b, b.buff);
    store.dispatch({type:'bond-buff', bondId});
  },
  breakBond(bondId){ store.set(s=>({ bonds:{...s.bonds, list: s.bonds.list.filter(b=>b.id!==bondId)}, ...logLine(s,'A bond is severed')})); store.dispatch({type:'bond-break'}); },
  clearBonds(){ store.set(s=>({ bonds:{ list:[] } })); },

  /* ---- 8 · THE WAGER ---- */
  openWager(prompt, outcomes, opts){
    opts = opts||{};
    const out = (outcomes||[]).filter(o=>o&&(o.label||o).toString().trim())
      .map((o,i)=>({ id:(o&&o.id)||('out'+i), label:(o&&o.label||o).toString().trim() })).slice(0,4);
    if(out.length<2) return;
    store.set(st=>({ ...stagesOff(st),
      wager:{ ...st.wager, open:true, prompt:(prompt||'How will it go?').toString(), outcomes:out,
        mode: opts.mode==='pot'?'pot':'house', bets:{}, status:'live', actual:'' },
      ...logLine(st,'🎲 A wager is opened')}));
    store.dispatch({type:'wager-open'});
  },
  placeWager(playerId, outcomeId, stake){
    const w = store.getState().wager;
    if(!w.open || w.status!=='live') return;
    const p = findP(store.getState(), playerId); if(!p) return;
    const s2 = Math.max(0, Math.min(Math.floor(Number(stake)||0), p.coins));
    store.set(s=>({ wager:{...s.wager, bets:{...s.wager.bets, [playerId]:{outcomeId, stake:s2, passed:false}}} }));
    store.dispatch({type:'wager-bet', playerId});
  },
  passWager(playerId){
    const w = store.getState().wager;
    if(!w.open || w.status!=='live') return;
    store.set(s=>({ wager:{...s.wager, bets:{...s.wager.bets, [playerId]:{outcomeId:'', stake:0, passed:true}}} }));
    store.dispatch({type:'wager-bet', playerId});
  },
  resolveWager(actualOutcomeId){
    const w = store.getState().wager;
    if(!w.open || w.status==='resolved') return;
    store.set(s=>({ wager:{...s.wager, status:'resolved', actual:actualOutcomeId}, ...logLine(s,'The wager resolves')}));
    store.dispatch({type:'wager-resolve', actualOutcomeId});
    setTimeout(()=>Game.payoutWager(actualOutcomeId), 1400);
  },
  payoutWager(actualOutcomeId){
    const w = store.getState().wager;
    const bets = Object.entries(w.bets).filter(([id,b])=>!b.passed && b.stake>0);
    const winners = bets.filter(([id,b])=>b.outcomeId===actualOutcomeId);
    if(w.mode==='pot'){
      const pot = bets.reduce((a,[id,b])=>a+b.stake,0);
      // losers already staked into pot; winners split pot, losers lose stake
      const share = winners.length ? Math.floor(pot/winners.length) : 0;
      store.set(s=>({ players: s.players.map(p=>{
        const b = w.bets[p.id]; if(!b||b.passed||b.stake<=0) return p;
        const won = b.outcomeId===actualOutcomeId;
        const delta = won ? (share - b.stake) : (-b.stake);
        return {...p, coins: Math.max(0, p.coins + delta)};
      })}));
    } else {
      // house odds: correct +stake, wrong -stake
      store.set(s=>({ players: s.players.map(p=>{
        const b = w.bets[p.id]; if(!b||b.passed||b.stake<=0) return p;
        const won = b.outcomeId===actualOutcomeId;
        return {...p, coins: Math.max(0, p.coins + (won? b.stake : -b.stake))};
      })}));
    }
    winners.forEach(([id])=>store.dispatch({type:'coins', playerId:id}));
    store.set(s=>({ ...logLine(s, winners.length?`${winners.length} bettor${winners.length>1?'s':''} paid out`:'The house takes all') }));
    audio.play('coin');
  },
  closeWager(){ store.set(s=>({ wager:{...s.wager, open:false}, ...logLine(s,'The wager closes')})); },

  /* ---- 9 · ALCHEMY SEQUENCE ---- */
  startBrew(opts){
    opts = opts||{};
    const players = store.getState().players; const n = players.length;
    const len = Math.max(3, Math.min(REAGENTS.length, opts.length || Math.max(3, Math.min(5, n+1))));
    const pool = shuffleArr(REAGENTS).slice(0, len);
    const recipe = shuffleArr(pool.map(r=>r.id));
    // distribute reagents to players (each holds 1-2)
    const holders = {}; players.forEach(p=>holders[p.id]=[]);
    if(n) recipe.forEach((rid,i)=>{ holders[players[i%n].id].push(rid); });
    // build clue fragments: each player learns one adjacency in the recipe
    const clues = {};
    players.forEach((p,i)=>{
      const k = i % Math.max(1,(recipe.length-1));
      const a = reagentById(recipe[k]).name, b = reagentById(recipe[k+1]).name;
      clues[p.id] = `${a} must go in before ${b}.`;
    });
    // ensure first + last hinted too if enough players
    if(players.length>=2){ clues[players[players.length-1].id] = `${reagentById(recipe[0]).name} is added first.`; }
    if(players.length>=3){ clues[players[players.length-2].id] = `${reagentById(recipe[recipe.length-1]).name} is added last.`; }
    store.set(st=>({ ...stagesOff(st),
      alchemy:{ ...st.alchemy, open:true, status:'live', recipe, pool, added:[], holders, clues,
        step:0, backfireId:0, backfireBy:'', resultItem: opts.resultItem||'Elixir of the Deep', nextScene: st.alchemy.nextScene||'' },
      ...logLine(st,`⚗ A brew begins — ${len} reagents`)}));
    store.dispatch({type:'brew-start'});
  },
  addReagent(playerId, reagentId){
    const al = store.getState().alchemy;
    if(!al.open || al.status!=='live') return;
    if(al.added.includes(reagentId)) return;
    const expected = al.recipe[al.added.length];
    if(reagentId===expected){
      const added = [...al.added, reagentId];
      store.set(s=>({ alchemy:{...s.alchemy, added, step:added.length} }));
      store.dispatch({type:'brew-add', reagentId, correct:true});
      if(added.length===al.recipe.length) Game.onBrewComplete();
    } else {
      Game.onBackfire(playerId);
    }
  },
  evaluateBrew(){ const a=store.getState().alchemy; return a.added.length===a.recipe.length; },
  onBackfire(playerId){
    store.set(s=>({ alchemy:{...s.alchemy, backfireId:Date.now(), backfireBy:playerId},
      ...logLine(s,`⚗ The cauldron backfires!`)}));
    store.dispatch({type:'brew-backfire', playerId});
    const victim = playerId || (()=>{ const liv=store.getState().players.filter(p=>!p.dead); return liv.length?liv[rnd(liv.length)].id:''; })();
    if(victim) setTimeout(()=>Game.triggerDamage(victim, 2), 200);
  },
  onBrewComplete(){
    const al = store.getState().alchemy;
    store.set(s=>({ alchemy:{...s.alchemy, status:'done'}, ...logLine(s,`⚗ The brew is perfected — ${al.resultItem}`)}));
    store.dispatch({type:'brew-complete'});
  },
  grantBrewToAll(){
    const al = store.getState().alchemy;
    store.getState().players.filter(p=>!p.dead).forEach((p,i)=>{
      Game.grantItem(p.id, { name:al.resultItem, type:'item', desc:'Brewed by the party.' });
      setTimeout(()=>store.dispatch({type:'purchase', playerId:p.id, items:[{name:al.resultItem, type:'item'}]}), i*120);
    });
    audio.play('coin');
  },
  setBrewNextScene(scene){ store.set(s=>({ alchemy:{...s.alchemy, nextScene:scene||''} })); },
  closeBrew(){ store.set(s=>({ alchemy:{...s.alchemy, open:false}, ...logLine(s,'The cauldron is cleared')})); },
  enterAfterBrew(){
    const sc = store.getState().alchemy.nextScene;
    store.set(s=>({ ...stagesOff(s), alchemy:{...s.alchemy, open:false}, ...(sc?{scene:sc}:{}), ...logLine(s, sc?`The party moves on → ${sc}`:'The party moves on')}));
    if(sc) store.dispatch({type:'scene'});
  },

  // ============================================================
  showStage(kind, payload){
    store.set(st=>{
      const cleared = {
        npc: null,
        enemy: {...st.enemy, active:false},
        shop: {...st.shop, open:false},
        gate: {...st.gate, open:false, demo:false},
        circuit: {...st.circuit, open:false},
        battle: {...st.battle, active:false},
        defuser: {...st.defuser, open:false},
        constellation: {...st.constellation, open:false},
        auction: {...st.auction, open:false},
        vote: {...st.vote, open:false},
        mainMap: null,
        mainCodex: null,
      };
      switch(kind){
        case 'scene':
          return { ...cleared, scene: payload || st.scene, ...logLine(st, `Main → ${payload||st.scene}`) };
        case 'npc':
          return { ...cleared, npc: payload, ...logLine(st, `Main → ${payload?payload.name:'NPC'}`) };
        case 'enemy':
          return { ...cleared, enemy: {...st.enemy, active:true}, ...logLine(st, `Main → ${st.enemy.name}`) };
        case 'shop':
          return { ...cleared, shop: {...st.shop, open:true, type: payload || st.shop.type}, ...logLine(st, `Main → ${st.shop.shops[payload||st.shop.type].name}`) };
        case 'gate':
          return { ...cleared, gate: {...st.gate, open:true}, ...logLine(st, 'Main → Sealed Gate') };
        case 'battle':
          return { ...cleared, battle: {...st.battle, active:true}, scene:'battle', ...logLine(st, 'Main → Action sequence') };
        default:
          return cleared;
      }
    });
    store.dispatch({type:'scene'});
  },
  resetAll(){ store.reset(); },
};

window.Game = Game;
window.useGame = useGame;
window.useGameEvents = useGameEvents;
window.useNet = useNet;
window.store = store;
window.STAT_KEYS = STAT_KEYS;

/* ---------- beat-puzzle note metadata + button distribution ---------- */
// 4 notes, each a colour + pitch. Distributed across players as evenly as
// possible, in order: 1p→[0,1,2,3] · 2p→[0,1][2,3] · 3p→[0,1][2][3] · 4p→one each.
const NOTE_META = [
  { color:'#f0a836', name:'I'   },
  { color:'#52b07a', name:'II'  },
  { color:'#5a8fd6', name:'III' },
  { color:'#c77dff', name:'IV'  },
];
function noteAssignments(nPlayers){
  const res = Array.from({length:Math.max(1,nPlayers)}, ()=>[]);
  for(let i=0;i<4;i++){
    const k = Math.min(Math.floor(i*nPlayers/4), nPlayers-1);
    res[k].push(i);
  }
  return res;
}
window.NOTE_META = NOTE_META;
window.noteAssignments = noteAssignments;

/* ---------- CIRCUIT LOCK — ring metadata, owner distribution & liveness ----------
   4 concentric rings, outer→inner, each coloured to a player role. 8 slots per
   ring (45° each). `rotation` is an UNBOUNDED integer so a single ±1 tap always
   animates the short way (the actual slot is taken modulo 8 only when evaluating). */
const CIRCUIT_META = [
  { key:'r0', name:'Outer Ring',  color:'#f0a836', label:'Gold',  radius:166 },
  { key:'r1', name:'Second Ring', color:'#5a8fd6', label:'Blue',  radius:128 },
  { key:'r2', name:'Third Ring',  color:'#52b07a', label:'Green', radius:90  },
  { key:'r3', name:'Inner Ring',  color:'#d9534f', label:'Red',   radius:52  },
];
const mod8 = (n)=> (((n % 8) + 8) % 8);
const actualSlot = (slot, rotation)=> mod8(slot + rotation);

// distribute the 4 rings across however many players are present, in order:
// 1p→one player holds all four · 2p→[0,2][1,3] · 3p→p0 doubles · 4p→one each.
function ringOwners(players){
  const n = players.length;
  return CIRCUIT_META.map((_,i)=> n ? players[i % n].id : null);
}

// recompute where the current flows: which rings are live, which junctions are
// connected, whether the core is lit, and WHERE the first break is (gap marker).
function circuitLiveness(circuit){
  const rings = circuit.rings || [];
  const N = rings.length;
  const powerSlot = circuit.powerSlot || 0;
  const coreSlot = circuit.coreSlot || 0;
  const liveRing = new Array(N).fill(false);
  const liveJunction = new Array(Math.max(0, N-1)).fill(false);
  let coreLive = false, firstGap = null;
  if(N > 0){
    const entry0 = actualSlot(rings[0].entrySlot, rings[0].rotation);
    if(entry0 === powerSlot) liveRing[0] = true;
    else firstGap = { kind:'power', ring:0, slot:powerSlot };
  }
  for(let i=0; i<N-1 && liveRing[i]; i++){
    const exitI = actualSlot(rings[i].exitSlot, rings[i].rotation);
    const entryNext = actualSlot(rings[i+1].entrySlot, rings[i+1].rotation);
    if(exitI === entryNext){ liveJunction[i] = true; liveRing[i+1] = true; }
    else if(!firstGap){ firstGap = { kind:'junction', ring:i, slot:exitI }; }
  }
  if(N>0 && liveRing[N-1]){
    const exitLast = actualSlot(rings[N-1].exitSlot, rings[N-1].rotation);
    if(exitLast === coreSlot) coreLive = true;
    else if(!firstGap) firstGap = { kind:'core', ring:N-1, slot:exitLast };
  }
  return { liveRing, liveJunction, coreLive, complete: coreLive, firstGap, powerSlot, coreSlot };
}
window.CIRCUIT_META = CIRCUIT_META;
window.circuitLiveness = circuitLiveness;
window.ringOwners = ringOwners;
window.circuitActualSlot = actualSlot;

/* ---------- shop type metadata ---------- */
const SHOP_TYPES = {
  weapons:      { label:'The Weaponsmith', sub:'Blades, hafts & armor', sections:['weapons','armor'] },
  armory_magic: { label:'Armory & Magic',  sub:'Steel and sorcery',     sections:['weapons','armor','magic'] },
  provisioner:  { label:'The Provisioner', sub:'Food, potions & gear',  sections:['provisions','gear'] },
  mega:         { label:'The Grand Bazaar',sub:'Every ware there is',   sections:['weapons','armor','magic','scrolls','provisions'] },
  scroll:       { label:'The Scrollkeeper',sub:'Sealed words of power',  sections:['scrolls','magic'] },
};
const SECTION_TITLES = {
  weapons:'Weapons', armor:'Armor & Shields', magic:'Magic', scrolls:'Scrolls',
  provisions:'Food & Potions', gear:'Gear & Tools',
};
window.SHOP_TYPES = SHOP_TYPES;
window.SECTION_TITLES = SECTION_TITLES;

/* ---------- shop NPCs — the keeper who greets the party per shop type.
   `art` is an image-slot id so the moderator can drop a portrait in later. */
const SHOP_NPC = {
  weapons:      { name:'Durgan the Blacksmith', welcome:'Steel and surety, traveler. Point at what you need.' },
  armory_magic: { name:'Brannoch, Arms & Wonders', welcome:'Blade or boon? Under my roof you\u2019ll find both.' },
  provisioner:  { name:'Mara the Greenwife', welcome:'Warm food, warmer remedies. Sit, browse, spend.' },
  mega:         { name:'The Grand Merchant', welcome:'Everything under one roof, friend — for the right price.' },
  scroll:       { name:'Mogra the Scrollkeeper', welcome:'Mmm… words of power, sealed and waiting.' },
};
window.SHOP_NPC = SHOP_NPC;

/* ---------- inventory item-type metadata ---------- */
const ITEM_TYPES = {
  weapon: { label:'Weapon', section:'weapons' },
  magic:  { label:'Magic',  section:'items' },
  item:   { label:'Item',   section:'items' },
};
window.ITEM_TYPES = ITEM_TYPES;
// format a list of stat reqs → "2 STR · 1 MED"
function reqText(reqs){
  if(!reqs || !reqs.length) return '';
  return reqs.map(r=> `${r.n} ${r.stat}`).join(' · ');
}
window.reqText = reqText;
// does a hero meet every stat requirement on an item?
function meetsReqs(player, reqs){
  if(!reqs || !reqs.length) return true;
  const st = (player && player.stats) || {};
  return reqs.every(r => (Number(st[r.stat])||0) >= r.n);
}
window.meetsReqs = meetsReqs;
