
let data={}, players=[], scenario=null, momentIndex=0, timer=null, seconds=90, lastPath=null, gameStarted=false, momentResolved=false, pathHistory=[], scenarioTotals={Ideal:0,Close:0,Miss:0}, finalShown=false;
const lanes=['Mind','Body','Soul']; const $=id=>document.getElementById(id);
async function load(){for(const f of ['decks','aspects','scenarios','cards']) data[f]=await fetch(`data/${f}.json`).then(r=>r.json()); buildScenarioSelect(); buildPlayers(); renderHelp();}
function deckIdFromName(d){return d.name.split(' ')[0].toLowerCase()}
function confirmReset(msg){if(!gameStarted)return true; return confirm(msg+' This resets current app state. Continue?')}
function buildScenarioSelect(){const s=$('scenarioSelect'); s.innerHTML=''; data.scenarios.forEach(x=>{let o=document.createElement('option');o.value=x.id;o.textContent=x.title;s.appendChild(o)})}
function buildPlayers(){if(!confirmReset('Rebuild players?'))return; const n=+$('playerCount').value; const wrap=$('players'); wrap.innerHTML=''; for(let i=0;i<n;i++){let div=document.createElement('div');div.className='player';div.innerHTML=`<h3>Player ${i+1}</h3><div class="row"><label>Name<input class="name" value="Player ${i+1}"></label><label>Deck<select class="deck"></select></label></div><div class="aspect"></div>`; wrap.appendChild(div); let ds=div.querySelector('.deck'); data.decks.forEach((d,j)=>{let o=document.createElement('option');o.value=deckIdFromName(d);o.textContent=`${d.name} / ${d.condition}`;ds.appendChild(o)}); ds.selectedIndex=Math.min(i,data.decks.length-1); ds.onchange=()=>{ if(confirmReset('Change deck and randomise Aspect?')) randomiseAspects(); }; } randomiseAspects(false);}
function randomAspect(deck){const arr=data.aspects.filter(a=>a.deckId===deck); return arr[Math.floor(Math.random()*arr.length)]}
function randomiseAspects(check=true){if(check&&!confirmReset('Randomise Aspects?'))return; players=[...document.querySelectorAll('.player')].map((p,i)=>{const deck=p.querySelector('.deck').value; const asp=randomAspect(deck); return {slot:i,name:p.querySelector('.name').value,deck,aspect:asp,pressed:false,pressReason:'',history:[],lastHadMiss:false,returnBlock:false};}); renderPlayers(); buildEntry();}
function modLine(a){return lanes.map(l=>`${l} ${a.mods[l]>0?'+':''}${a.mods[l]}`).join(', ')}
function renderPlayers(){[...document.querySelectorAll('.player')].forEach((p,i)=>{const pl=players[i]; if(!pl)return; p.querySelector('.aspect').className='aspect '+(pl.pressed?'pressed':''); p.querySelector('.aspect').innerHTML=`<b>${pl.aspect.name}</b> (${pl.aspect.category})<br><span>${modLine(pl.aspect)}</span><br>${pl.pressed?pl.aspect.pressedRule:pl.aspect.frontRule}${pl.pressed&&pl.pressReason?`<small>Pressed because: ${pl.pressReason}</small>`:''}<small>Recovery: ${pl.aspect.recovery}</small>`;});}
function loadScenario(){if(!confirmReset('Load scenario?'))return; scenario=data.scenarios.find(s=>s.id===$('scenarioSelect').value); momentIndex=0; lastPath=null; pathHistory=[]; scenarioTotals={Ideal:0,Close:0,Miss:0}; finalShown=false; gameStarted=true; momentResolved=false; players.forEach(p=>{p.history=[];p.lastHadMiss=false;p.returnBlock=false;p.pressReason='';p.pressed=false;}); showMoment(); buildEntry(); $('results').innerHTML=''; $('cleanup').className='cleanup hidden';}
function currentMoment(){return scenario?.moments[momentIndex]}
function pressPlayer(p, aspect, reason){p.aspect=aspect; p.pressed=true; p.pressReason=reason;}
function applyMomentAspectEvent(m){ if(!m || !m.aspectShiftCategory || momentIndex===0) return; const cat=m.aspectShiftCategory.charAt(0).toUpperCase()+m.aspectShiftCategory.slice(1); players.forEach(p=>{ if(p.pressed || !p.lastHadMiss) return; const next=data.aspects.find(a=>a.deckId===p.deck && a.category===cat); if(next) pressPlayer(p,next,`scenario boundary shifted to ${cat} after a miss`); }); }
function showMoment(){const m=currentMoment(); if(!m)return; applyMomentAspectEvent(m); renderPlayers(); const wr=(momentIndex>0 && lastPath)?m.pathWrinkles?.[lastPath]:null; $('momentTitle').textContent=`${scenario.title}: ${m.title}`; let extra = momentIndex===0 ? `<br><b>Opening:</b> no previous path exists yet.` : (wr?`<br><b>${wr.label}:</b> ${wr.text}`:''); if(m.aspectShiftCategory && momentIndex>0) extra += `<br><b>Aspect pressure:</b> ${m.aspectShiftNote||'The app will Press or shift affected players based on the previous Moment.'}`; $('momentText').innerHTML=(m.text||'')+extra; $('tMind').textContent=m.target.Mind; $('tBody').textContent=m.target.Body; $('tSoul').textContent=m.target.Soul; seconds=m.timerSeconds||90; $('time').textContent=seconds; $('pathNote').innerHTML=momentIndex===0?'Opening Moment: no previous path.':`Current path: <span class="path">${lastPath||'none'}</span>`; momentResolved=false; $('cleanup').className='cleanup hidden';}
function returnsForPlayer(p){let returns=data.cards.filter(c=>c.deckId===p.deck && (c.tags||[]).includes('RETURN')); if(!p.pressed) return []; const needed='RETURN_'+p.aspect.category.toUpperCase(); return returns.filter(c=>(c.tags||[]).includes(needed));}
function buildEntry(){const wrap=$('entry'); wrap.innerHTML=''; players.forEach((p,i)=>{let returns=returnsForPlayer(p); let opts='<option value="">No matching RETURN physically played</option>'+returns.map(c=>`<option value="${c.id}">${c.id} ${c.title} - ${p.aspect.category} RETURN only</option>`).join(''); let div=document.createElement('div');div.className='entryPanel '+(p.pressed?'pressed':''); div.innerHTML=`<h3>${p.name} - ${p.aspect.name}${p.pressed?' (Pressed '+p.aspect.category+')':''}</h3><p class="small">Aspect mods: ${modLine(p.aspect)}${p.pressed?'<br>Pressed player constraint: '+p.aspect.pressedRule:''}${p.pressed&&p.pressReason?'<br>Pressed because: '+p.pressReason:''}</p><div class="row"><label>Mind raw<input type="number" class="Mind" value="0" min="0" max="20"></label><label>Body raw<input type="number" class="Body" value="0" min="0" max="20"></label><label>Soul raw<input type="number" class="Soul" value="0" min="0" max="20"></label><label>RETURN matching current Pressed Aspect<select class="ret" ${p.pressed?'':'disabled'}>${opts}</select></label><label>Had to reshuffle discard into draw deck?<select class="reshuffle"><option value="no">No</option><option value="yes">Yes</option></select></label></div>`; wrap.appendChild(div);});}
function startTimer(){clearInterval(timer); seconds=currentMoment()?.timerSeconds||90; const half=Math.floor(seconds/2); let hit=false; timer=setInterval(()=>{seconds--; $('time').textContent=seconds; if(!hit && seconds<=half){hit=true; alert('Halfway: lock current Leads. Any empty lane can still receive its first card later, but that first card is still its Lead.')} if(seconds<=0){clearInterval(timer); alert('Moment ended. Enter final raw values.')}},1000)}
function grade(v,t){if(v===t)return ['Ideal','ideal']; if(Math.abs(v-t)<=1)return ['Close','close']; return [v<t?'Miss low':'Miss high','miss'];}
function dominantPath(table){let counts=lanes.map(l=>[l,table[l].miss]); let max=Math.max(...counts.map(x=>x[1])); if(max===0) return 'Balanced'; let winners=counts.filter(x=>x[1]===max); return winners.length===1?winners[0][0]:'Balanced';}
function physicalChecklist(){return confirm('Physical legality check before Resolve:\n\n1. First card in each lane is the Lead.\n2. Second card in a lane is the Support.\n3. Banked cards are Leads for this Moment, not Supports.\n4. Raw values ignore Aspect modifiers.\n5. A RETURN is selected only if that exact matching physical card was played in its matching lane.\n\nResolve now?')}
function cleanupPrompt(){let lines=['<h3>Cleanup and draw-up now</h3>','<ol><li>Move all Lead and Support cards in your lanes to your Used pile.</li><li>Keep unplayed cards in your hand.</li><li>Keep any Banked Leads assigned for the next Moment in your Banked Lead area.</li><li>Draw from your draw deck up to 6 cards, or 5 if Pressed Mind.</li><li>If your draw deck is empty and you must draw, shuffle discard into draw deck and mark Reshuffled next Moment.</li><li>Do not shuffle Used pile into draw deck during this scenario.</li></ol>']; $('cleanup').className='cleanup'; $('cleanup').innerHTML=lines.join('');}
function resolve(){if(momentResolved){alert('This Moment is already resolved. Do Cleanup, then press Next Moment.');return;} if(!physicalChecklist())return; const m=currentMoment(); if(!m)return; const panels=[...document.querySelectorAll('.entryPanel')]; let table={Mind:{miss:0,good:0},Body:{miss:0,good:0},Soul:{miss:0,good:0}}; const res=$('results'); res.innerHTML=''; let momentTotals={Ideal:0,Close:0,Miss:0}; panels.forEach((pan,i)=>{const p=players[i]; const raw={}; const adj={}; const grades={}; lanes.forEach(l=>{raw[l]=+pan.querySelector('.'+l).value; adj[l]=raw[l]+(p.aspect.mods?.[l]||0); grades[l]=grade(adj[l],m.target[l]); if(grades[l][1]==='miss'){table[l].miss++; momentTotals.Miss++;} else {table[l].good++; momentTotals[grades[l][0]]++;}}); let ret=pan.querySelector('.ret')?.value||''; let returnSuccess=false; let returnReject=''; if(ret && p.pressed){let card=data.cards.find(c=>c.id===ret); let needed='RETURN_'+p.aspect.category.toUpperCase(); let hasNeeded=(card?.tags||[]).includes(needed); let lane=p.aspect.category; if(hasNeeded && grades[lane][1]!=='miss'){p.pressed=false; p.pressReason=''; returnSuccess=true; p.returnBlock=true;} else if(!hasNeeded){returnReject=`RETURN rejected: ${ret} does not match current Pressed ${p.aspect.category} Aspect.`;} else {returnReject=`RETURN failed: ${lane} was not Close or Ideal after app adjustment.`;}}
 const reshuffle=pan.querySelector('.reshuffle').value==='yes'; if(reshuffle && !returnSuccess){const next=data.aspects.find(a=>a.deckId===p.deck && a.category===p.aspect.category) || p.aspect; pressPlayer(p,next,'reshuffle: discard was shuffled into draw deck');}
 const hadMiss=lanes.some(l=>grades[l][1]==='miss'); p.lastHadMiss=hadMiss; p.history.push({grades:Object.fromEntries(lanes.map(l=>[l,grades[l][0]])), returnSuccess});
 let div=document.createElement('div'); div.className='result'; div.innerHTML=`<h3>${p.name}</h3><p>Aspect: ${p.aspect.name}${p.pressed?' - Pressed':''}${returnSuccess?' - matching RETURN recovered':''}${returnReject?'<br><b>'+returnReject+'</b>':''}</p>`+lanes.map(l=>`<span class="grade ${grades[l][1]}">${l}: raw ${raw[l]} -> ${adj[l]} / target ${m.target[l]} = ${grades[l][0]}</span>`).join(''); res.appendChild(div); });
 lastPath=dominantPath(table); pathHistory.push({moment:m.title,path:lastPath,table,totals:momentTotals}); ['Ideal','Close','Miss'].forEach(k=>scenarioTotals[k]+=momentTotals[k]); applyAutomaticPress(); renderPlayers(); buildEntry(); momentResolved=true; cleanupPrompt(); if(momentIndex>=scenario.moments.length-1){showFinal(momentTotals);} else {$('pathNote').innerHTML=`Next path: <span class="path">${lastPath}</span>. Do Cleanup/draw-up, then press Next Moment.`;}}
function applyAutomaticPress(){players.forEach(p=>{if(p.pressed || p.returnBlock){p.returnBlock=false;return;} let recent=p.history.slice(-2); if(recent.length<2) return; for(const l of lanes){let misses=recent.filter(h=>(h.grades[l]||'').startsWith('Miss')).length; let ideals=recent.filter(h=>h.grades[l]==='Ideal').length; if(misses>=2 || ideals>=2){let next=data.aspects.find(a=>a.deckId===p.deck && a.category===l); if(next) pressPlayer(p,next,`two consecutive ${l} ${misses>=2?'Miss':'Ideal'} results`); break;}}});}
function showFinal(momentTotals){if(finalShown)return; finalShown=true; const ending=scenario.ending?.[lastPath||'Balanced'] || 'Read the table results as what was carried forward.'; $('results').innerHTML += `<div class="final result"><h3>Scenario ending - ${lastPath||'Balanced'}</h3><p>${ending}</p><p><b>Whole-scenario table pattern:</b> Ideal ${scenarioTotals.Ideal}, Close ${scenarioTotals.Close}, Miss ${scenarioTotals.Miss}.</p><p><b>Final Moment pattern:</b> Ideal ${momentTotals.Ideal}, Close ${momentTotals.Close}, Miss ${momentTotals.Miss}.</p><p>This is not a win/loss. It is the shape of what happened inside. Do final Cleanup, then stop or load a new scenario.</p></div>`; $('pathNote').innerHTML=`Scenario complete: <span class="path">${lastPath||'Balanced'}</span> ending.`;}
function nextMoment(){if(!scenario)return; if(finalShown){alert('Scenario complete. Load a new scenario to play again.');return;} if(!momentResolved && !confirm('Current Moment is not resolved. Continue anyway?')) return; if(momentIndex>=scenario.moments.length-1){alert('Final Moment reached. Resolve it to see the ending.');return;} momentIndex++; showMoment(); buildEntry(); $('results').innerHTML='';}
function renderHelp(){const h=$('helpPanel'); if(!h)return; h.innerHTML=`<h2>Rules help</h2><p class="small"><b>Bank 1:</b> reveal top draw-deck card and assign it as a Banked Lead for next Moment. It does not enter your hand. Max one Banked Lead per lane.</p><p class="small"><b>Cleanup:</b> after app result, lane cards go to Used pile, unplayed hand stays, draw up to 6 or 5 if Pressed Mind.</p><p class="small"><b>Aspect pressure:</b> reshuffle, two consecutive Ideal/Miss results in the same lane, or scenario boundary can Press an Aspect. The app tells you why.</p><p class="small"><b>RETURN:</b> only the RETURN matching your current Pressed Aspect category can recover it. Recovery is checked after final app adjustment.</p>`;}
$('buildPlayers').onclick=buildPlayers; $('randomAspects').onclick=()=>randomiseAspects(true); $('loadScenario').onclick=loadScenario; $('nextMoment').onclick=nextMoment; $('startTimer').onclick=startTimer; $('halfway').onclick=()=>alert('Halfway lock: current Leads are now locked. Empty lanes can still receive their first card later as Lead, but they lose that lane\'s Support chance unless a card says otherwise.'); $('endTimer').onclick=()=>{clearInterval(timer); $('time').textContent='ended'}; $('resolve').onclick=resolve;
if('serviceWorker' in navigator) window.addEventListener('load',()=>navigator.serviceWorker.register('sw.js').catch(()=>{})); load();
