
(()=>{
'use strict';
const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
const storeKey='stepaway.caseConsole.v220';
const trackerKey='stepaway.localTracker.v220';
const profileKey='stepaway.profile.v220';
const accessKey='stepaway.paypalEntitlementAccess.v220';
const suits={D:{name:'Seen',verb:'Notice the shift',colour:'#a51628',symbol:'♦'},H:{name:'Held',verb:'Stay with the person',colour:'#cf4b28',symbol:'♥'},C:{name:'Helped',verb:'Make help real',colour:'#166c35',symbol:'♣'},S:{name:'Secured',verb:'Protect the hour',colour:'#0b2d52',symbol:'♠'}};
const suitByName={Seen:'D',Held:'H',Helped:'C',Secured:'S'};
let cards=[],cases=[],campaigns=[],wrinkles={hard:[],easy:[]},settings={},packs={packs:[]},aboutData={};
const state={screen:'home',deckMode:localStorage.getItem('sa.deckMode')||'official',players:Number(localStorage.getItem('sa.players')||1),selectedCampaign:null,activeCase:null,mode:'standalone',campaignRun:null,moment:0,progress:0,pressure:0,presence:1,receptivity:2,momentRevealed:{},momentCheckins:{},impact:null,crisis:null,phase:'intervention',searchLeft:30,overtimeLeft:10,momentTargets:null,sequenceNotes:[],weavedPending:false,weavedActive:false,weavedPlayed:false,weavedUsedMoments:{},timerLeft:90,timerId:null,log:[],plays:[],lastPlay:null,jokers:{fracture:false,resilience:false},cardFilter:'all',cardSearch:'',installEvent:null,pausedByModal:false,timeStopped:false,lastOutcome:null,lastShare:null,caseClosed:false,lastServerCheck:null,offlineReminderDismissedAt:null};
function esc(v=''){return String(v).replace(/[&<>'"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[m]));}
async function j(path){const r=await fetch(path,{cache:'no-store'}); if(!r.ok) throw new Error(path); return r.json();}
function isAccessGatedContent(file){return ['cases','campaigns','wrinkles','pack-index','pack'].includes(file);}
function localPathForContent(file){
  const map={cards:'data/cards.json',cards_manifest:'data/cards_manifest.json',cases:'data/cases.json',campaigns:'data/campaigns.json',wrinkles:'data/wrinkles.json',settings:'data/settings.json','pack-index':'content/packs/index.json',about:'data/about.json'};
  return map[file]||null;
}
function demoOnlyPayload(file,data){
  if(hasFullAccess())return data;
  if(file==='cases'&&Array.isArray(data))return data.slice(0,1).map(c=>({...c,demo:true,subtitle:`${c.subtitle||'Demo Case'} · Demo`}));
  if(file==='campaigns'&&Array.isArray(data)){
    const demoCase=Array.isArray(cases)&&cases[0]?cases[0]:null;
    const campaignId=demoCase?.campaign;
    const filtered=data.filter(c=>!campaignId||c.id===campaignId).slice(0,1);
    return filtered.length?filtered.map(c=>({...c,title:`${c.title||'Step Away'} Demo`,summary:'Poker-deck demo access. Purchase access unlocks the full case system.'})):data.slice(0,1);
  }
  if(file==='wrinkles')return {hard:[],easy:[]};
  if(file==='pack-index')return {packs:[],demo:true,message:'Purchase access unlocks the full pack system.'};
  return data;
}
async function fetchJsonSafe(url){
  const r=await fetch(url,{cache:'no-store'});
  if(!r.ok)throw new Error(`${url} HTTP ${r.status}`);
  const ct=(r.headers.get('content-type')||'').toLowerCase();
  const text=await r.text();
  try{return JSON.parse(text);}catch(e){throw new Error(`${url} did not return JSON${ct?` (${ct})`:''}`);}
}
async function content(file,fallback){
  const local=fallback||localPathForContent(file);
  const urls=[];
  try{urls.push(`api.php?endpoint=content&file=${encodeURIComponent(file)}&${contentAccessParams()}`);}catch(e){}
  if(local)urls.push(local);
  let last=null;
  for(const url of urls){
    try{
      const data=await fetchJsonSafe(url);
      return isAccessGatedContent(file)?demoOnlyPayload(file,data):data;
    }catch(e){last=e;}
  }
  throw new Error(last?.message||`${file}.json`);
}

function accessGrant(){
  try{return JSON.parse(localStorage.getItem(accessKey)||'null')||{tier:'demo'};}catch(e){return {tier:'demo'};}
}
function hasFullAccess(){const g=accessGrant();const p=profile();return (g.tier==='full'&&!!g.accessToken)||!!p.fullApp;}
function saveAccessGrant(g){localStorage.setItem(accessKey,JSON.stringify(g));}
function contentAccessParams(){
  const g=accessGrant();
  const p=defaultProfile();
  const qs=new URLSearchParams();
  qs.set('profileId',p.profileId);
  if(g.accessToken)qs.set('accessToken',g.accessToken);
  return qs.toString();
}

function playerCfg(){return settings.playerScaling?.[state.players]||{hand:7,timer:90};}
function clearTimer(){if(state.timerId){clearInterval(state.timerId);state.timerId=null;}}
function save(){const clean={...state,timerId:null,installEvent:null};localStorage.setItem(storeKey,JSON.stringify(clean));}
function load(){try{const x=JSON.parse(localStorage.getItem(storeKey)||'null'); if(x) Object.assign(state,x,{timerId:null,installEvent:null,plays:x.plays||[],log:x.log||[],jokers:x.jokers||{fracture:false,resilience:false},phase:x.phase||'intervention',searchLeft:Number.isFinite(x.searchLeft)?x.searchLeft:30,overtimeLeft:Number.isFinite(x.overtimeLeft)?x.overtimeLeft:10,presence:Number.isFinite(x.presence)?x.presence:1,momentTargets:x.momentTargets||null,sequenceNotes:x.sequenceNotes||[],weavedPending:!!x.weavedPending,weavedActive:!!x.weavedActive,weavedPlayed:!!x.weavedPlayed,weavedUsedMoments:x.weavedUsedMoments||{},caseClosed:!!x.caseClosed,lastServerCheck:x.lastServerCheck||null,offlineReminderDismissedAt:x.offlineReminderDismissedAt||null,receptivity:Number.isFinite(x.receptivity)?x.receptivity:2,momentRevealed:x.momentRevealed||{},momentCheckins:x.momentCheckins||{},impact:x.impact||null});}catch(e){}}
function tracker(){try{return JSON.parse(localStorage.getItem(trackerKey)||'{}')}catch(e){return {}}}
function saveTracker(t){localStorage.setItem(trackerKey,JSON.stringify(t));}

function defaultProfile(){
  let p=null;
  try{p=JSON.parse(localStorage.getItem(profileKey)||'null')}catch(e){}
  if(!p||!p.profileId){
    const rand=(crypto&&crypto.getRandomValues)?Array.from(crypto.getRandomValues(new Uint8Array(6))).map(x=>x.toString(16).padStart(2,'0')).join(''):String(Date.now()).slice(-8);
    p={profileId:'local_'+rand,displayName:'Step Away Table',createdAt:new Date().toISOString(),serverSynced:false,totalCases:0,publishedStories:0};
    localStorage.setItem(profileKey,JSON.stringify(p));
  }
  return p;
}
function profile(){return defaultProfile();}
function saveProfile(p){localStorage.setItem(profileKey,JSON.stringify(p));}
async function syncProfile(){
  const p=profile();
  try{
    const r=await fetch(settings.profileEndpoint||'api.php?endpoint=profile',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({profileId:p.profileId,displayName:p.displayName||'Step Away Table'})});
    if(!r.ok)throw new Error('profile offline');
    const j=await r.json();
    if(j.ok&&j.profile){
      Object.assign(p,j.profile,{serverSynced:true});
      saveProfile(p);
      return p;
    }
  }catch(e){p.serverSynced=false;saveProfile(p);}
  return p;
}
function resultHashBasis(outcome){
  const c=currentCase()||{};
  return [profile().profileId,c.id||state.activeCase||'',outcome,state.progress,state.pressure,state.plays.length,state.plays.map(p=>p.cards.join('+')+':'+p.value+':'+p.result).join('|')].join('::');
}
async function sha256(text){
  try{
    const enc=new TextEncoder().encode(text);
    const buf=await crypto.subtle.digest('SHA-256',enc);
    return Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');
  }catch(e){return btoa(unescape(encodeURIComponent(text))).replace(/[^a-zA-Z0-9]/g,'').slice(0,48);}
}

function toast(msg){const t=$('#toast'); if(!t)return; t.textContent=msg;t.classList.remove('hidden');setTimeout(()=>t.classList.add('hidden'),2400)}
function show(screen){if(state.screen==='play'&&screen!=='play'&&(typeof activeCaseOpen==='function'?activeCaseOpen():false)&&state.timerId){clearTimer();state.timeStopped=true;toast('Case paused. Return to continue.');}state.screen=screen;$$('.screen').forEach(s=>s.classList.remove('active'));$('#screen-'+screen)?.classList.add('active');$$('.dock button').forEach(b=>b.classList.toggle('active',b.dataset.screen===screen));renderHeader(); if(screen==='tracker') renderTracker(); if(screen==='deck') renderDeck(); if(screen==='rules') renderRules(); if(screen==='packs') renderPacks(); if(screen==='about') renderAbout(); if(screen==='instructions') renderInstructions(); save();}
function campaign(id){return campaigns.find(c=>c.id===id)}
function currentCase(){return cases.find(c=>c.id===state.activeCase)}
function currentMoment(){const c=currentCase();return c?.beats?.[state.moment]||null}
function currentWrinkle(){return state.campaignRun?.activeWrinkle||null}
function gameCards(){return cards.filter(c=>c.deck==='game')}
function valueCards(){return gameCards().filter(c=>c.type!=='joker')}
function numberCards(){return gameCards().filter(c=>c.type==='number')}
function cardById(id){return cards.find(c=>c.id===id)}
function rankLabel(c){return state.deckMode==='standard'&&c.standard_deck_label?c.standard_deck_label:`${suits[suitByName[c.suit]]?.symbol||''} ${c.rank}`}
function cardOption(c){let label=`${rankLabel(c)} - ${c.title}`; if(c.value) label+=` (VALUE ${c.value}${c.play_mode_label?`, ${c.play_mode_label}`:''})`; return label;}
function getSuitKey(card){return suitByName[card?.suit]||null}
function baseTargets(){return currentMoment()?.suitTargets||{D:10,H:10,C:10,S:10}}
function targets(){return state.momentTargets||baseTargets()}
function crisisTargets(){return state.crisis?.suitTargets||currentMoment()?.crisisSuitTargets||targets()}
function copyTargets(t){return Object.assign({D:10,H:10,C:10,S:10},t||{})}

function progressDisplay(c){const t=c?.progressTarget||0;return state.progress>t?`${t} / ${t} secured, +${state.progress-t} overflow`:`${state.progress} / ${t}`;}
function pressureDisplay(c){const l=c?.pressureLimit||0;return state.pressure<=0?`0 / ${l} calm`:`${state.pressure} / ${l}`;}
function setPresence(delta,reason=''){
  const before=Number.isFinite(state.presence)?state.presence:1;
  state.presence=Math.max(0,Math.min(4,before+(Number(delta)||0)));
  if(state.presence!==before){const text=reason||`Presence ${delta>0?'rises':'drops'} to ${presenceDisplay()}.`;state.sequenceNotes.push({moment:state.moment,text,time:Date.now()});return text;}
  return '';
}
function setReceptivity(delta,reason=''){
  const before=Number.isFinite(state.receptivity)?state.receptivity:2;
  state.receptivity=Math.max(0,Math.min(4,before+(Number(delta)||0)));
  if(state.receptivity!==before){const text=reason||`Receptivity moves to ${receptivityDisplay()}.`;state.sequenceNotes.push({moment:state.moment,text,time:Date.now()});return text;}
  return '';
}
function rangeDisplayForSuit(suit,crisis=false){const r=idealRange(targetForSuit(suit,crisis),suit);return `${r.low}-${r.high}`;}
function momentWeaveUsed(){return !!(state.weavedUsedMoments&&state.weavedUsedMoments[state.moment]);}
function setMomentWeaveUsed(){if(!state.weavedUsedMoments)state.weavedUsedMoments={};state.weavedUsedMoments[state.moment]=true;}
function cardMode(c){return String(c?.play_mode_label||c?.playMode||c?.play_mode||'').toUpperCase();}
function isWeaveOnly(c){return cardMode(c)==='WEAVE ONLY'||String(c?.play_mode||'').toLowerCase()==='weave_only';}
function canSingleCard(c){return !!c && !isWeaveOnly(c) && c.type!=='joker';}
function compatibleWeave(a,b){const x=getSuitKey(a),y=getSuitKey(b);const pair=[x,y].sort().join('');const allowed=new Set(['DH','HC','CS','DS','CD','HS']);return x&&y&&allowed.has(pair);}
function clearOpenModal(){try{const m=$('#modal');if(m&&m.open)m.close();}catch(e){}state.pausedByModal=false;}
function nudgeTarget(s,delta){if(!s)return '';if(!state.momentTargets)state.momentTargets=copyTargets(baseTargets());const before=state.momentTargets[s]??10;state.momentTargets[s]=Math.max(5,before+delta);const sign=delta>0?`+${delta}`:`${delta}`;return `${suits[s].name} target ${sign}.`;}
function applySequenceShift(data,band){
  if(state.crisis)return '';
  let parts=[];
  const key=band.key;
  if(key==='ideal') parts.push(nudgeTarget(data.supportSuit||data.leadSuit,-1)||'The room becomes easier to read.');
  else if(key==='under') parts.push(nudgeTarget(data.leadSuit,1)||'The Moment asks for clearer contact.');
  else if(key==='miss') parts.push(nudgeTarget(data.leadSuit,1)||'The Moment tightens around what was missed.');
  else if(key==='heavy') parts.push('The help has weight, but the table feels the cost of using force.');
  else if(key==='overwhelm') parts.push(nudgeTarget(data.leadSuit,2)||'The room narrows under too much help.');
  if(Number.isFinite(band.presence)&&band.presence!==0) parts.push(setPresence(band.presence, band.presence>0?'Presence rises because the intervention fitted the Moment.':'Presence drops because the intervention did not reach safely.'));
  if(Number.isFinite(band.receptivity)&&band.receptivity!==0) parts.push(setReceptivity(band.receptivity, band.receptivity>0?'They become more open, but also more exposed.':'They become less receptive. The next help needs to fit.'));
  if(data.weaveOnly&&key!=='miss'&&key!=='overwhelm') parts.push(setPresence(1,'Presence rises because the care-chain connected.'));
  parts=parts.filter(Boolean);
  if(parts.length) state.sequenceNotes.push({moment:state.moment,text:parts.join(' '),time:Date.now()});
  return parts.join(' ');
}
function startIntervention(){if(state.phase==='intervention'){state.timeStopped=false;if(!state.timerId)state.timerId=setInterval(tick,1000);renderPlay();save();return;}state.phase='intervention';state.timerLeft=playerCfg().timer;state.timeStopped=false;clearTimer();state.timerId=setInterval(tick,1000);addLog('Moment started','The Moment is live. DIG remains physical table work.','timer');renderPlay();save();}
function flashImpact(play){const el=$('#impactFlash');if(!el||!play)return;const emoji=play.story?.emoji||state.impact?.emoji||'•';const impact=play.story?.impact||state.impact?.impact||play.result||'Impact noted';el.innerHTML=`<b>${esc(emoji)}</b><span>${esc(impact)}</span>`;el.classList.remove('hidden','flash-run');void el.offsetWidth;el.classList.add('flash-run');setTimeout(()=>el.classList.add('hidden'),1700);}
function revealMomentMeters(reason='check-in'){
  state.momentRevealed = state.momentRevealed || {};
  state.momentCheckins = state.momentCheckins || {};
  state.momentRevealed[state.moment] = reason;
  if(reason==='check-in') state.momentCheckins[state.moment] = true;
}
function metersVisible(){
  return !!(state.crisis || state.phase==='weaved' || (state.momentRevealed && state.momentRevealed[state.moment]));
}
function presenceLabel(v=state.presence){
  const labels=['Gone Quiet','Flickering','Reachable','With You','Anchored'];
  return labels[Math.max(0,Math.min(labels.length-1,Number(v)||0))]||'Flickering';
}
function presenceDisplay(){return presenceLabel(state.presence);}
function receptivityLabel(v=state.receptivity){
  const labels=['Closed','Guarded','Receptive','Warmed','Overwhelmed'];
  return labels[Math.max(0,Math.min(labels.length-1,Number(v)||0))]||'Receptive';
}
function receptivityDisplay(){return receptivityLabel(state.receptivity);}
function activeClock(){return state.phase==='overtime'?state.overtimeLeft:state.timerLeft;}
function phaseLabel(){
  if(state.phase==='overtime')return 'More time';
  if(state.phase==='weaved')return 'Weaved';
  if(state.timerId)return 'Live';
  if(state.timerLeft<playerCfg().timer)return 'Paused';
  return 'Ready';
}
function timerButtonLabel(){
  if(state.phase==='overtime')return 'End Moment';
  if(state.timerId)return 'Pause Moment';
  if(state.timerLeft<playerCfg().timer)return 'Resume Moment';
  return state.moment===0&&state.plays.length===0?'Begin Case':'Begin Moment';
}

function resetMomentFlow(autoStart=false){state.phase='intervention';state.searchLeft=0;state.overtimeLeft=10;state.momentTargets=copyTargets(baseTargets());state.sequenceNotes=[];state.weavedPending=false;state.weavedActive=false;state.weavedPlayed=false;state.timerLeft=playerCfg().timer;state.timeStopped=!autoStart;clearTimer();if(autoStart){state.timeStopped=false;state.timerId=setInterval(tick,1000);}}
function targetForSuit(suit,crisis=false){return (crisis?crisisTargets():targets())[suit] ?? 12}
function relevanceRank(suit,crisis=false){const t=crisis?crisisTargets():targets();return Object.entries(t).sort((a,b)=>a[1]-b[1]).findIndex(x=>x[0]===suit)}
function supportModifier(suit,crisis=false){const r=relevanceRank(suit,crisis);return [-2,-1,1,2][r]??1}
function idealRange(target, suit=null){
  const t=Number(target)||10;
  const pres=state.presence||1;
  const rec=state.receptivity??2;
  let low=t-1, high=t+1;
  if(pres>=3) high+=1;
  if(rec>=3) low+=1;
  if(rec<=1) high-=1;
  return {low:Math.max(2,low), high:Math.max(Math.max(2,low),high)};
}
function resultBand(value,target){
  const r=idealRange(target);
  const deltaLow=value-r.low, deltaHigh=value-r.high;
  if(value>=r.low && value<=r.high) return {key:'ideal',label:'Ideal fit',progress:4,pressure:-1,presence:1,receptivity:0,emoji:'🫱',pulse:'They take it in.',range:r,margin:0};
  if(value<r.low){
    const gap=r.low-value;
    if(gap<=2) return {key:'under',label:'Too gentle',progress:1,pressure:0,presence:0,receptivity:-1,emoji:'…',pulse:'It barely reaches them.',range:r,margin:-gap};
    return {key:'miss',label:'Missed reach',progress:0,pressure:1,presence:-1,receptivity:-1,emoji:'🫥',pulse:'They drift further away.',range:r,margin:-gap};
  }
  const over=value-r.high;
  if(over<=2) return {key:'heavy',label:'Heavy but useful',progress:2,pressure:1,presence:0,receptivity:1,emoji:'⚠️',pulse:'It helps, but it presses hard.',range:r,margin:over};
  return {key:'overwhelm',label:'Overwhelmed',progress:0,pressure:2,presence:-1,receptivity:1,emoji:'🌀',pulse:'It crowds the room.',range:r,margin:over};
}
function hashCode(text){let h=2166136261; text=String(text||''); for(let i=0;i<text.length;i++){h^=text.charCodeAt(i); h=Math.imul(h,16777619);} return Math.abs(h>>>0);}
function pick(list,key){if(!Array.isArray(list)||!list.length)return ''; return list[hashCode(key)%list.length];}
function categoryVoice(cat){return ({
 quiet_drift:{name:'Quiet Drift',edge:'The risk hides in quiet distance, shortened contact and routines everyone has learned to explain away.',saved:'The table did not wait for a perfect warning sign. It treated distance as something worth answering.',partial:'Some of the silence was reached, but part of the distance stayed in place.',lost:'The quiet became too easy to accept.',teaches:['Withdrawal can look ordinary from the outside.','A change in contact, routine or tone is information.','Early noticing and steady presence can prevent the case becoming an emergency.']},
 heavy_load:{name:'Heavy Load',edge:'The risk gathers as practical weight, shame and exhaustion become too much for one person to carry.',saved:'The table made the burden practical enough to share.',partial:'Some weight moved, but the next day still needed more structure.',lost:'The burden stayed too private and too heavy.',teaches:['Practical burdens can become emotional danger.','Help lands harder when it becomes named, timed and shared.','Nobody should be made into the lone rescuer, build a real hand-off.']},
 frayed_connections:{name:'Frayed Connections',edge:'The risk grows through shame, fallout and the belief that being alone is safer than being seen.',saved:'The table put a living thread back into the case.',partial:'A thread remained, but it was not yet strong enough to fully trust.',lost:'The person was left too alone with their own story.',teaches:['Isolation changes the risk picture.','Staying with someone can be an intervention before advice arrives.','A hand-off matters only if the next person truly has them.']},
 immediate_edge:{name:'Immediate Edge',edge:'The risk is close to the hour itself. Safety has to beat pride, politeness and perfect phrasing.',saved:'The table chose life before embarrassment, and the hour was protected.',partial:'The hour was not lost, but it stayed unstable and costly.',lost:'The case moved faster than the table could secure it.',teaches:['Immediate safety beats pride, politeness or perfect wording.','Create space between the person and the risk.','If life is at risk in real life, stop playing and get urgent help.']}
})[cat]||{name:'Step Away',edge:'The case asks what kind of help fits the Moment.',saved:'The table kept the person in reach.',partial:'The table held part of the case.',lost:'The case was not held in time.',teaches:['Notice the shift.','Stay with the person.','Make help real.','Protect the hour.']};}
const suitNarrative={
 D:{name:'Seen',verb:'noticing the shift',lesson:'This teaches that warning signs are often changes in pattern, not dramatic announcements.',strong:['The table read the change before it hardened into silence. What had looked ordinary became visible enough to answer.','The Seen card cut through the excuse layer. The case stopped being vague and became specific.','The table named the pattern without turning the person into a problem to solve.'],success:['The shift was recognised in time to matter. The Moment became clearer.','The table noticed enough to stop drifting with the room.','The intervention made the hidden pattern harder to ignore.'],partial:['Something was noticed, but not cleanly enough to settle the Moment.','The table caught part of the signal, while part of the risk stayed under the surface.','The card opened a question, not yet an answer.'],miss:['The table looked, but the Moment needed more than recognition. Pressure stayed close.','The sign was read too narrowly. The case tightened around what remained unsaid.','Seeing the pattern helped a little, but the response did not yet meet the need.'],harmful:['The table treated observation like intervention. The person stayed exposed without being held.','The Moment recoiled because being noticed was not the same as being helped.','The card named the room, but did not change it.']},
 H:{name:'Held',verb:'staying with the person',lesson:'This teaches that presence is not passive. Staying can be the thing that keeps the hour open.',strong:['The table stayed close without crowding the person. The Moment had more air in it.','Held landed as presence, not performance. The person did not have to carry the room alone.','The intervention made connection feel available without demanding a perfect confession.'],success:['The person remained reachable. That mattered more than it looked.','The table kept the thread alive long enough for the next choice to exist.','The card softened the edge of the Moment.'],partial:['Presence reached them, but did not fully move the pressure.','The table stayed near, though the case still asked for more structure.','The Moment was not solved, but it was less alone.'],miss:['Staying close was not enough by itself here. The case needed a sharper practical move.','The table offered presence, but the pressure kept building around it.','The person was held for a moment, but the risk still had room.'],harmful:['The table stayed, but without changing the conditions around the risk. The Moment narrowed.','Presence became too soft for what the hour required.','The card comforted the room more than it protected the person.']},
 C:{name:'Helped',verb:'making help real',lesson:'This teaches that help has to become named, timed and reachable, not just suggested.',strong:['Help stopped being a slogan and became a route. The case gained structure.','The table gave the Moment a next step with a real person attached to it.','The intervention moved weight out of the person and into a shared plan.'],success:['A practical route appeared. The table had more to work with.','The help became concrete enough to change the case.','The card moved the situation from concern into action.'],partial:['Help was named, but not yet fully secured.','The plan became more real, though the Moment still needed holding.','The table built part of a bridge, not the whole crossing.'],miss:['The table tried to organise help, but the timing or fit was off.','Practical help was useful, but the Moment resisted being turned into admin.','The card brought options, but not enough trust around them.'],harmful:['The table moved too fast into fixing. The person risked disappearing behind the plan.','Help arrived as pressure rather than relief.','The card made a route, but not one the person could yet walk.']},
 S:{name:'Secured',verb:'protecting the hour',lesson:'This teaches that when risk is immediate, safety is not overreaction, it is the intervention.',strong:['The table protected the hour before pride could argue. The case became safer in a physical, practical way.','Secured landed cleanly. Space opened between the person and the risk.','The intervention changed the immediate environment, and the Moment stopped accelerating.'],success:['The hour had more structure around it.','The card reduced the room’s sharp edges.','The table bought time, and time mattered.'],partial:['Some safety was built, but not enough to relax.','The table slowed the next move without fully changing the case.','The intervention bought minutes, not certainty.'],miss:['Securing the space was useful, but the Moment needed another kind of contact too.','The table tried to protect the hour, but the person stayed emotionally out of reach.','The card changed the scene without fully changing the case.'],harmful:['The table reached for safety in a way that felt too late or too blunt.','Protection became control in the wrong Moment. Pressure rose.','The card tried to close the danger, but missed the person inside it.']}
};
function resultKey(label){label=String(label||'').toLowerCase(); if(label.includes('strong'))return 'strong'; if(label==='success'||label.includes('success'))return 'success'; if(label.includes('partial'))return 'partial'; if(label.includes('harm'))return 'harmful'; if(label.includes('miss'))return 'miss'; return 'partial';}
function relevanceName(suit,crisis=false){const r=relevanceRank(suit,crisis); return ['core','supporting','strained','late'][r]||'strained';}
function pressurePhrase(){const c=currentCase(); if(!c)return 'The case is waiting.'; const rr=state.pressure/(c.pressureLimit||6), pr=state.progress/(c.progressTarget||20); if(state.crisis)return 'The room has tipped into crisis. Every card now reads as part of the emergency record.'; if(rr>.8)return 'Pressure is high enough that the table can feel the margin thinning.'; if(pr>.75)return 'The case has a route now, but the ending is not earned until the Moment is closed.'; if(pr>.35)return 'The story is changing because the table has started to leave evidence behind.'; return 'The first job is not to win the case, it is to read what kind of help the Moment is asking for.';}
function momentIntro(m,i){const arr=m?.narrative?.chapterLead||[]; return pick(arr,`${currentCase()?.id}:${i}:${state.plays.length}`)||m?.text||'';}
function buildInterventionStory(data,band,target){const c=currentCase()||{}, m=currentMoment()||{}; const cat=categoryVoice(c.campaign); const key=resultKey(band.label); const lead=suitNarrative[data.leadSuit]||suitNarrative.D; const support=data.supportSuit?(suitNarrative[data.supportSuit]||null):null; const leadRel=relevanceName(data.leadSuit,!!state.crisis); const supportRel=support?relevanceName(data.supportSuit,!!state.crisis):null; const cardNames=data.cards.map(c=>c.title).join(' + '); const seed=[c.id,state.moment,cardNames,data.value,target,band.label,state.plays.length,state.pressure].join('|'); const suitLine=pick(lead[key]||lead.partial,seed); let headline=''; let body=''; if(data.kind==='Weave'){
  const sameSuit=support&&lead.name===support.name;
  headline=sameSuit?`${lead.name} doubled down`:`${lead.name} led, ${support.name} supported`;
  const weaveLine=sameSuit
    ? pick([`The Weave did not split its meaning. Both cards pushed ${lead.verb}, making one kind of help louder and more committed.`,`Because both cards belonged to ${lead.name}, the archive reads this as a doubled intervention rather than a mixed approach.`,`The support card did not change direction. It reinforced the Lead suit and made the table commit harder to that route.`],seed+'weave')
    : pick([`The Weave did not just add numbers. It made ${lead.verb} the main move, while ${support.verb} shaped how that move landed.`,`Because the higher card led, the archive reads this as ${lead.name} first and ${support.name} second. That changes the story of the intervention.`,`The support card mattered, but it did not take over the Moment. It changed the target, then let the Lead suit carry the meaning.`],seed+'weave');
  body=sameSuit?`${suitLine} ${weaveLine} ${lead.name} was ${leadRel} for this Moment. VALUE ${data.value} met target ${target} as ${band.label.toLowerCase()}.`:`${suitLine} ${weaveLine} ${lead.name} was ${leadRel} for this Moment; ${support.name} was ${supportRel}. VALUE ${data.value} met target ${target} as ${band.label.toLowerCase()}.`;
 } else {
  headline=`${lead.name} intervention`;
  body=`${suitLine} ${lead.name} was ${leadRel} for this Moment. VALUE ${data.value} met target ${target} as ${band.label.toLowerCase()}.`;
 }
 if(state.crisis){body+=` Because this happened inside a Crisis Window, it became part of the emergency total rather than a normal Moment beat.`;}
 const lesson=pick([lead.lesson,`The case is teaching that ${lead.verb} only works when it fits the moment in front of the table.`,`${cat.name} cases reward the right shape of help, not just the largest number.`],seed+'lesson');
 return {headline,body,lesson,lead:lead.name,support:support?.name||'',leadRelevance:leadRel,supportRelevance:supportRel,resultKey:key,category:cat.name,moment:m.title||'',cards:cardNames};
}
function jokerStory(which,card){const base=which==='fracture'?{headline:'Fracture Moment interrupted the plan',body:'The room cracked. Whatever the table thought it had organised no longer held in quite the same shape. Hands were reset, and the case forced everyone to work from a changed emotional position.',lesson:'This teaches that crisis work is rarely linear. A good plan can still be disrupted.'}:{headline:'Resilience returned spent effort to reach',body:'Something that had been spent was not gone. The discard returned to the top of the draw deck, turning previous effort back into available ground.',lesson:'This teaches that support can come back into reach, especially when the table has already done real work.'};return {...base,resultKey:'event',category:categoryVoice(currentCase()?.campaign).name,moment:currentMoment()?.title||'',cards:card?.title||which};}
function buildNovella(snap){const cat=categoryVoice(snap.category);const sections=[];const opening=[];opening.push(snap.opening||'');opening.push((snap.caseVoice||cat.edge));opening.push(`This archive is not a score sheet. It is the record of how the table read the case: what it noticed, what it tried, when pressure rose, and which cards became the turning points.`);sections.push({kicker:'Case file',title:snap.title,paragraphs:opening.filter(Boolean)});(snap.moments||[]).forEach(m=>{const paras=[];paras.push(m.text||'');if(m.watch||m.aim)paras.push(`The table was asked to watch for ${m.watch||'the shift'} and aim toward ${m.aim||'a fitting intervention'}.`);if(!m.plays||!m.plays.length){paras.push('No committed intervention was recorded here. In the story of the case, that absence matters: the Moment moved on without clear evidence of help.');}else{m.plays.forEach(p=>{if(p.story?.body)paras.push(p.story.body); else paras.push(`${p.cards.map(c=>c.title).join(' + ')} landed as ${p.result} with VALUE ${p.value}.`);if(p.story?.lesson)paras.push(p.story.lesson);});}const crisis=m.plays?.some(p=>p.crisis); if(crisis)paras.push('Because one or more plays happened inside a Crisis Window, this chapter reads as an emergency beat rather than ordinary support. Closeness mattered, even when the first card did not fully resolve the danger.');sections.push({kicker:`Moment ${m.index}`,title:m.title,paragraphs:paras});});const end=[];end.push(snap.closing||'');end.push((cat[snap.outcome]||''));end.push(`Final archive: progress ${snap.progress.value}/${snap.progress.target}, pressure ${snap.pressure.value}/${snap.pressure.limit}, presence ${snap.presence?.label||'Flickering'}, receptivity ${snap.receptivity?.label||'Receptive'}, interventions ${snap.plays.length}.`);sections.push({kicker:'Outcome',title:snap.outcomeLabel,paragraphs:end.filter(Boolean)});return {version:'case-novella-v1',sections,teaching:snap.teachingPoints||[],summary:`${snap.outcomeLabel}: ${snap.title}. ${cat.edge}`};}
function novellaHtml(story){if(!story?.sections)return '';return `<section class="novella-preview">${story.sections.map(sec=>`<article><p class="eyebrow">${esc(sec.kicker||'Story')}</p><h3>${esc(sec.title||'')}</h3>${(sec.paragraphs||[]).map(p=>`<p>${esc(p)}</p>`).join('')}</article>`).join('')}</section>`;}

function casePulseText(play=null){const c=currentCase(); if(!c)return{title:'No case',body:'Start a case.'}; if(state.crisis)return{title:'Crisis Window',body:`${pressurePhrase()} Crisis total is ${state.crisis.score||0}/${state.crisis.target}. Keep intervening, or deliberately fail if the table has no hand left.`}; if(play&&play.story){return{title:play.story.headline,body:play.story.body};} const m=currentMoment(); const pr=state.progress/(c.progressTarget||20), rr=state.pressure/(c.pressureLimit||6); const intro=momentIntro(m,state.moment); if(rr>.75)return{title:'The margin is thin',body:`${pressurePhrase()} ${intro}`}; if(pr>.65)return{title:'The case has a route',body:`${pressurePhrase()} End the Moment if the table has done enough, or push further and spend more of the deck.`}; if(pr>.25)return{title:'The story is changing',body:`${pressurePhrase()} ${intro}`}; return{title:'Search the Moment',body:`${pressurePhrase()} ${intro}`};}
function suitTargetBar(crisis=false){const t=crisis?crisisTargets():targets();return '<div class="suit-targets">'+['D','H','C','S'].map(s=>`<div style="--suit:${suits[s].colour}"><b>${suits[s].symbol} ${suits[s].name}</b><span>Ideal ${rangeDisplayForSuit(s,crisis)}</span></div>`).join('')+'</div>';}


function activeCaseOpen(){return !!state.activeCase && !state.caseClosed && !!currentCase();}
function clearActiveCaseState(){
  clearTimer();
  state.activeCase=null;
  state.caseClosed=true;
  state.crisis=null;
  state.phase='intervention';
  state.timeStopped=false;
  state.timerLeft=playerCfg().timer;
  state.weavedPending=false;
  state.weavedActive=false;
  state.weavedPlayed=false;
  state.weavedUsedMoments={};
  state.momentTargets=null;
  state.impact=null;
  save();
}
function confirmAbandonCase(after){
  const c=currentCase();
  if(!activeCaseOpen()){ if(after)after(); return; }
  modal(`<h2>Abandon this case?</h2><p>You are choosing to stop this case before it is resolved. The table is leaving this person to Step Away.</p><p><b>${esc(c?.title||'Current case')}</b></p><div class="modal-actions"><button id="confirmAbandon" class="danger">Abandon case</button><button id="cancelAbandon" class="secondary">Stay with case</button></div>`);
  $('#cancelAbandon').onclick=closeModal;
  $('#confirmAbandon').onclick=()=>{addLog('Case abandoned','The table stepped away before resolution.','outcome');clearActiveCaseState();closeModal();toast('Case abandoned');if(after)after();else{show('home');renderAll();}};
}

function abandonCase(){confirmAbandonCase(()=>{show('home');renderAll();});}
function safeStartCase(caseId,mode='standalone',run=null){
  if(activeCaseOpen()) return confirmAbandonCase(()=>initStateForCase(caseId,mode,run));
  initStateForCase(caseId,mode,run);
}
function validateLoadedState(){
  if(state.activeCase && !cases.some(c=>c.id===state.activeCase)){
    clearTimer();
    state.activeCase=null;
    state.caseClosed=true;
    state.screen='home';
    save();
    return;
  }
  const c=currentCase();
  if(c && (!Number.isFinite(state.moment)||state.moment<0||state.moment>=c.beats.length)){
    state.moment=0;
    resetMomentFlow(false);
    save();
  }
}
function beginNewCaseFlow(campaignMode=false){
  const go=()=>{state.selectedCampaign=campaigns[0]?.id;show('setup');renderSetup();if(campaignMode)toast('Choose category, then Start Campaign');};
  if(activeCaseOpen()) confirmAbandonCase(go); else go();
}
function initStateForCase(caseId,mode='standalone',run=null){clearTimer();Object.assign(state,{activeCase:caseId,caseClosed:false,mode,campaignRun:run,moment:0,progress:0,pressure:0,presence:1,receptivity:2,momentRevealed:{},momentCheckins:{},impact:null,crisis:null,log:[],plays:[],lastPlay:null,jokers:{fracture:false,resilience:false},weavedPending:false,weavedActive:false,weavedPlayed:false,weavedUsedMoments:{},timerLeft:playerCfg().timer,timeStopped:false}); resetMomentFlow(false); const wr=currentWrinkle(); if(wr?.apply==='pressure_minus_1_start') state.pressure=Math.max(0,state.pressure-1); addLog('Case opened',currentCase()?.opening||''); save(); show('play'); renderAll();}
function startStandalone(caseId){safeStartCase(caseId,'standalone',null)}
function startRun(campaignId){const list=cases.filter(c=>c.campaign===campaignId); const run={id:'run_'+Date.now(),campaign:campaignId,caseNumber:1,hard:0,easy:0,history:[],activeWrinkle:null}; safeStartCase(list[0].id,'campaign',run)}
function continueRun(){const run=state.campaignRun;if(!run)return;const list=cases.filter(c=>c.campaign===run.campaign); const next=list[run.history.length%list.length]; safeStartCase(next.id,'campaign',run)}
function renderAll(){renderHome();renderSetup();renderPlay();renderDeck();renderRules();renderPacks();renderInstructions();renderAbout();renderHeader();}
function renderHeader(){const c=currentCase();$('#topStatus').textContent=c?`${c.title} · Moment ${state.moment+1}`:'No case loaded';}

function renderAccessStatus(){
  const el=$('#accessStatus'); if(!el)return;
  const g=accessGrant();
  const full=hasFullAccess();
  const credits=Number(g.walletCredits||0);
  el.className='access-status panel '+(full?'full':'demo');
  el.innerHTML=full
    ? `<span><b>Purchase access unlocked.</b> Full case system active${g.label?`: ${esc(g.label)}`:''}.${credits?` Pack credits: ${credits}.`:''}</span><button id="manageAccessBtn" class="secondary">Access</button>`
    : `<span><b>Poker-deck demo mode.</b> One demo case is available. A PayPal transaction ID from an official deck or app-access purchase unlocks the full system.</span><button id="manageAccessBtn" class="primary">Enter transaction ID</button>`;
  $('#manageAccessBtn').onclick=openAccess;
}
function openAccess(){
  const g=accessGrant();
  const full=hasFullAccess();
  modal(`<h2>${full?'Purchase access active':'Unlock full Step Away'}</h2>
    <p>${full?'This device has full app access. You can add another PayPal transaction ID to attach a new deck purchase or app-access purchase to this profile.':'Poker-deck demo mode gives one case. Enter the PayPal transaction ID from an official deck purchase or app-access-only purchase to unlock all cases and packs on this device.'}</p>
    <p class="muted">Official deck purchases support up to three active profiles. App-access-only purchases support one active profile by default. If the limit is exceeded, access moves from the oldest active profile to the newer one.</p>
    ${full?`<p><b>Access:</b> ${esc(g.label||'PayPal Purchase')}${g.walletCredits?` · Credits ${esc(g.walletCredits)}`:''}</p>`:''}
    <input id="accessCodeInput" class="access-code-box" placeholder="PAYPAL TRANSACTION ID" autocomplete="off">
    <div class="modal-actions">
      <button id="activateAccessBtn" class="primary">${full?'Add transaction':'Unlock'}</button>
      ${full?'<button id="clearAccessBtn" class="secondary">Clear access on this device</button>':''}
      <button id="closeModal" class="secondary">Close</button>
    </div>`);
  $('#closeModal').onclick=closeModal;
  $('#activateAccessBtn')?.addEventListener('click',activateAccess);
  $('#clearAccessBtn')?.addEventListener('click',()=>{localStorage.removeItem(accessKey);closeModal();location.reload();});
}
async function activateAccess(){
  const code=$('#accessCodeInput')?.value||'';
  if(!code.trim())return toast('Enter the PayPal transaction ID.');
  const p=profile();
  try{
    const r=await fetch('api.php?endpoint=transaction-activate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({transactionId:code,profileId:p.profileId})});
    const j=await r.json();
    if(!r.ok||!j.ok)throw new Error(j.error||'Access failed');
    saveAccessGrant({
      tier:j.tier||'full',
      accessToken:j.accessToken,
      label:j.label||'PayPal Purchase',
      productType:j.productType||'purchase',
      walletCredits:j.walletCredits||0,
      deckOwner:!!j.deckOwner,
      appAccessOnly:!!j.appAccessOnly,
      activatedAt:new Date().toISOString()
    });
    const msg=j.creditAdded?`Full app active. ${j.creditAdded} pack credit added.`:(j.productType==='app_access_only'?'App access unlocked.':'Full app unlocked.');
    toast(msg);
    setTimeout(()=>location.reload(),800);
  }catch(e){
    const m=e.message||'';
    toast(m==='activation_limit_reached'||m==='profile_limit_reached'?'Profile limit reached for that transaction.':m==='transaction_not_found'?'Transaction not recognised yet.':m==='transaction_not_completed'?'Transaction is not marked completed yet.':'Could not unlock. Check the transaction ID and hosting.');
  }
}

function renderHome(){renderAccessStatus();const cont=$('#continueBtn');cont.disabled=!activeCaseOpen();cont.onclick=()=>activeCaseOpen()?show('play'):toast('No live case saved');$('#newCaseBtn').onclick=()=>beginNewCaseFlow(false);$('#campaignBtn').onclick=()=>beginNewCaseFlow(true);$('#campaignGrid').innerHTML=campaigns.map(c=>`<article class="campaign-card panel" style="--accent:${c.accent}"><h2>${esc(c.title)}</h2><h3>${esc(c.tagline)}</h3><p>${esc(c.summary||c.theme)}</p><div class="row"><button data-setup="${c.id}">Cases</button><button data-run="${c.id}">Campaign</button></div></article>`).join('');$$('[data-setup]').forEach(b=>b.onclick=()=>{state.selectedCampaign=b.dataset.setup;show('setup');renderSetup()});$$('[data-run]').forEach(b=>b.onclick=()=>startRun(b.dataset.run));$$('.quick-card[data-screen]').forEach(b=>b.onclick=()=>show(b.dataset.screen));}
function renderSetup(){if(!state.selectedCampaign)state.selectedCampaign=campaigns[0]?.id;$('#deckModeSeg').innerHTML=['official','standard'].map(m=>`<button data-mode="${m}" class="${state.deckMode===m?'selected':''}">${m==='official'?'Official deck':'Standard deck'}</button>`).join('');$$('#deckModeSeg button').forEach(b=>b.onclick=()=>{state.deckMode=b.dataset.mode;localStorage.setItem('sa.deckMode',state.deckMode);renderSetup();renderPlay();});$('#playerRow').innerHTML=[1,2,3,4].map(p=>`<button data-p="${p}" class="${state.players===p?'selected':''}">${p}</button>`).join('');$$('#playerRow button').forEach(b=>b.onclick=()=>{state.players=Number(b.dataset.p);localStorage.setItem('sa.players',state.players);resetTimer(false);renderSetup();renderPlay();});const cfg=playerCfg();$('#scaleNote').textContent=`Hand ${cfg.hand}, ${cfg.timer} seconds per Moment. Draw back up only when the Moment ends.`;$('#categorySelect').innerHTML=campaigns.map(c=>`<button data-camp="${c.id}" class="${state.selectedCampaign===c.id?'selected':''}" style="--accent:${c.accent}">${c.title}</button>`).join('');$$('#categorySelect button').forEach(b=>b.onclick=()=>{state.selectedCampaign=b.dataset.camp;renderSetup()});const list=cases.filter(c=>c.campaign===state.selectedCampaign);$('#caseGrid').innerHTML=list.map(c=>`<article class="case-card panel"><h2>${esc(c.title)}</h2><h3>${esc(c.subtitle)} · ${esc(c.difficulty)}</h3><p>${esc(c.opening||c.beats[0]?.text||'')}</p><div class="row"><button data-case="${c.id}">Standalone</button><button data-campaign-case="${c.id}">Campaign</button></div></article>`).join('');$$('[data-case]').forEach(b=>b.onclick=()=>startStandalone(b.dataset.case));$$('[data-campaign-case]').forEach(b=>b.onclick=()=>{const run={id:'run_'+Date.now(),campaign:state.selectedCampaign,caseNumber:1,hard:0,easy:0,history:[],activeWrinkle:null};initStateForCase(b.dataset.campaignCase,'campaign',run)});}
function renderPlay(){const c=currentCase();const m=currentMoment();$('#standardBanner').classList.toggle('hidden',state.deckMode!=='standard');$('#buyLink').href=settings.officialDeckUrl||'official-deck.html';if(!c||!m){$('#playCampaign').textContent='Case';$('#playCaseTitle').textContent='No case loaded';$('#playCaseSubtitle').textContent='Choose New to start a case.';$('#momentKicker').textContent='Moment';$('#momentTitle').textContent='No active Moment';$('#momentText').textContent='';$('#momentWatch').textContent='';$('#momentAim').textContent='';$('#progressText').textContent='0 / 0';$('#pressureText').textContent='0 / 0';$('#progressFill').style.width='0%';$('#pressureFill').style.width='0%';if($('#presenceText'))$('#presenceText').textContent='No active case';if($('#presenceFill'))$('#presenceFill').style.width='0%';$('#timerText').textContent='0';$('#timerToggle').textContent='Begin Case';$('#digBadge').textContent='No live case';$('#wrinkleBadge').textContent='';$('#casePulse').innerHTML='<b>No active case</b><span>Start a new case when the table is ready.</span>';renderHeader();return;}const camp=campaign(c.campaign);const visible=metersVisible();$('#playCampaign').textContent=`${camp?.title||'Case'} · ${state.mode==='campaign'?'Campaign':'Standalone'}`;$('#playCaseTitle').textContent=c.title;$('#playCaseSubtitle').textContent=c.subtitle;$('#momentKicker').textContent=`Moment ${state.moment+1} of ${c.beats.length} · ${visible?'Check-in revealed':'Read the person first'}`;$('#momentTitle').textContent=m.title;$('#momentText').textContent=m.text;$('#momentWatch').textContent=m.watch;$('#momentAim').textContent=m.aim;
if(visible){$('#progressText').textContent=progressDisplay(c);$('#pressureText').textContent=pressureDisplay(c);$('#progressFill').style.width=Math.min(100,100*state.progress/c.progressTarget)+'%';$('#pressureFill').style.width=Math.min(100,100*state.pressure/c.pressureLimit)+'%';if($('#presenceText'))$('#presenceText').textContent=presenceDisplay()+` · ${receptivityLabel()}`;if($('#presenceFill'))$('#presenceFill').style.width=Math.min(100,25*(state.presence||0))+'%';}else{$('#progressText').textContent='Check-in pending';$('#pressureText').textContent='Check-in pending';$('#progressFill').style.width='0%';$('#pressureFill').style.width='0%';if($('#presenceText'))$('#presenceText').textContent='Read the reaction';if($('#presenceFill'))$('#presenceFill').style.width='0%';}
$('#handBadge').textContent=`Hand ${playerCfg().hand} · draw at Moment end`;$('#modeBadge').textContent=state.deckMode==='official'?'Official deck':'Standard deck';$('#digBadge').textContent=state.phase==='weaved'?`Weaved Moment: one intervention · ${state.timerLeft}s`:state.phase==='overtime'?`More-time choice: ${state.overtimeLeft}s · default end`:'Live DIG: physical search while the timer runs';$('#wrinkleBadge').textContent=currentWrinkle()?`Wrinkle: ${currentWrinkle().title}`:(visible?`Receptivity: ${receptivityDisplay()}`:'Meters hidden until check-in');$('#timerText').textContent=activeClock();$('#timerToggle').textContent=timerButtonLabel();const timerPhase=$('#timerPhase');if(timerPhase)timerPhase.textContent=phaseLabel();renderCrisis();const pulse=casePulseText(state.lastPlay&&state.lastPlay.moment===state.moment?state.lastPlay:null);$('#casePulse').innerHTML=`<b>${esc(pulse.title)}</b><span>${esc(pulse.body)}</span>${visible?suitTargetBar(!!state.crisis):'<div class="suit-targets hidden-targets"><div><b>Targets hidden</b><span>Use the reaction, then check in.</span></div></div>'}`;renderSelectors();renderEvidence();renderTools();renderHeader();}
function renderCrisis(){const el=$('#crisisWindow');if(!state.crisis){el.classList.add('hidden');return;}el.classList.remove('hidden');$('#crisisTitle').textContent=state.crisis.title||'The room has changed';$('#crisisText').textContent='Resolve the Crisis Window with the same card system. VALUE adds into the crisis total. Closeness matters.';$('#crisisTarget').textContent=`${state.crisis.score||0} / ${state.crisis.target}`;$('#failCrisisBtn').onclick=failCrisis;}
function optionList(list){return '<option value="">Choose card</option>'+list.map(c=>`<option value="${c.id}">${esc(cardOption(c))}</option>`).join('')}
function groupedNumberOptions(){return '<option value="">Choose numbered card</option>'+['D','H','C','S'].map(s=>`<optgroup label="${suits[s].symbol} ${suits[s].name}">${numberCards().filter(c=>getSuitKey(c)===s).map(c=>`<option value="${c.id}">${esc(cardOption(c))}</option>`).join('')}</optgroup>`).join('')}
function renderSelectors(){const singles=valueCards().filter(canSingleCard);const nums=numberCards();const weaveUsed=momentWeaveUsed()||state.plays.some(p=>p.moment===state.moment&&p.kind==='Weave');$('#singleRow').innerHTML=['D','H','C','S'].map(s=>`<div class="suit-col suit-${s}"><div class="suit-title">${suits[s].symbol} ${suits[s].name}</div><select data-single-suit="${s}">${optionList(singles.filter(c=>getSuitKey(c)===s))}</select><div class="suit-help">${suits[s].verb}</div></div>`).join('');const weaveOptions=groupedNumberOptions();$('#weaveRow').classList.add('weave-open');$('#weaveRow').innerHTML=`<div class="weave-pair"><div class="weave-card"><label>First numbered card</label><select data-weave-card="a">${weaveOptions}</select><span>Weave Only cards need a compatible partner.</span></div><div class="weave-card"><label>Second numbered card</label><select data-weave-card="b">${weaveOptions}</select><span>Only one Weave can be played per Moment.</span></div></div>`;const locked=state.phase==='overtime';const weaved=state.phase==='weaved';$('#singleSubmit').disabled=locked|| (weaved&&state.weavedPlayed);$('#weaveSubmit').disabled=locked||weaved||weaveUsed;$('#weaveSubmit').textContent=weaveUsed?'Weave used this Moment':'Submit Weave';$$('[data-single-suit], [data-weave-card]').forEach(sel=>{sel.disabled=locked||(sel.hasAttribute('data-weave-card')&&(weaved||weaveUsed));});}
function selected(sel){return $$(sel).map(x=>x.value).filter(Boolean)}
function submitSingle(){if(state.phase==='overtime')return toast('Choose more time or end the Moment.');if(state.phase==='weaved'&&state.weavedPlayed)return toast('The Weaved Moment allows one intervention only.');const ids=selected('[data-single-suit]');if(ids.length!==1)return toast('Choose exactly one Single card.');const card=cardById(ids[0]);if(!canSingleCard(card))return toast('This card cannot be played as a Single intervention.');resolveIntervention({kind:state.phase==='weaved'?'Weaved Moment':'Single',cards:[card],value:Number(card.value),leadSuit:getSuitKey(card),supportSuit:null,weavedMoment:state.phase==='weaved'});}
function submitWeave(){if(state.phase==='weaved')return toast('The Weaved Moment allows one Single intervention only.');if(state.phase==='overtime')return toast('Choose more time or end the Moment.');if(momentWeaveUsed()||state.plays.some(p=>p.moment===state.moment&&p.kind==='Weave'))return toast('Only one Weave can be played per Moment.');const ids=selected('[data-weave-card]');if(ids.length!==2)return toast('Choose exactly two numbered cards for Weave.');if(new Set(ids).size!==2)return toast('Choose two different physical cards for Weave.');const cs=ids.map(cardById);if(cs.some(c=>c.type!=='number'))return toast('Weave uses numbered cards only.');if(cs.some(isWeaveOnly)&&!compatibleWeave(cs[0],cs[1]))return toast('WEAVE ONLY needs a compatible care-chain pair.');const [a,b]=cs;let lead=a,support=b;if(Number(b.value)>Number(a.value)){lead=b;support=a;}else if(Number(a.value)===Number(b.value)){const ra=relevanceRank(getSuitKey(a),!!state.crisis), rb=relevanceRank(getSuitKey(b),!!state.crisis); if(rb<ra){lead=b;support=a;}}
 const leadSuit=getSuitKey(lead), supportSuit=getSuitKey(support); const base=targetForSuit(leadSuit,!!state.crisis); const mod=supportModifier(supportSuit,!!state.crisis); const target=Math.max(6,base+mod); resolveIntervention({kind:'Weave',cards:cs,value:Number(a.value)+Number(b.value),leadSuit,supportSuit,targetOverride:target,leadId:lead.id,supportId:support.id,weaveOnly:cs.some(isWeaveOnly)});}
function resolveIntervention(data){const c=currentCase(), m=currentMoment(); if(!c||!m)return; const target=data.targetOverride ?? targetForSuit(data.leadSuit,!!state.crisis); const band=resultBand(data.value,target); let note=''; if(data.kind==='Weave') note=`Lead ${suits[data.leadSuit].name}, supported by ${suits[data.supportSuit].name}. ${data.value} vs target ${target}.`; else note=`${suits[data.leadSuit].name} intervention. ${data.value} vs target ${target}.`;
 const story=buildInterventionStory(data,band,target);
 const sequenceNote=applySequenceShift(data,band);if(sequenceNote)story.body+=` ${sequenceNote}`;if(data.kind==='Weave'){setMomentWeaveUsed();if(data.weaveOnly&&!state.crisis){state.weavedPending=true;revealMomentMeters('breakthrough');story.body+=` The help did not just land. It connected, opening a Weaved Moment at the end of this Moment.`;}}
 let play={id:'play_'+Date.now(),moment:state.moment,kind:data.kind,cards:data.cards.map(c=>c.id),value:data.value,target,result:band.label,note,label:data.cards.map(c=>c.title).join(' + '),leadSuit:data.leadSuit,supportSuit:data.supportSuit||null,time:Date.now(),crisis:!!state.crisis,story,sequenceNote};
 state.lastPlay=play;state.plays.push(play);state.impact={emoji:story.emoji,impact:story.impact,label:band.label,time:Date.now()};
 if(state.crisis){state.crisis.score=(state.crisis.score||0)+Math.max(0,data.value);addLog(`${data.kind}: ${play.label}`,`${story.body} Crisis total ${state.crisis.score}/${state.crisis.target}.`,'crisis');if(state.crisis.score>=state.crisis.target){addLog('Crisis resolved','The Crisis Window closes. The table reached the target and the case breathes again.','crisis');state.progress+=2;state.pressure=Math.max(0,state.pressure-1);state.crisis=null;}}
 else{state.progress+=band.progress;state.pressure=Math.max(0,state.pressure+band.pressure); const fx=data.cards.length===1&&data.cards[0].effect?` ${data.cards[0].effect}`:'';addLog(`${data.kind}: ${play.label}`,`${story.body} ${story.lesson}${fx?' '+fx:''}`,band.key); if(state.pressure>=c.pressureLimit){openCrisis();}}
 if(data.weavedMoment){state.weavedPlayed=true;state.weavedActive=false;state.weavedPending=false;clearTimer();addLog('Weaved Moment spent','The bonus opening closes after one intervention.','moment');renderPlay();flashImpact(play);save();setTimeout(showMoreTimePrompt,80);return;}
 renderPlay();flashImpact(play);save();}
function openCrisis(){if(state.crisis)return;const m=currentMoment();const ct=m.crisisSuitTargets||m.suitTargets||targets();const target=Math.min(...Object.values(ct));state.crisis={title:'The room has changed',suitTargets:ct,target,score:0,openedAt:Date.now()};clearTimer();addLog('Crisis Window',`Target ${target}. Case pressure has tipped.`,'crisis');}
function failCrisis(){if(!state.crisis)return;const ratio=(state.crisis.score||0)/state.crisis.target;let penalty=3,label='failed to resolve';if(ratio>=.85){penalty=1;label='contained at cost'}else if(ratio>=.5){penalty=2;label='partially held'}if(!confirm(`Fail this Crisis Window? Current crisis total ${state.crisis.score||0}/${state.crisis.target}. Penalty: +${penalty} pressure.`))return;state.pressure+=penalty;addLog('Crisis failed',`Crisis ${label}. Pressure increased by ${penalty}.`,'crisis');state.crisis=null;if(state.pressure>=currentCase().pressureLimit+2) finishCase('lost');renderPlay();save();}
function renderEvidence(){const here=state.plays.filter(p=>p.moment===state.moment);$('#momentPlayRow').innerHTML=here.length?here.map(p=>`<div class="evidence-play ${p.crisis?'crisis':''}"><div class="evidence-cards">${p.cards.map(id=>{const c=cardById(id);return c?.front?`<img src="${c.front}" alt="${esc(c.title)}">`:''}).join('')}</div><b>${esc(p.label)}</b><span>${esc(p.result)} · VALUE ${p.value}</span></div>`).join(''):'<span class="empty-evidence">No cards committed in this Moment yet.</span>';}
function renderTools(){const lp=state.lastPlay;$('#lastPlay').innerHTML=lp?`<div class="last-card multi"><div class="last-card-images">${lp.cards.map(id=>{const c=cardById(id);return c?.front?`<img src="${c.front}" alt="${esc(c.title)}" data-img="${c.front}">`:''}).join('')}</div><div><b>${esc(lp.label)}</b><span>${esc(lp.kind)} · ${esc(lp.result)} · VALUE ${lp.value} vs target ${lp.target}</span><p>${esc(lp.note||'')}</p></div></div>`:'No intervention yet.';$$('[data-img]').forEach(i=>i.onclick=()=>openCardImage(i.dataset.img));renderDigTools();$('#logList').innerHTML=[...state.log].reverse().map(l=>`<div class="log-entry ${esc(l.type||'')}"><b>${esc(l.title)}</b><p>${esc(l.text)}</p><small>${new Date(l.time).toLocaleTimeString()}</small></div>`).join('')||'<p>No log yet.</p>';}
function renderDigTools(){
  const el=$('#digTools'); if(!el)return;
  el.innerHTML=`<div class="dig-note"><b>DIG is physical, not app-logged.</b><p>Single DIG: discard 1 card, reveal its printed DIG, keep 1 from those revealed. Weave DIG: discard 2 numbered cards, reveal combined DIG, keep 2. The app does not track this because DIG thins the physical deck and should stay as table memory.</p><p>Range reminder: VALUE 2-3 = DIG 1, VALUE 4-6 = DIG 2, VALUE 7-9 = DIG 3, VALUE 10 = DIG 5.</p></div>`;
}
function openWeavedOffer(){if(!state.weavedPending||state.weavedActive)return false;startWeavedMoment();toast('Weaved Moment opened: one last Single intervention.');return true;}
function startWeavedMoment(){state.phase='weaved';state.timerLeft=playerCfg().timer;state.weavedActive=true;state.weavedPlayed=false;state.timeStopped=false;clearTimer();state.timerId=setInterval(tick,1000);addLog('Weaved Moment','One last intervention is available because a care-chain connected.','moment');renderPlay();save();}
function endWeavedWindow(){state.weavedPending=false;state.weavedActive=false;clearTimer();addLog('Weaved Moment closed','The bonus opening passed without another intervention.','moment');showMoreTimePrompt();}
function showMoreTimePrompt(){state.phase='overtime';state.overtimeLeft=10;state.timeStopped=false;clearTimer();modal(`<h2>Need more time?</h2><p>The Moment is ready to close. Take more time only if you mean to keep acting.</p><p><b id="overtimeText">10</b> seconds. If no button is pressed, the Moment ends with no pressure gain.</p><div class="modal-actions"><button id="takeMoreTime" class="primary">Take more time, +1 Pressure</button><button id="endMomentClean" class="secondary">End Moment</button></div>`);$('#takeMoreTime').onclick=()=>{state.pressure++;state.phase='intervention';state.timerLeft=playerCfg().timer;state.timeStopped=false;clearTimer();closeModal();if(state.pressure>=currentCase().pressureLimit)openCrisis();addLog('More time taken','The table chose more time. Pressure increased by 1.','timer');state.timerId=setInterval(tick,1000);renderPlay();save();};$('#endMomentClean').onclick=()=>{clearTimer();clearOpenModal();nextMomentDirect();};state.timerId=setInterval(tick,1000);renderPlay();save();}
function showMomentCheckin(after){
  const c=currentCase(), m=currentMoment(); if(!c||!m){after&&after();return;}
  revealMomentMeters('check-in');
  const plays=state.plays.filter(p=>p.moment===state.moment);
  const last=plays[plays.length-1];
  const ranges=suitTargetBar(!!state.crisis);
  const text=last?.story?.body||'The Moment closes with no committed intervention. That absence becomes part of the case.';
  modal(`<h2>Moment check-in</h2><p><b>${esc(m.title)}</b></p><p>${esc(text)}</p><p><b>Progress:</b> ${esc(progressDisplay(c))}. <b>Pressure:</b> ${esc(pressureDisplay(c))}. <b>Presence:</b> ${esc(presenceDisplay())}. <b>Receptivity:</b> ${esc(receptivityDisplay())}.</p>${ranges}<div class="modal-actions"><button id="continueAfterCheckin" class="primary">Continue</button></div>`);
  $('#continueAfterCheckin').onclick=()=>{closeModal();after&&after();};
  renderPlay();save();
}
function advanceMomentDirect(){const c=currentCase();if(!c)return;if(state.moment>=c.beats.length-1){finishCase();return;}state.moment++;resetMomentFlow(false);addLog('End Moment',`Draw back up to ${playerCfg().hand}. Discard down to 7 if above it.`,'moment');addLog('Next Moment',currentMoment()?.title||'','moment');renderPlay();save();}
function nextMomentDirect(){showMomentCheckin(advanceMomentDirect);}
function nextMoment(){if(state.crisis)return toast('Resolve or fail the Crisis Window first.');if(state.phase==='weaved')return endWeavedWindow();if(state.weavedPending)return openWeavedOffer();if(state.phase==='overtime'){clearTimer();clearOpenModal();return nextMomentDirect();}return showMoreTimePrompt();}
function prevMoment(){toast('Moments only move forward. Use the archive after the case ends.');}
function finishCase(force=null){clearTimer();const c=currentCase();let outcome=force;if(!outcome){if(state.pressure>=c.pressureLimit+1)outcome='lost';else if(state.progress>=c.progressTarget)outcome='saved';else if(state.progress>=Math.ceil(c.progressTarget*.6))outcome='partial';else outcome='lost';}
 const t=tracker();t.totalCases=(t.totalCases||0)+1;t[outcome]=(t[outcome]||0)+1;t.totalPlays=(t.totalPlays||0)+state.plays.length;saveTracker(t);addLog(outcome==='saved'?'Case saved':outcome==='partial'?'Case held':'Case lost',c['closing'+(outcome==='saved'?'Win':outcome==='partial'?'Partial':'Loss')]||'', 'outcome');state.lastOutcome=makeOutcomeSnapshot(outcome);state.caseClosed=true;reportCommunity(outcome);outcomeModal(outcome);save();}
function outcomeLabel(outcome){return outcome==='saved'?'Case Saved':outcome==='partial'?'Case Held':'Case Lost';}
function closingTextFor(outcome,c=currentCase()){return c?.['closing'+(outcome==='saved'?'Win':outcome==='partial'?'Partial':'Loss')]||'';}
function playSnapshot(p){return{moment:p.moment,kind:p.kind,label:p.label,value:p.value,target:p.target,result:p.result,note:p.note,leadSuit:p.leadSuit,supportSuit:p.supportSuit,crisis:!!p.crisis,time:p.time,story:p.story||null,cards:p.cards.map(id=>{const c=cardById(id)||{};return{id:c.id||id,title:c.title||id,suit:c.suit||'',rank:c.rank||'',value:c.value||0,front:c.front||'',line:c.line||''};})};}
function makeOutcomeSnapshot(outcome){const c=currentCase()||{};const camp=campaign(c.campaign)||{};const moments=(c.beats||[]).map((m,i)=>({index:i+1,title:m.title,text:m.text,watch:m.watch,aim:m.aim,suitTargets:m.suitTargets||{},narrativeIntro:momentIntro(m,i),plays:state.plays.filter(p=>p.moment===i).map(playSnapshot)}));const p=profile(); const snap={schema:'stepaway-outcome-v3-narrative',appVersion:settings.version||'2.0 narrative',createdAt:new Date().toISOString(),profileId:p.profileId,profileName:p.displayName||'Step Away Table',caseId:c.id||state.activeCase,title:c.title||'Step Away Case',subtitle:c.subtitle||'',category:c.campaign||'',categoryTitle:camp.title||categoryVoice(c.campaign).name,caseVoice:c.shareLead||categoryVoice(c.campaign).edge,teachingPoints:c.teachingPoints||categoryVoice(c.campaign).teaches||[],mode:state.mode,deckMode:state.deckMode,players:state.players,outcome,outcomeLabel:outcomeLabel(outcome),opening:c.opening||'',closing:closingTextFor(outcome,c),progress:{value:state.progress,target:c.progressTarget||0},pressure:{value:state.pressure,limit:c.pressureLimit||0},presence:{value:state.presence,label:presenceLabel()},receptivity:{value:state.receptivity,label:receptivityLabel()},jokers:state.jokers,moments,plays:state.plays.map(playSnapshot),log:state.log.slice(-60),product:{name:'Step Away',url:settings.officialDeckUrl||'official-deck.html'}}; snap.story=buildNovella(snap); snap.resultHash=[snap.profileId,snap.caseId,snap.outcome,snap.progress.value,snap.pressure.value,snap.plays.map(p=>p.cards.map(c=>c.id).join('+')+':'+p.value+':'+p.result+':'+(p.story?.resultKey||'')).join('|')].join('::'); return snap;}
function storyTextFromSnapshot(snap){if(snap.story?.sections){const lines=[];lines.push(`${snap.outcomeLabel}: ${snap.title}`);if(snap.subtitle)lines.push(snap.subtitle);lines.push('');snap.story.sections.forEach(sec=>{lines.push(sec.kicker?sec.kicker.toUpperCase():'STORY');lines.push(sec.title||'');(sec.paragraphs||[]).forEach(p=>{lines.push('');lines.push(p);});lines.push('');});if(snap.story.teaching?.length){lines.push('WHAT THIS CASE TEACHES');snap.story.teaching.forEach(p=>lines.push(`- ${p}`));}lines.push('');lines.push('Played with Step Away.');return lines.join('\n');}
const lines=[];lines.push(`${snap.outcomeLabel}: ${snap.title}`);if(snap.subtitle)lines.push(snap.subtitle);lines.push('');if(snap.opening)lines.push(snap.opening);snap.moments.forEach(m=>{lines.push('');lines.push(`Moment ${m.index}: ${m.title}`);lines.push(m.text||'');if(m.plays.length){m.plays.forEach(p=>{const cards=p.cards.map(c=>c.title).join(' + ');lines.push(`${cards}: ${p.story?.body||p.note||p.result}`.trim());});}else{lines.push('No intervention was committed in this Moment.');}});lines.push('');if(snap.closing)lines.push(snap.closing);lines.push(`Final archive: progress ${snap.progress.value}/${snap.progress.target}, pressure ${snap.pressure.value}/${snap.pressure.limit}, presence ${snap.presence?.label||'Flickering'}, receptivity ${snap.receptivity?.label||'Receptive'}, interventions ${snap.plays.length}.`);return lines.join('\n');}
function outcomeStoryHtml(snap){return `<section class="share-story-preview"><p class="eyebrow">Shareable case novella</p><h3>${esc(snap.title)}</h3>${novellaHtml(snap.story)||`<p>${esc(snap.opening||'')}</p>`}</section>`;}
function encodePayload(obj){const txt=JSON.stringify(obj);let b64=btoa(unescape(encodeURIComponent(txt)));return b64.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');}
function fallbackShareUrl(snap){return new URL(`share.html#story=${encodePayload(snap)}`,location.href).href;}
function serverShareUrl(id){return new URL(`${settings.sharePage||'share.php'}?id=${encodeURIComponent(id)}`,location.href).href;}
async function publishShareLink(){const snap=state.lastOutcome||makeOutcomeSnapshot('partial');if(!snap.resultHash)snap.resultHash=await sha256(resultHashBasis(snap.outcome||'partial'));snap.profileId=profile().profileId;snap.profileName=profile().displayName||'Step Away Table';let url='';let server=false;try{const r=await fetch(settings.shareEndpoint||'api.php?endpoint=share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(snap)});if(!r.ok)throw new Error('share failed');const j=await r.json();if(!j.ok)throw new Error(j.error||'share failed');url=new URL(j.shareUrl||serverShareUrl(j.id),location.href).href;server=true;state.lastShare={id:j.id,url};}
catch(e){url=fallbackShareUrl(snap);state.lastShare={url,fallback:true};}
const note=server?'Public case page created. Anyone with the link can read the outcome archive.':'Static fallback link created. For clean short links, host this pack with PHP enabled.';modal(`<h2>Share case outcome</h2><p>${esc(note)}</p><input id="shareUrlBox" class="share-url" value="${esc(url)}" readonly><div class="modal-actions"><button id="copyShareUrl" class="primary">Copy link</button><button id="nativeShare" class="secondary">Share</button><a class="secondary" href="${esc(url)}" target="_blank" rel="noopener">Open</a><button id="closeModal" class="secondary">Close</button></div>`);$('#closeModal').onclick=()=>{closeModal();clearActiveCaseState();show('tracker');renderAll();};$('#copyShareUrl').onclick=async()=>{try{await navigator.clipboard.writeText(url);toast('Share link copied')}catch(e){$('#shareUrlBox').select();document.execCommand('copy');toast('Share link copied')}};$('#nativeShare').onclick=async()=>{try{if(navigator.share)await navigator.share({title:snap.title,text:`${snap.outcomeLabel}: ${snap.title}`,url});else throw new Error('no share');}catch(e){toast('Use Copy link')}};save();}
async function copyStoryText(){const snap=state.lastOutcome||makeOutcomeSnapshot('partial');const text=storyTextFromSnapshot(snap);try{await navigator.clipboard.writeText(text);toast('Story text copied')}catch(e){modal(`<h2>Case story text</h2><textarea class="story-textarea" readonly>${esc(text)}</textarea><div class="modal-actions"><button id="closeModal" class="secondary">Close</button></div>`);$('#closeModal').onclick=closeModal;}}
function outcomeModal(outcome){const c=currentCase();const snap=state.lastOutcome||makeOutcomeSnapshot(outcome);const title=outcomeLabel(outcome);const body=snap.closing||'';modal(`<h2>${title}</h2><p>${esc(body)}</p><p><b>Progress:</b> ${progressDisplay(c)}. <b>Pressure:</b> ${pressureDisplay(c)}. <b>Interventions:</b> ${state.plays.length}.</p><div class="outcome-strip">${state.plays.map(p=>p.cards.map(id=>{const card=cardById(id);return card?.front?`<img src="${card.front}" alt="${esc(card.title)}">`:''}).join('')).join('')}</div>${outcomeStoryHtml(snap)}<div class="modal-actions"><button id="publishStory" class="primary">Create share link</button><button id="saveStory" class="secondary">Save story PNG</button><button id="copyStory" class="secondary">Copy story text</button><button id="closeModal" class="secondary">Close</button></div>`);$('#closeModal').onclick=()=>{closeModal();clearActiveCaseState();show('tracker');renderAll();};$('#saveStory').onclick=saveStoryPng;$('#publishStory').onclick=publishShareLink;$('#copyStory').onclick=copyStoryText;}
async function saveStoryPng(){const snap=state.lastOutcome||makeOutcomeSnapshot('partial');const canvas=document.createElement('canvas');canvas.width=1400;canvas.height=1000;const ctx=canvas.getContext('2d');ctx.fillStyle='#11171d';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#f7efe4';ctx.font='bold 52px sans-serif';ctx.fillText(snap.title,46,76);ctx.font='26px sans-serif';ctx.fillText(`${snap.outcomeLabel} · Progress ${snap.progress.value}/${snap.progress.target} · Pressure ${snap.pressure.value}/${snap.pressure.limit} · Presence ${snap.presence?.label||'Flickering'}`,46,120);ctx.fillStyle='#d8c9b5';ctx.font='22px sans-serif';wrapCanvasText(ctx,snap.opening||'',46,160,1300,28,3);let x=46,y=260;for(const p of snap.plays){for(const card of p.cards){if(!card.front)continue;try{const img=await loadImg(card.front);ctx.drawImage(img,x,y,116,162);x+=126;if(x>1240){x=46;y+=188;}}catch(e){}}}ctx.fillStyle='#f7efe4';ctx.font='bold 24px sans-serif';wrapCanvasText(ctx,snap.closing||'',46,920,1300,30,2);const a=document.createElement('a');a.download='step-away-case-story.png';a.href=canvas.toDataURL('image/png');a.click();}
function wrapCanvasText(ctx,text,x,y,maxWidth,lineHeight,maxLines=99){const words=String(text).split(/\s+/);let line='',lines=0;for(const w of words){const test=line?line+' '+w:w;if(ctx.measureText(test).width>maxWidth&&line){ctx.fillText(line,x,y);y+=lineHeight;lines++;line=w;if(lines>=maxLines)return;}else line=test;}if(line&&lines<maxLines)ctx.fillText(line,x,y);} 
function loadImg(src){return new Promise((res,rej)=>{const im=new Image();im.onload=()=>res(im);im.onerror=rej;im.src=src;});}
function addLog(title,text,type=''){state.log.push({title,text,time:Date.now(),type});}
function resetTimer(render=true){clearTimer();state.timerLeft=playerCfg().timer;state.overtimeLeft=10;state.timeStopped=false;if(render)renderPlay();}
function tick(){if(state.timeStopped)return;if(state.phase==='overtime'){state.overtimeLeft=Math.max(0,state.overtimeLeft-1);const tt=$('#timerText');if(tt)tt.textContent=state.overtimeLeft;const ot=$('#overtimeText');if(ot)ot.textContent=state.overtimeLeft;if(state.overtimeLeft<=0){clearTimer();clearOpenModal();nextMomentDirect();}return;}state.timerLeft=Math.max(0,state.timerLeft-1);const tt=$('#timerText');if(tt)tt.textContent=state.timerLeft;if(state.timerLeft<=0){clearTimer();state.timeStopped=true;timeExpiredModal();}}
function searchExpiredModal(){modal(`<h2>DIG/Search time has gone</h2><p>You can take more time, but the case pressure rises because the table is still searching instead of acting.</p><div class="modal-actions"><button id="moreSearchPressure" class="primary">Need more search time, +1 pressure</button><button id="startNow" class="secondary">Start intervention now</button></div>`);$('#moreSearchPressure').onclick=()=>{state.pressure++;state.searchLeft=20;state.timeStopped=false;addLog('More search time','The table kept searching. Pressure increased by 1.','timer');closeModal();if(state.pressure>=currentCase().pressureLimit)openCrisis();state.timerId=setInterval(tick,1000);renderPlay();save();};$('#startNow').onclick=()=>{closeModal();startIntervention();};}
function timeExpiredModal(){if(state.phase==='weaved')return endWeavedWindow();if(state.crisis){modal(`<h2>Time has gone</h2><p>The Crisis Window needs a decision.</p><div class="modal-actions"><button id="continuePressure" class="primary">Continue, +1 pressure</button><button id="failByTime" class="secondary">Fail crisis</button></div>`);$('#continuePressure').onclick=()=>{state.pressure++;state.timerLeft=playerCfg().timer;state.timeStopped=false;closeModal();state.timerId=setInterval(tick,1000);renderPlay();save();};$('#failByTime')?.addEventListener('click',()=>{closeModal();failCrisis();});return;}if(state.weavedPending)return openWeavedOffer();showMoreTimePrompt();}
function toggleTimer(){if(!currentCase())return toast('No case loaded.');if(state.phase==='overtime')return nextMoment();if(state.timerId){clearTimer();state.timeStopped=true;}else{state.timeStopped=false;state.timerId=setInterval(tick,1000);}renderPlay();save();}
function handleTimerButton(){if(state.phase==='overtime')nextMoment();else toggleTimer();}
function renderDeck(){const q=state.cardSearch.toLowerCase();let list=cards.filter(c=>state.cardFilter==='all'||c.type===state.cardFilter||state.cardFilter==='face'&&(c.type==='face'||c.type==='ace')||state.cardFilter==='helper'&&c.deck==='helper');if(q)list=list.filter(c=>[c.id,c.title,c.subtitle,c.line,c.effect,c.suit,c.rank,c.play_mode_label].join(' ').toLowerCase().includes(q));$('#cardGrid').innerHTML=list.map(c=>{const showImg=c.front;return `<article class="card-tile ${c.deck==='helper'?'helper-tile':''}" data-card="${c.id}">${showImg?`<img src="${c.front}" alt="${esc(c.title)}">`:`<div class="helper-card-thumb"><b>${esc(c.id)}</b><span>${esc(c.subtitle||'Reference')}</span></div>`}<b>${esc(c.title)}</b><span>${esc(c.id)} · ${esc(c.suit)} ${c.value?`· VALUE ${c.value}`:''}${c.play_mode_label?` · ${esc(c.play_mode_label)}`:''}</span></article>`}).join('');$$('[data-card]').forEach(el=>el.onclick=()=>openCard(el.dataset.card));}
function renderRules(){const rules=[['Live DIG','DIG is physical. The app does not log it because deck burn and hand memory are table skill.'],['Ideal range','Each suit now has an ideal VALUE range. Too low may not reach. Too high may overwhelm. Fit matters more than force.'],['Hidden meters','Progress, Pressure, Presence and Receptivity are mainly revealed at the end of each Moment. During play, read the reaction.'],['Impact flash','After each intervention, the app shows a quick reaction cue. It tells you how it felt before the numbers explain why.'],['Presence','Presence tracks how reachable the person feels. Higher Presence hints that deeper help may land, but it still needs the right suit and intensity.'],['Receptivity','Receptivity sits between closed, guarded, receptive, warmed and overwhelmed. Try to keep the person open, not flooded.'],['Weaved breakthrough','A successful WEAVE ONLY care-chain can reveal the meters for the rest of the Moment because something genuinely connects.'],['Story archive','The final story is not just a result. It explains how the person changed in response to what the table offered.']];$('#rulesGrid').innerHTML=rules.map(r=>`<article class="rule-card"><h2>${esc(r[0])}</h2><p>${esc(r[1])}</p></article>`).join('');}
function renderInstructions(){/* static HTML screen */}
function renderAbout(){const a=aboutData||{};$('#aboutBox').innerHTML=`<article class="panel"><p class="eyebrow">Why this exists</p><h1>${esc(a.title||'About Step Away')}</h1><p><b>${esc(a.lead||'A social-awareness card game about noticing before silence becomes an exit.')}</b></p><p>${esc(a.body||'')}</p><p class="muted">${esc(a.footer||'')}</p></article>`;}
function renderTracker(){
  const local=tracker();const total=local.totalCases||0;const p=profile();
  $('#trackerGrid').innerHTML=`<article class="stat-card profile-card"><h2>${esc(p.displayName||'Step Away Table')}</h2><p>This browser table profile: <code>${esc(p.profileId)}</code></p><button id="editProfileBtn" class="secondary tiny">Edit table name</button><button id="syncProfileBtn" class="primary tiny">Sync now</button></article><article class="stat-card"><h2>${total}</h2><p>This device cases archived locally.</p></article><article class="stat-card"><h2>${local.saved||0}</h2><p>Saved outcomes on this device.</p></article><article class="stat-card"><h2>${local.partial||0}</h2><p>Held / partial outcomes on this device.</p></article><article class="stat-card"><h2>${local.lost||0}</h2><p>Lost outcomes on this device.</p></article><article class="stat-card"><h2>${local.totalPlays||0}</h2><p>Cards committed locally.</p></article><article class="stat-card" id="communityPanel"><h2>Checking…</h2><p>Hosted community tracker.</p></article>`;
  const ms=settings.communityMilestones||[];
  $('#milestoneList').innerHTML=`<article class="milestone"><h3>This device</h3><p>These unlock from cases completed on this browser. They are yours and do not need an account.</p></article>`+ms.map(m=>`<article class="milestone"><h3>${total>=m.at?'This device unlocked':'This device locked'} · ${esc(m.title)}</h3><p>${total}/${m.at} local cases. ${esc(m.reward)}</p></article>`).join('')+`<article class="milestone"><h3>Community</h3><p id="communityMilestoneIntro">Checking hosted community progress…</p></article><div id="communityMilestones"></div>`;
  $('#editProfileBtn').onclick=editProfile;
  $('#syncProfileBtn').onclick=async()=>{await syncProfile();toast('Profile sync checked');renderTracker();};
  loadCommunity(ms);
}
function editProfile(){
  const p=profile();
  modal(`<h2>Table profile</h2><p>This is an anonymous table identity for local archive and hosted community totals. No login needed.</p><input id="profileNameInput" class="search" value="${esc(p.displayName||'Step Away Table')}" maxlength="40"><p class="muted">Profile code: <code>${esc(p.profileId)}</code></p><div class="modal-actions"><button id="saveProfileName" class="primary">Save</button><button id="closeModal" class="secondary">Close</button></div>`);
  $('#closeModal').onclick=closeModal;
  $('#saveProfileName').onclick=async()=>{p.displayName=$('#profileNameInput').value.trim()||'Step Away Table';saveProfile(p);await syncProfile();closeModal();renderTracker();};
}
async function loadCommunity(ms){
  try{
    const r=await fetch(settings.communityEndpoint||'api.php?endpoint=community',{cache:'no-store'});
    if(!r.ok)throw new Error('offline');
    const g=await r.json();const total=g.totalCases||0;const shared=g.publishedStories||0;const profiles=g.profileCount||0;
    const p=$('#communityPanel');if(p)p.innerHTML=`<h2>${total}</h2><p>Community cases reported by hosted tables. ${shared} public story links. ${profiles} table profiles.</p>`;
    const intro=$('#communityMilestoneIntro'); if(intro) intro.textContent=`${total} reported cases across all hosted tables, ${shared} public case stories shared.`;
    const cm=$('#communityMilestones'); if(cm) cm.innerHTML=ms.map(m=>`<article class="milestone"><h3>${total>=m.at?'Community unlocked':'Community locked'} · ${esc(m.title)}</h3><p>${total}/${m.at} community cases. ${esc(m.reward)}</p></article>`).join('');
  }catch(e){
    const p=$('#communityPanel');if(p)p.innerHTML='<h2>This device only</h2><p>Upload to PHP hosting with writable /data to enable community totals.</p>';
    const intro=$('#communityMilestoneIntro'); if(intro) intro.textContent='Community sync is not currently reachable. Local archive still works.';
    const cm=$('#communityMilestones'); if(cm) cm.innerHTML='';
  }
}
function renderPacks(){const list=packs.packs||[];$('#packList').innerHTML=list.map(p=>`<article class="pack-card"><h3>${esc(p)}</h3><p>Listed in content/packs/index.json. Add monthly packs here without changing the app code.</p></article>`).join('')||'<p>No packs listed.</p>';}
function openCard(id){const c=cardById(id);if(!c)return;modal(`${c.front?`<img class="full-card" src="${c.front}" alt="${esc(c.title)}">`:''}<h2>${esc(c.title)}</h2><p><b>${esc(c.suit)} ${c.rank||''}</b> ${c.value?`· VALUE ${c.value}`:''}${c.play_mode_label?` · ${esc(c.play_mode_label)}`:''}</p><p>${esc(c.line||'')}</p>${c.field_note?`<p><b>Reality note:</b> ${esc(c.field_note)}</p>`:''}${c.tone_note?`<p><b>Intervention tone:</b> ${esc(c.tone_note)}</p>`:''}${c.effect?`<p><b>${c.deck==='helper'?'Rule':(c.type==='joker'?'When revealed':'When played')}:</b> ${esc(c.effect)}</p>`:''}<div class="modal-actions"><button id="closeModal" class="secondary">Close</button></div>`);$('#closeModal').onclick=closeModal;}
function openCardImage(src){modal(`<img class="full-card" src="${src}" alt="Card"><div class="modal-actions"><button id="closeModal" class="secondary">Close</button></div>`);$('#closeModal').onclick=closeModal;}
function caseNotes(){const c=currentCase(); if(!c)return;const pulse=casePulseText();modal(`<h2>${esc(c.title)}</h2><p>${esc(c.opening||'')}</p><p><b>Progress target:</b> ${c.progressTarget}. <b>Pressure limit:</b> ${c.pressureLimit}.</p><p><b>Current read:</b> ${esc(pulse.title)} - ${esc(pulse.body)}</p>${suitTargetBar(!!state.crisis)}<p>${currentWrinkle()?`<b>Active wrinkle:</b> ${esc(currentWrinkle().title)} - ${esc(currentWrinkle().text)}`:'No active wrinkle.'}</p><div class="modal-actions"><button id="closeModal" class="secondary">Close</button></div>`);$('#closeModal').onclick=closeModal;}
function modal(html){const m=$('#modal'); if(state.timerId){clearTimer();state.pausedByModal=true;}m.innerHTML=`<div class="modal-inner">${html}</div>`;m.showModal();}
function closeModal(){$('#modal').close();state.pausedByModal=false;renderPlay();save();}

function confirmResetTimer(){
  modal(`<h2>Reset timer?</h2><p>This should only be used for a mis-tap, interruption or setup mistake. It does not change Progress, Pressure or Presence.</p><div class="modal-actions"><button id="confirmTimerReset" class="primary">Reset timer</button><button id="cancelTimerReset" class="secondary">Cancel</button></div>`);
  $('#cancelTimerReset').onclick=closeModal;
  $('#confirmTimerReset').onclick=()=>{closeModal();resetTimer(true);save();toast('Timer reset');};
}
function pauseCaseForExit(){
  if(activeCaseOpen()&&state.timerId){
    clearTimer();
    state.timeStopped=true;
    save();
  }
}
function openSettings(){const install=state.installEvent?'<button id="installBtn" class="primary">Install app</button>':'<p class="muted">Install appears here when your browser allows it.</p>';modal(`<h2>Settings</h2><p>Deck mode, player count and purchase access stay saved on this device.</p><p><b>Access:</b> ${hasFullAccess()?'Official full app':'Poker-deck demo'}</p><p><b>Table profile:</b> ${esc(profile().displayName||'Step Away Table')} <code>${esc(profile().profileId)}</code></p><div class="modal-actions">${install}<button id="resetSave" class="secondary">Clear saved case</button><a class="secondary" href="admin.php">Admin</a><button id="closeModal" class="secondary">Close</button></div>`);$('#closeModal').onclick=closeModal;$('#resetSave').onclick=()=>{localStorage.removeItem(storeKey);location.reload()};$('#installBtn')?.addEventListener('click',async()=>{const e=state.installEvent; if(!e)return; e.prompt(); await e.userChoice; state.installEvent=null; closeModal();});}
function showJoker(which){const used=which==='fracture'?state.jokers.fracture:state.jokers.resilience;if(used)return toast(`${which==='fracture'?'Fracture':'Resilience'} already used`);const card=cardById(which==='fracture'?'JF':'JR');modal(`${card?.front?`<img class="full-card" src="${card.front}" alt="${esc(card.title)}">`:''}<h2>${esc(card?.title||which)}</h2><p>${esc(card?.line||'')}</p><p><b>When revealed:</b> ${esc(card?.effect||'')}</p><div class="modal-actions"><button id="cancelJoker" class="secondary">Cancel</button><button id="confirmJoker" class="primary">Resolve and remove</button></div>`);$('#cancelJoker').onclick=closeModal;$('#confirmJoker').onclick=()=>{closeModal();resolveJoker(which,card);};}
function resolveJoker(which,card){if(which==='fracture')state.jokers.fracture=true;else state.jokers.resilience=true;const story=jokerStory(which,card);const play={id:'joker_'+Date.now(),moment:state.moment,kind:'Joker',cards:[card?.id].filter(Boolean),value:0,target:0,result:'Event',note:card?.effect||'',label:card?.title||which,time:Date.now(),crisis:!!state.crisis,story,sequenceNote:null};state.lastPlay=play;state.plays.push(play);state.impact={emoji:story.emoji,impact:story.impact,label:band.label,time:Date.now()};addLog(card?.title||'Joker',card?.effect||'Joker resolved.','joker');renderPlay();save();}
async function reportCommunity(outcome){
  const c=currentCase();const p=profile();const hash=await sha256(resultHashBasis(outcome));
  const payload={profileId:p.profileId,profileName:p.displayName||'Step Away Table',resultHash:hash,caseId:state.activeCase,title:c?.title||'',category:c?.campaign||'unknown',outcome,plays:state.plays.length,moments:c?.beats?.length||0,pressure:state.pressure,progress:state.progress,createdAt:new Date().toISOString(),ts:Date.now()};
  try{const r=await fetch(settings.reportEndpoint||'api.php?endpoint=report',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); if(r.ok){const j=await r.json(); if(j.counted===false) console.info('Step Away community duplicate ignored');}}catch(e){}
}
function bind(){window.addEventListener('beforeinstallprompt',e=>{e.preventDefault();state.installEvent=e;});$('#homeBtn')?.addEventListener('click',()=>show('home'));$('#settingsBtn')?.addEventListener('click',openSettings);$('#accessBtn')?.addEventListener('click',openAccess);$$('[data-screen]').forEach(b=>b.onclick=()=>show(b.dataset.screen));$('#singleSubmit').onclick=submitSingle;$('#weaveSubmit').onclick=submitWeave;$('#nextMoment').onclick=nextMoment;$('#prevMoment').onclick=prevMoment;$('#timerToggle').onclick=handleTimerButton;$('#timerReset').onclick=confirmResetTimer;$('#abandonCaseBtn')?.addEventListener('click',abandonCase);window.addEventListener('beforeunload',pauseCaseForExit);document.addEventListener('visibilitychange',()=>{if(document.hidden)pauseCaseForExit();});$('#caseNotesBtn').onclick=caseNotes;$('#fractureBtn').onclick=()=>showJoker('fracture');$('#resilienceBtn').onclick=()=>showJoker('resilience');$('#cardSearch').oninput=e=>{state.cardSearch=e.target.value;renderDeck()};$('#cardFilters').onclick=e=>{const b=e.target.closest('button[data-filter]');if(!b)return;state.cardFilter=b.dataset.filter;$$('#cardFilters button').forEach(x=>x.classList.toggle('selected',x===b));renderDeck()};}

function daysSince(iso){if(!iso)return 9999;return (Date.now()-new Date(iso).getTime())/86400000;}
function dismissOfflineReminder(){const g=accessGrant();g.offlineReminderDismissedAt=new Date().toISOString();saveAccessGrant(g);closeModal();}
async function monthlyAccessCheck(){
  const g=accessGrant(), p=profile();
  if(!hasFullAccess())return;
  const due=daysSince(g.lastServerCheck||p.lastServerCheck)>30;
  try{
    const qs=new URLSearchParams();qs.set('profileId',p.profileId);if(g.accessToken)qs.set('accessToken',g.accessToken);
    const r=await fetch(`api.php?endpoint=transaction-check&${qs.toString()}`,{cache:'no-store'});
    if(!r.ok)throw new Error('server check failed');
    const j=await r.json();
    const now=new Date().toISOString();
    if(j.full||p.fullApp){
      g.lastServerCheck=now;
      if(j.entitlement){g.walletCredits=j.entitlement.walletCredits||0;g.deckOwner=!!j.entitlement.deckOwner;g.appAccessOnly=!!j.entitlement.appAccessOnly;}
      saveAccessGrant(g);
      p.lastServerCheck=now;
      if(j.entitlement){p.fullApp=!!j.entitlement.fullApp;p.walletCredits=j.entitlement.walletCredits||0;p.deckOwner=!!j.entitlement.deckOwner;}
      saveProfile(p);
    }
  }catch(e){
    if(due && daysSince(g.offlineReminderDismissedAt)>7){
      modal(`<h2>Offline check-in due</h2><p>You can keep playing offline, but this table has not checked in for over a month. Go online when you can so Step Away can confirm access and pull the latest cases or fixes.</p><div class="modal-actions"><button id="dismissOffline" class="primary">Keep playing</button></div>`);
      $('#dismissOffline').onclick=dismissOfflineReminder;
    }
  }
}
async function init(){
  try{
    settings=await content('settings','data/settings.json');
    load();
    profile();
    try{await syncProfile();}catch(e){}
    cards=await content('cards','data/cards.json');
    aboutData=await content('about','data/about.json');
    cases=await content('cases','data/cases.json');
    campaigns=await content('campaigns','data/campaigns.json');
    wrinkles=await content('wrinkles','data/wrinkles.json');
    packs=await content('pack-index','content/packs/index.json');
    validateLoadedState();
    monthlyAccessCheck();
    bind();
    renderAll();
    show(state.screen||'home');
    if('serviceWorker'in navigator)navigator.serviceWorker.register('service-worker.js').catch(()=>{});
  }catch(e){
    console.error(e);
    document.body.innerHTML='<pre style="color:white;padding:20px;white-space:pre-wrap">Step Away failed to load launch content.\n\nUpload the app folder to a PHP-enabled host and open index.php, or run it through a local web server. Do not open index.html directly from inside a ZIP.\n\nMissing: '+esc(e.message)+'</pre>';
  }
}
init();
})();
