<!-- RUBY-BACKUP · part 396 -->
 += (ops[i].size||1); } return m; }
function stoneMass(g){ var u=g.userData; return u.procMass || u.rubies || 1; }
function deleteProcedure(name){
  var n=0;
  for(var i=stones.children.length-1;i>=0;i--)
    if(stones.children[i].userData.proc===name){ stones.remove(stones.children[i]); n++; }
  return n;
}
/* oops: the undo stack. every obsoleted project is remembered — its stones,
   its code — so typing oops brings the last one back. */
var undoStack = [];
function loadUndo(){ try{ undoStack = JSON.parse(localStorage.getItem('ruby-undo-v1')||'[]'); }catch(e){ undoStack=[]; } }
function saveUndo(){ try{ localStorage.setItem('ruby-undo-v1', JSON.stringify(undoStack.slice(-10))); }catch(e){} }
function pushUndo(proc, killer){
  var st = [], mass = 0;
  for(var i=0;i<stones.children.length;i++){
    var s = stones.children[i];
    if(s.userData.proc===proc){
      var u = s.userData;
      st.push({x:s.position.x, z:s.position.z, r:u.rubies||1, s:u.shape||'cube', v:u.ghost?1:0, y:u.y||0, p:proc});
      mass += (u.rubies||1);
    }
  }
  if(!st.length) return;
  undoStack.push({victim:proc, killer:killer||null, mass:mass, stones:st});
  if(undoStack.length>10) undoStack.shift();
  saveUndo();
}
function doOops(){
  var rec = undoStack.pop();
  if(!rec){ codeStatus('nothing to undo'); return; }
  saveUndo();
  if(rec.victim==='spot'){
    /* a spot save undone — the spot is freed, save it again somewhere new */
    var ssk='ruby-spots-v1', spts={};
    try{ spts=JSON.parse(localStorage.getItem(ssk)||'{}'); }catch(e){}
    delete spts[rec.spot];
    try{ localStorage.setItem(ssk, JSON.stringify(spts)); }catch(e){}
    codeStatus('spot '+rec.spot+' unsaved');
    return;
  }
  if(rec.killer) deleteProcedure(rec.killer);
  var restored=0, skipped=0;
  for(var i=0;i<rec.stones.length;i++){
    var c = rec.stones[i];
    var g = placeStone(c.x, c.z, c.r, c.s+(c.v?'~ghost':''), true, true, c.y||0, !!c.l);
    if(g){ g.userData.proc = (c.hasOwnProperty('p') ? c.p : rec.victim); g.userData.procMass = c.r || rec.mass || 1; restored++; }
    else skipped++;
  }
  save(); updateAnchors();
  codeStatus(rec.victim+' restored'+(skipped?' · '+skipped+' stepped over':''));
}

/* save: name anything you build and it becomes a word. select with oops-my-bad,
   `save mygate` — then `mygate` rises wherever the cursor stands. `saves` lists. */
var SAVES = {};
var SAVE_RESERVED = {oops:1, cut:1, keep:1, swap:1, saves:1, send:1, take:1, newmap:1, lock:1, unlock:1, anchors:1, portal:1, unportal:1, portals:1};
function loadSaves(){ try{ SAVES = JSON.parse(localStorage.getItem('ruby-saves-v1')||'{}'); }catch(e){ SAVES={}; } }
function persistSaves(){ try{ localStorage.setItem('ruby-saves-v1', JSON.stringify(SAVES)); }catch(e){} }
function saveSelection(name){
  if(!selection.length){ codeStatus('nothing selected — oops-my-bad first'); return; }
  var a = selection[0];
  var st = [];
  for(var i=0;i<selection.length;i++){
    var s=selection[i], u=s.userData;
    st.push({dx:Math.round((s.position.x-a.position.x)*100)/100, dz:Math.round((s.position.z-a.position.z)*100)/100,
             y:u.y||0, r:u.rubies||1, s:u.shape||'cube', v:u.ghost?1:0});
  }
  SAVES[name] = {stones:st};
  persistSaves();
  codeStatus(name+' saved ('+st.length+' stones)');
}
function listSaves(){
  var ks = Object.keys(SAVES);
  if(!ks.length){ codeStatus('no saves yet'); return; }
  codeStatus('saves: '+ks.map(function(k){ return k+' ('+SAVES[k].stones.length+')'; }).join(', '));
}
/* did: no audio, just a small "this is what you did" — 1. 2. 3. — so the progress stays visible. */
var didLog = [];
var pendingDid = null;
function loadDid(){ try{ didLog = JSON.parse(localStorage.getItem('ruby-did-v1')||'[]'); }catch(e){ didLog=[]; } }
function persistDid(){ try{ localStorage.setItem('ruby-did-v1', JSON.stringify(didLog.slice(-8))); }catch(e){} }
function escapeHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function loadAnchors(){ try{ lockedAnchors = JSON.parse(localStorage.getItem('ruby-anchors-v1')||'[]'); }catch(e){ lockedAnchors=[]; } }
function persistAnchors(){ try{ localStorage.setItem('ruby-anchors-v1', JSON.stringify(lockedAnchors)); }catch(e){} }
function drawAnchorRings(){
  for(var i=anchorGrp.children.length-1;i>=0;i--) anchorGrp.remove(anchorGrp.children[i]);
  lockedAnchors.forEach(function(a){
    var ring = new T.Mesh(new T.RingGeometry(1.0, 1.25, 40),
      new T.MeshBasicMaterial({color:0xffe27a, transparent:true, opacity:0.85, side:T.DoubleSide}));
    ring.rotation.x = -Math.PI/2;
    ring.position.set(a.x, 0.12+(a.y||0)*0.13, a.z);
    anchorGrp.add(ring);
  });
}
function tapLock(grp){
  awaitingLock = false;
  var a = {x:grp.position.x, z:grp.position.z, y:grp.userData.y||0};
  var exists = lockedAnchors.some(function(e){ return Math.abs(e.x-a.x)<0.01 && Math.abs(e.z-a.z)<0.01 && Math.abs((e.y||0)-a.y)<0.01; });
  if(!exists) lockedAnchors.push(a);
  cursor.x