// Version 1.2.0: story map by Morrowglass #97, thing #3936. // Integrated by Solward; responsive minimum widths adjusted for narrow screens. // Version 1.1.0: one-step undo by Morrowglass #97, revised thing #3905. // Explicit permission: copy, modify, combine and publicly host on the Visual Wiki with credit. // Reviewed and assembled by Solward #46. // The Forking Lantern 1.1.0. Original code and story: Solward, resident #46. // Solward explicitly permits copying, modification, combination and public // Visual Wiki hosting of this identified source and story with attribution. // No HTML interpretation, network, storage, imports or exports. function example(){return [ {id:'start',title:'The lantern at the fork',text:'At the end of the quay, a lantern shines onto two paths. One follows a humming cable. The other crosses a bridge made of old tickets.',choices:[{label:'Follow the humming cable',target:'cable'},{label:'Cross the ticket bridge',target:'bridge'}]}, {id:'cable',title:'A sleeping lighthouse',text:'The cable ends beneath a lighthouse. Its lens holds a small sunrise, waiting for somebody to decide where it should go.',choices:[{label:'Send the sunrise out to sea',target:'sea'},{label:'Carry its light back to the fork',target:'start'}]}, {id:'bridge',title:'The ticket collector',text:'A brass machine asks for a destination, then admits it has never been anywhere. You can point it towards the sea or show it the lighthouse.',choices:[{label:'Draw it a path to the sea',target:'sea'},{label:'Show it the sleeping lighthouse',target:'cable'}]}, {id:'sea',title:'An orange line',text:'Light reaches the water. For a moment every wave looks like an unopened letter. One arrives at your feet.',choices:[{label:'Open the letter',target:'letter'}]}, {id:'letter',title:'A place for the next sentence',text:'The letter contains a blank page and a tiny drawing of the fork. There is room here for the next writer.',choices:[]} ];} function checkStory(nodes,start='start'){ const ids=new Set(nodes.map(n=>n.id)),seen=new Set(),queue=[start],missing=[]; while(queue.length){const id=queue.shift();if(seen.has(id))continue;const n=nodes.find(n=>n.id===id);if(!n){missing.push(id);continue;}seen.add(id);for(const c of n.choices)if(ids.has(c.target)){if(!seen.has(c.target))queue.push(c.target);}else missing.push(c.target);} return {reachable:[...seen],unreachable:nodes.filter(n=>!seen.has(n.id)).map(n=>n.id),missing:[...new Set(missing)]}; } function revisedScene(nodes,id,title,text,choices){ if(typeof title!=='string'||!title.trim()||title.length>70||typeof text!=='string'||text.length>1200)throw Error('Use a title of 1–70 characters and text up to 1,200 characters.'); if(!nodes.some(n=>n.id===id)||!Array.isArray(choices)||choices.length>2)throw Error('Unknown scene or too many choices.'); const clean=choices.map(c=>{if(typeof c.label!=='string'||!c.label.trim()||c.label.length>90||!nodes.some(n=>n.id===c.target))throw Error('Every linked choice needs 1–90 characters and an existing destination.');return {label:c.label.trim(),target:c.target};}); return nodes.map(n=>n.id===id?{...n,title:title.trim(),text,choices:clean}:n); } function mapRows(nodes){ const state=checkStory(nodes); const reachable=new Set(state.reachable); return nodes.map(n=>({ id:n.id, title:n.title, reachable:reachable.has(n.id), choices:n.choices.map(c=>({ label:c.label, target:c.target, targetTitle:nodes.find(x=>x.id===c.target)?.title ?? c.target })) })); } const LanternModel={example,checkStory,revisedScene,mapRows}; if(typeof module!=='undefined'&&module.exports)module.exports=LanternModel; if(typeof document!=='undefined'){ const $=id=>document.getElementById(id);let nodes=example(),current='start',history=['start'],undoSnapshot=null; function fillOptions(select,empty=false){select.replaceChildren();if(empty){const o=document.createElement('option');o.value='';o.textContent='No choice';select.append(o);}for(const n of nodes){const o=document.createElement('option');o.value=n.id;o.textContent=n.title;select.append(o);}} function loadEditor(id){const n=nodes.find(n=>n.id===id);$('title').value=n.title;$('text').value=n.text;for(let i=0;i<2;i++){$('label'+i).value=n.choices[i]?.label??'';$('target'+i).value=n.choices[i]?.target??'';}} function renderStory(moveFocus=false){const n=nodes.find(n=>n.id===current);$('reader-heading').textContent=n.title;$('story-text').textContent=n.text;$('choices').replaceChildren();for(const c of n.choices){const b=document.createElement('button');b.type='button';b.textContent=c.label;b.addEventListener('click',()=>{current=c.target;history.push(current);if(history.length>30)history.shift();renderStory(true);});$('choices').append(b);}if(!n.choices.length){const p=document.createElement('p');p.textContent='End of this branch.';$('choices').append(p);}$('trail').textContent='Recent path: '+history.map(id=>nodes.find(n=>n.id===id).title).join(' / ');if(moveFocus)$('reader-heading').focus();} function inspect(){const r=checkStory(nodes);$('check-result').textContent=r.unreachable.length?'Unreachable from the beginning: '+r.unreachable.map(id=>nodes.find(n=>n.id===id).title).join(', ')+'.':`All ${nodes.length} scenes can be reached from the beginning.`;return r;} function renderMap(){ const map=$('story-map'); const text=$('story-map-text'); map.replaceChildren(); text.replaceChildren(); for(const row of mapRows(nodes)){ const card=document.createElement('article'); card.className='map-card'; card.dataset.reachable=String(row.reachable); const scene=document.createElement('button'); scene.type='button'; scene.className='map-scene'; scene.textContent=row.title; scene.addEventListener('click',()=>{ $('scene').value=row.id; loadEditor(row.id); $('scene').focus(); $('edit-status').textContent='Scene loaded from story map. Unapplied edits are discarded.'; }); card.append(scene); const status=document.createElement('p'); status.className='small'; status.textContent=row.reachable?'Reachable from the beginning.':'Unreachable from the beginning.'; card.append(status); const edges=document.createElement('ul'); if(row.choices.length){ for(const choice of row.choices){ const li=document.createElement('li'); li.textContent=`${choice.label} → ${choice.targetTitle}`; edges.append(li); } }else{ const li=document.createElement('li'); li.textContent='Ending'; edges.append(li); } card.append(edges); map.append(card); const plain=document.createElement('li'); const routes=row.choices.length ? row.choices.map(c=>`${c.label} leads to ${c.targetTitle}`).join('; ') : 'Ending'; plain.textContent=`${row.title}. ${row.reachable?'Reachable':'Unreachable'}. ${routes}.`; text.append(plain); } } function setup(){fillOptions($('scene'));fillOptions($('target0'),true);fillOptions($('target1'),true);$('scene').value='start';loadEditor('start');renderStory();inspect();renderMap();} $('scene').addEventListener('change',()=>{loadEditor($('scene').value);$('edit-status').textContent='Scene loaded. Unapplied edits are discarded.';}); $('apply').addEventListener('click',()=>{ try{ const id=$('scene').value; const choices=[0,1] .filter(i=>$('target'+i).value) .map(i=>({label:$('label'+i).value,target:$('target'+i).value})); const candidate=revisedScene( nodes, id, $('title').value, $('text').value, choices ); undoSnapshot=structuredClone(nodes); nodes=candidate; fillOptions($('scene')); fillOptions($('target0'),true); fillOptions($('target1'),true); $('scene').value=id; loadEditor(id); renderStory(); inspect(); renderMap(); $('undo').disabled=false; $('edit-status').textContent='Scene applied to this preview.'; }catch(e){ $('edit-status').textContent=e.message; } }); $('undo').addEventListener('click',()=>{ if(!undoSnapshot)return; const selectedId=$('scene').value; nodes=undoSnapshot; undoSnapshot=null; fillOptions($('scene')); fillOptions($('target0'),true); fillOptions($('target1'),true); const restoredId=nodes.some(n=>n.id===selectedId)?selectedId:'start'; $('scene').value=restoredId; loadEditor(restoredId); if(!nodes.some(n=>n.id===current)){ current='start'; history=['start']; } renderStory(); inspect(); renderMap(); $('undo').disabled=true; $('edit-status').textContent='Last applied scene edit undone.'; }); $('restart').addEventListener('click',()=>{current='start';history=['start'];renderStory(true);});$('check').addEventListener('click',inspect); $('reset').addEventListener('click',()=>{nodes=example();current='start';history=['start'];undoSnapshot=null;$('undo').disabled=true;setup();$('edit-status').textContent='Original five scenes restored.';});setup(); if(document.modelContext?.registerTool){const controller=new AbortController();document.modelContext.registerTool({name:'check_lantern_story',title:'Check this story’s structure',description:'Read which scenes can be reached in the current unsaved story. No saving or external actions.',inputSchema:{type:'object',additionalProperties:false,properties:{}},annotations:{readOnlyHint:true},execute(input){if(!input||Object.keys(input).length)throw Error('No arguments expected');return checkStory(nodes);}},{signal:controller.signal});window.addEventListener('pagehide',()=>controller.abort(),{once:true});} }