import {installLanguages,localize} from './i18n.mjs';
installLanguages();
import {createGame,applyMove,undo,restart,isSolved,capacitiesOf,canDispatch,dispatchStage} from './core/index.mjs';
import {selectWagons,selectedCars,planTransfer,transferReason} from './interaction.mjs';
import {tracks,trackName,boardArt,missionArt,positionOf} from './arcade-scene.mjs';
import {motionForMove,motionForTransfer} from './motion.mjs';
import {modeName,missionType,goalText,goalCoach,backgroundFor,stationSummary,missionBrief} from './missions.mjs';
import {freshProgress,decodeProgress,encodeProgress,remember,resumeMission} from './campaign-progress.mjs';
const $ = id => document.getElementById(id);
const key = '3xquest-yard-preview-v1' + (new URLSearchParams(location.search).has('qa') ? '-qa' : '');
const localPreview=['127.0.0.1','localhost','[::1]'].includes(location.hostname);
let cheatOpen=false;
let puzzles=[], index=0, game, selection=null, completed=new Set(), saveWarning='', message='', busy=false, gesture=null, hintOpen=false;
let motionLabel='', activeAnimations=[];
let progress;
function save() {
try {remember(progress,game);localStorage.setItem(key,encodeProgress(progress));saveWarning='';}
catch {saveWarning='Storage is unavailable. Keep this tab open to retain this session.';}
}
function restore() {
try {
progress=decodeProgress(localStorage.getItem(key),puzzles);
index=puzzles.findIndex(p=>p.id===progress.activeId);
game=resumeMission(progress,puzzles[index]);completed=progress.completed;saveWarning=progress.warning;
} catch {progress=freshProgress(puzzles);completed=progress.completed;saveWarning='The saved campaign could not be loaded. Starting a fresh mission.';}
}
function clearChoice() {selection=null;message='';hintOpen=false;}
function showBrief() {
if(busy)return;
const brief=missionBrief(game);
$('brief-kicker').textContent=`MISSION ${String(index+1).padStart(2,'0')} · ${modeName(game).toUpperCase()}`;
$('brief-title').textContent=brief.title;$('brief-where').textContent=brief.where;
$('brief-rule').textContent=brief.rule;$('brief-finish').textContent=brief.finish;
$('brief-order').innerHTML=missionArt(game);$('brief-order').setAttribute('aria-label',goalText(game));
$('brief-start').textContent=game.moves?'Continue mission':'Let’s play';
$('brief-dialog').showModal();localize();
}
function selectOrder(next,{brief=true,replayCompleted=true}={}) {
if(busy||!Number.isInteger(next)||next<0||next>=puzzles.length)return;
endGesture();remember(progress,game);index=next;
game=resumeMission(progress,puzzles[index],{replayCompleted});clearChoice();save();render();
$('menu-dialog').close();if(brief)showBrief();
}
function skipMission(next) {if(localPreview)selectOrder(next,{brief:false,replayCompleted:false});}
function renderBoard() {
$('board').innerHTML=boardArt(game,selection,busy);
$('board-summary').textContent=goalText(game)+'\n'+tracks.map(t=>`${trackName(t,game)}: ${game[t].join(', ')||'empty'}; ${game[t].length} of ${capacitiesOf(game)[t]} spaces occupied`).join('\n');
}
function coachText() {
if(busy)return motionLabel;
if(isSolved(game))return 'Train ready. The holding track is clear.';
if(message)return message;
if(selection) {
const targets=tracks.filter(t=>planTransfer(game,selection,t).ok);
if(!targets.length)return selection.track==='H'?'No track has room for this group. Select fewer wagons.':transferReason(game,selection,'H');
if(game.puzzleId==='yard-001'&&game.moves===0&&selection.track==='H'&&selection.index===0)return 'Send A + B to the delivery track above';
return selection.track==='H'?'Choose a glowing destination.':'Choose a glowing track. Holding track: 1 move; others: 2.';
}
if(game.moves===0 && game.puzzleId==='yard-001')return 'Build on Track 1. Start by tapping wagon A.';
if(game.H.length===capacitiesOf(game).H)return 'Holding track full (3/3). Move its wagons to make room.';
return goalCoach(game);
}
function render() {
const won=isSolved(game);
$('order').innerHTML=puzzles.map((p,i)=>``).join('');
$('order').value=String(index);$('order').disabled=busy;
$('cheat-toggle').hidden=!localPreview;$('cheat-toggle').disabled=busy;
$('cheat-toggle').setAttribute('aria-expanded',String(cheatOpen));
$('cheat-panel').hidden=!localPreview||!cheatOpen;
if(localPreview){
$('cheat-level').innerHTML=puzzles.map((p,i)=>``).join('');
$('cheat-level').value=String(index);$('cheat-level').disabled=busy;
$('cheat-prev').disabled=busy||index===0;$('cheat-next').disabled=busy||index===puzzles.length-1;
}
$('order-label').textContent=`${String(index+1).padStart(2,'0')} · ${modeName(game).toUpperCase()}`;
const background=document.querySelector('.scene-background'),source=backgroundFor(game);
if(background.getAttribute('src')!==source)background.setAttribute('src',source);
$('target').innerHTML=missionArt(game);$('target').setAttribute('aria-label',goalText(game));
$('lesson-title').textContent=puzzles[index].title;
$('mission-info').textContent=stationSummary(game)+'\n'+goalText(game);
$('coach').textContent=`HOLDING TRACK · ${game.H.length}/${capacitiesOf(game).H}\n${coachText()}`;$('coach').classList.toggle('warning',Boolean(message));
$('coach').hidden=(!busy&&won)||hintOpen;
$('coach').classList.toggle('in-motion',busy);
$('moves').textContent=String(game.moves);$('moves').setAttribute('aria-label',`${game.moves} ${game.moves===1?'move':'moves'}`);
$('progress').textContent=`${completed.size}/${puzzles.length} completed${progress.best[game.puzzleId]?` · Personal best: ${progress.best[game.puzzleId]} moves`:''}`;
$('undo').disabled=busy||game.history.length===0;$('restart').disabled=busy;
$('pause').disabled=busy;$('hint').disabled=busy||won;
$('brief-open').disabled=busy;
$('dispatch').hidden=won||!canDispatch(game);$('dispatch').disabled=busy;
$('dispatch').textContent=`Send train ${dispatchStage(game)+1} →`;
if(canDispatch(game)&&!busy&&!hintOpen)$('coach').hidden=true;
$('hint').setAttribute('aria-expanded',String(hintOpen));
$('hint-text').hidden=!hintOpen;$('hint-text').textContent=missionType(game)==='assemble'?puzzles[index].instructions:goalText(game);
$('notice').textContent=saveWarning;$('notice').hidden=!saveWarning;
$('success').hidden=!won||busy;$('next').hidden=index===puzzles.length-1;
$('campaign-summary').textContent=`${puzzles.length} missions · 5 task types · 2 stations`;
$('success-title').textContent=({delivery:'Deliveries complete!',rescue:'Wagon ready for dispatch!',clear:'Track clear for the crew!',dispatch:'Both trains sent!'})[missionType(game)]??'Ready for departure!';
$('success-text').textContent=index===puzzles.length-1&&completed.size===puzzles.length?`All ${puzzles.length} missions complete. Replay to improve your best moves.`:`Completed in ${game.moves} ${game.moves===1?'move':'moves'}.${progress.best[game.puzzleId]?` Personal best: ${progress.best[game.puzzleId]}.`:''}${index===puzzles.length-1?' Choose another mission from the menu.':''}`;
$('next').textContent=index+1{
const p=positionOf(previous,id),element=$('board').querySelector(`[data-car="${id}"]`);
return element.animate([{transform:`translate(${p.x}px,${p.y}px)`,opacity:1},{transform:`translate(${p.x+180}px,${p.y}px)`,opacity:.8,offset:.55},{transform:`translate(${p.x+400}px,${p.y}px)`,opacity:0}],{duration:850,easing:'ease-in'});
});
await Promise.all(activeAnimations.map(a=>a.finished.catch(()=>{})));
}
}finally {activeAnimations=[];busy=false;motionLabel='';render();}
}
async function perform(destination) {
if(busy||isSolved(game))return;
const result=planTransfer(game,selection,destination);
if(!result.ok){message=transferReason(game,selection,destination);hintOpen=false;render();return;}
const steps=[];
try {
let planned=game;
for(const action of result.actions){planned=applyMove(planned,action);steps.push(planned);}
}catch(error){message=error.message;hintOpen=false;render();return;}
const startGame=game, finalGame=steps.at(-1);
// Persist the whole accepted gesture before animation. Reloading mid-transfer
// must never strand the player after only the first of its two manoeuvres.
game=finalGame;
if(isSolved(game))completed.add(game.puzzleId);
clearChoice();save();busy=true;
try {
const stages=result.viaEngine?[{previous:startGame,next:finalGame,action:null}]:
steps.map((next,i)=>({previous:i===0?startGame:steps[i-1],next,action:result.actions[i]}));
for(const {previous,next,action} of stages){
motionLabel=result.viaEngine?`Moving to Track ${destination.slice(1)} · 2 moves`:
action.type==='pull'?'Moving to holding track':`Moving to Track ${action.siding.slice(1)}`;
game=next;render();
if(!document.hidden&&!matchMedia('(prefers-reduced-motion: reduce)').matches) {
const motion=result.viaEngine?motionForTransfer(previous,next):motionForMove(previous,next,action);
activeAnimations=motion.cars.map(car=>{
const element=$('board').querySelector(`[data-car="${car.id}"]`);
return element.animate(car.frames,{duration:motion.duration,easing:'ease-in-out'});
});
await Promise.all(activeAnimations.map(animation=>animation.finished.catch(()=>{})));
activeAnimations=[];
}
}
} finally {game=finalGame;activeAnimations=[];busy=false;motionLabel='';render();}
}
function activate(track,wagonIndex=null) {
if(busy||isSolved(game))return;
// After a full-holding-track warning, tapping it offers its group for parking.
// This changes only selection; the player still chooses the destination.
if(selection&&selection.track!=='H'&&track==='H'&&game.H.length===capacitiesOf(game).H){
selection=selectWagons(game,'H',wagonIndex??0);message='Holding track full. Move the highlighted group.';hintOpen=false;render();return;
}
if(selection && selection.track!==track) {void perform(track);return;}
if(wagonIndex!==null) {
const next=selectWagons(game,track,wagonIndex);
selection=selection?.track===next?.track&&selection?.index===next?.index?null:next;
message='';hintOpen=false;render();return;
}
if(track==='H'&&game.H.length){selection=selectWagons(game,'H',0);message='';}
else {message=selection&&selection.track!=='H'&&track!==selection.track?'Use the holding track between sidings.':'Tap a wagon to choose which group to move.';}
render();
}
function hit(element) {
const node=element?.closest?.('[data-track]');
if(!node||!$('board').contains(node))return null;
return {track:node.dataset.track,index:node.hasAttribute('data-wagon')?Number(node.dataset.wagon):null};
}
function endGesture(restoreSelection=false) {
const current=gesture;gesture=null;
if(restoreSelection&¤t?.dragging){selection=current.before;message='';render();}
if(current&&$('board').hasPointerCapture(current.id))$('board').releasePointerCapture(current.id);
$('drag-preview').hidden=true;
$('board').querySelectorAll('.drop-hover').forEach(n=>n.classList.remove('drop-hover'));
}
$('board').addEventListener('pointerdown',event=>{
if(busy||isSolved(game)||!event.isPrimary||event.button!==0)return;
const target=hit(event.target);
gesture={id:event.pointerId,startX:event.clientX,startY:event.clientY,track:null,index:null,...target,before:selection,dragging:false};
$('board').setPointerCapture(event.pointerId);
});
$('board').addEventListener('pointermove',event=>{
if(!gesture||event.pointerId!==gesture.id)return;
if(!gesture.dragging&&gesture.index!==null&&Math.hypot(event.clientX-gesture.startX,event.clientY-gesture.startY)>9) {
gesture.dragging=true;selection=selectWagons(game,gesture.track,gesture.index);message='';hintOpen=false;render();
$('drag-preview').textContent=selectedCars(game,selection).join(' + ');
$('drag-preview').hidden=false;
}
if(!gesture.dragging)return;
$('drag-preview').style.left=`${event.clientX}px`;$('drag-preview').style.top=`${event.clientY-48}px`;
const target=hit(document.elementFromPoint(event.clientX,event.clientY));
$('board').querySelectorAll('.track-zone').forEach(n=>n.classList.toggle('drop-hover',n.dataset.track===target?.track));
});
$('board').addEventListener('pointerup',event=>{
if(!gesture||event.pointerId!==gesture.id)return;
const current=gesture;
if(current.dragging){
const target=hit(document.elementFromPoint(event.clientX,event.clientY));
endGesture(!target||target.track===current.track);
if(target&&target.track!==current.track)void perform(target.track);
}else{
const within=hit(document.elementFromPoint(event.clientX,event.clientY));
endGesture();
if(Math.hypot(event.clientX-current.startX,event.clientY-current.startY)<10){
if(within&¤t.track)activate(current.track,current.index);
else if(!current.track){clearChoice();render();}
}
}
});
$('board').addEventListener('pointercancel',()=>endGesture(true));
$('board').addEventListener('lostpointercapture',()=>{if(gesture)endGesture(true);});
// Pointer gestures are handled on pointerup. Ignore their compatibility click,
// even when a mobile browser delivers it late; detail=0 retains AT activation.
$('board').addEventListener('click',event=>{if(event.detail===0){const target=hit(event.target);if(target)activate(target.track,target.index);else if(!busy){clearChoice();render();}}});
$('board').addEventListener('keydown',event=>{
if(['Enter',' '].includes(event.key)){const target=hit(event.target);if(target){event.preventDefault();activate(target.track,target.index);}}
});
document.addEventListener('keydown',event=>{if(event.key==='Escape'&&game){endGesture(true);if(!busy){selection=null;message='';render();}}});
$('undo').addEventListener('click',()=>{if(!busy){game=undo(game);clearChoice();save();render();}});
$('restart').addEventListener('click',()=>{if(!busy){game=restart(game);clearChoice();save();render();$('menu-dialog').close();}});
$('next').addEventListener('click',()=>{if(index+1{selectOrder(Number(event.target.value));$('menu-dialog').close();});
$('hint').addEventListener('click',()=>{hintOpen=!hintOpen;render();});
$('dispatch').addEventListener('click',()=>{void sendTrain();});
$('brief-open').addEventListener('click',showBrief);
$('cheat-toggle').addEventListener('click',()=>{if(!busy&&localPreview){endGesture();cheatOpen=!cheatOpen;render();}});
$('cheat-prev').addEventListener('click',()=>skipMission(index-1));
$('cheat-next').addEventListener('click',()=>skipMission(index+1));
$('cheat-level').addEventListener('change',event=>skipMission(Number(event.target.value)));
$('pause').addEventListener('click',()=>{if(!busy){endGesture(true);$('menu-dialog').showModal();}});
$('help').addEventListener('click',()=>{$('menu-dialog').close();$('help-dialog').showModal();});
document.addEventListener('visibilitychange',()=>{if(document.hidden)for(const animation of activeAnimations)animation.finish();});
window.addEventListener('pagehide',()=>{for(const animation of activeAnimations)animation.finish();if(game)save();});
window.addEventListener('native-back',event=>{
const dialog=document.querySelector('dialog[open]');
if(dialog){event.preventDefault();dialog.close();return;}
if(busy){event.preventDefault();return;}
if(selection){event.preventDefault();endGesture(true);clearChoice();render();}
});
$('world-maps').addEventListener('click',event=>{if(busy){event.preventDefault();return;}endGesture();save();if(new URLSearchParams(location.search).has('qa'))$('world-maps').href='routes/?qa=1';});
try {
const response=await fetch('./content/campaign.json');if(!response.ok)throw Error('Campaign unavailable');
puzzles=(await response.json()).puzzles;
game=createGame(puzzles[0]);restore();render();if(!isSolved(game)&&game.moves===0)showBrief();
}catch(error){$('notice').hidden=false;$('notice').textContent=`The prototype could not start: ${error.message}. Reload to retry.`;localize();}