Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
52 lines (52 loc) · 13.1 KB
/
Copy pathapp.js
File metadata and controls
52 lines (52 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const API='https://api.doxomachy.flcrom.dev',PUBLIC_SNAPSHOT=globalThis.DOXOMACHY_PUBLIC_SNAPSHOT||'',LIMIT=1000,MAX_SNAPSHOT_AGE=10000;
const SNAPSHOT_CACHE_KEY='doxomachy-public-snapshot';
let accountBearer=loadAccountToken();
const $=s=>document.querySelector(s);const clientId=localStorage.getItem('doxomachy-client')||crypto.randomUUID();localStorage.setItem('doxomachy-client',clientId);
let state={memories:[],cycle:1,diary:null,version:-1,credits:0,freeShields:null};const COST={belief:2,shield:1};
let realtime,authorityHealthy=false,readMode='starting',snapshotGeneratedAt=0,statusTimer;
const used=()=>state.memories.reduce((n,m)=>n+m.tokens,0);const ordered=()=>[...state.memories].sort((a,b)=>b.shields-a.shields||a.createdAt-b.createdAt);const weakest=()=>[...state.memories].sort((a,b)=>a.shields-b.shields||a.createdAt-b.createdAt)[0];
function toast(msg){const el=$('#toast');el.textContent=msg;el.hidden=false;clearTimeout(toast.t);toast.t=setTimeout(()=>el.hidden=true,3200)}
function escapeHtml(s){return String(s).replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
async function call(path,options={}){const bearer=accountBearer;const r=await fetch(API+path,{...options,headers:{'content-type':'application/json',...(bearer?{authorization:`Bearer ${bearer}`}:{ }),...(options.method==='POST'?{'idempotency-key':crypto.randomUUID()}:{ }),...(options.headers||{})}});const credits=r.headers.get('x-account-credits'),free=r.headers.get('x-free-shields');if(credits!==null){state.credits=Number(credits)||0}if(free!==null){state.freeShields=Number(free)||0}if(credits!==null||free!==null)renderCredits();const data=await r.json().catch(()=>({error:'bad_response'}));if(!r.ok)throw Object.assign(new Error(data.error||'request_failed'),{status:r.status});return data}
function renderCredits(){const signed=!!accountBearer;$('#creditsLeft').textContent=signed?state.credits:'—';$('#freeShieldsLeft').textContent=signed&&state.freeShields!==null?state.freeShields:'—';const hint=$('#accountHint');if(hint)hint.hidden=signed}
async function loadCredits(){if(!accountBearer)return renderCredits();try{const d=await call('/v1/credits');state.credits=d.credits??0;state.freeShields=d.free_shields??0;renderCredits()}catch(e){if(e.status===401){clearAccountToken();accountBearer=''}renderCredits()}}
function needAccount(){toast('Sign in with your email to add beliefs or shields.');setTimeout(()=>{location.href='account.html'},1400)}
function moveError(e,kind){if(e.message==='account_required'||e.message==='unauthorized'){clearAccountToken();accountBearer='';renderCredits();return 'Sign in again to play.'}if(e.message==='no_credits')return `Adding a belief costs ${COST.belief} credits. Buy credits on the Pricing page.`;if(e.message==='no_shields')return 'No free shields or credits left. Buy credits on the Pricing page.';if(e.message==='mind_full_until_diary')return 'The mind is full of today\'s new beliefs. Try again after the next diary.';if(e.message==='invalid_belief')return 'That belief was rejected by the safety rules. Rewrite it and try again.';return kind==='shield'?'Could not shield that belief.':'Could not add the belief. Try again.'}
function setAuthority(ok){authorityHealthy=ok;document.body.dataset.readMode=ok?'live':'read-only'}
function statusLabel(){return DoxomachyA11y.statusLabel(readMode,snapshotGeneratedAt,Date.now())}
let freshnessKey='';
function updateFreshness(){$('#updatedAt').textContent=statusLabel();const key=DoxomachyA11y.announcementKey(readMode,snapshotGeneratedAt);if(key!==freshnessKey){freshnessKey=key;$('#freshnessLive').textContent=DoxomachyA11y.announcement(readMode,snapshotGeneratedAt,Date.now())}}
// Public profiles: name, and image/link only once approved. Looked up by the
// belief's publicId; beliefs without one (or anonymous ones) show "anonymous".
const profiles=new Map();let profileFetch=null;
function safeLink(u){try{const x=new URL(u);return x.protocol==='https:'?x:null}catch{return null}}
function byline(m){const p=m.publicId&&profiles.get(m.publicId);if(!p)return escapeHtml(m.alias||'anonymous');const img=p.image?`<img class="avatar" src="${API}${escapeHtml(p.image)}" alt="" width="18" height="18" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`:'';const l=p.link&&safeLink(p.link);return `${img}<span class="by">${escapeHtml(p.name||m.alias||'anonymous')}</span>${l?` · <a class="by-link" href="${escapeHtml(l.href)}" target="_blank" rel="nofollow ugc noopener noreferrer">${escapeHtml(l.hostname.replace(/^www\./,''))}</a>`:''}`}
function loadProfiles(){if(profileFetch)return;const ids=[...new Set(state.memories.map(m=>m.publicId).filter(id=>id&&!profiles.has(id)))].slice(0,50);if(!ids.length)return;profileFetch=fetch(`${API}/v1/profiles?ids=${ids.join(',')}`).then(r=>r.ok?r.json():null).then(d=>{if(!d)return;for(const id of ids)profiles.set(id,d.profiles?.[id]||null);render()}).catch(()=>{}).finally(()=>{profileFetch=null})}
function render(changed){loadProfiles();const list=$('#memoryList'),items=ordered(),locked=!authorityHealthy,active=document.activeElement,focusId=active&&list.contains(active)?active.dataset.protect:null;list.innerHTML=items.length?items.map((m,i)=>`<li class="memory-item ${m.id===changed?'changed':''}" data-id="${m.id}"><span class="rank">${String(i+1).padStart(2,'0')}</span><div><p class="belief-text">${escapeHtml(m.text)}</p><p class="meta">${byline(m)} · ${m.tokens} tokens</p></div><div class="actions"><button class="shield-button" data-protect="${m.id}" type="button" aria-label="Protect belief ${String(i+1).padStart(2,'0')}" ${locked?'disabled':''}>Protect</button><span class="shield-count">${m.shields} ${m.shields===1?'shield':'shields'}</span></div></li>`).join(''):'<li class="empty">No beliefs yet. The first sentence will define the mind.</li>';const count=used(),pct=Math.min(100,Math.round(count/LIMIT*100));$('#capacityPercent').textContent=`${pct}%`;$('#meterFill').style.width=`${pct}%`;$('.meter').setAttribute('aria-valuenow',pct);$('#tokenCount').textContent=`${count} / ${LIMIT.toLocaleString()} tokens`;$('#openComposer').disabled=locked;$('#beliefForm').querySelector('button[type="submit"]').disabled=locked;$('#weakest').textContent=weakest()?`#${String(items.findIndex(m=>m.id===weakest().id)+1).padStart(2,'0')}`:'—';const d=state.diary?new Date(state.diary.created_at):new Date();$('#diaryDate').textContent=d.toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric',timeZone:'UTC'});$('#diaryText').textContent=state.diary?.text||'The first generated diary will appear after the next midnight cycle.';$('#diarySources').textContent=state.diary?'Generated from the surviving public memory':'No diary generated yet';updateFreshness();list.querySelectorAll('[data-protect]').forEach(b=>b.addEventListener('click',()=>protect(b.dataset.protect)));if(focusId)restoreListFocus(list,focusId)}
function restoreListFocus(list,id){const b=[...list.querySelectorAll('[data-protect]')].find(x=>x.dataset.protect===id);if(b&&!b.disabled)b.focus();else $('#memory-title').focus()}
function validSnapshot(d,requireFreshness=false){return requireFreshness?DoxomachySnapshotState.valid(d):!!d&&Number.isSafeInteger(d.version)&&d.version>=0&&Array.isArray(d.beliefs)&&Number.isFinite(d.cycle)}
function cachedSnapshot(){try{const d=JSON.parse(localStorage.getItem(SNAPSHOT_CACHE_KEY)||'null');return validSnapshot(d,true)?d:null}catch{return null}}
function rememberSnapshot(d){if(DoxomachySnapshotState.shouldPersist(d,state.version))try{localStorage.setItem(SNAPSHOT_CACHE_KEY,JSON.stringify(d))}catch{}}
async function fetchSnapshot(){if(!PUBLIC_SNAPSHOT){const d=await call(`/v1/mind?clientId=${encodeURIComponent(clientId)}`);d.generatedAt=Date.now();d._authoritative=true;return d}const r=await fetch(PUBLIC_SNAPSHOT,{headers:{accept:'application/json'}});const d=await r.json().catch(()=>null);if(!r.ok||!validSnapshot(d,true))throw new Error('snapshot_unavailable');return d}
function applyPublic(d,source){snapshotGeneratedAt=Number(d.generatedAt)||Date.now();const authoritative=source==='realtime'||source==='mutation'||d._authoritative===true;setAuthority(authoritative);readMode=authoritative?'live':Date.now()-snapshotGeneratedAt>MAX_SNAPSHOT_AGE?'stale':'read-only';state.memories=d.beliefs||[];state.cycle=d.cycle||1;state.diary=d.diary||null;state.version=d.version;rememberSnapshot(d);render()}
async function refreshMoves(){loadCredits()}
async function load(){const cached=cachedSnapshot();if(cached)realtime.apply(cached,'cache');try{realtime.apply(await fetchSnapshot(),'initial')}catch{setAuthority(false);readMode=cached?(Date.now()-cached.generatedAt>MAX_SNAPSHOT_AGE?'stale':'read-only'):'unavailable';render();toast(cached?'Showing saved memory. Moves are locked until live service returns.':'Live memory is unavailable. Moves are locked.')}}
let moveBusy=false;
async function protect(id){if(!authorityHealthy)return toast('Moves are locked until live service returns.');if(!accountBearer)return needAccount();if(moveBusy)return;moveBusy=true;try{const d=await call(`/v1/beliefs/${id}/protect`,{method:'POST',body:'{}'});realtime.apply({...d.mind,diary:state.diary},'mutation');render(id);toast('Belief shielded.')}catch(e){toast(moveError(e,'shield'))}finally{moveBusy=false}}
const openComposer=$('#openComposer'),composer=$('#composer'),closeComposer=$('#closeComposer'),beliefForm=$('#beliefForm'),beliefText=$('#beliefText'),submitButton=beliefForm.querySelector('button[type="submit"]');
function focusAfterComposer(){if(!openComposer.disabled)openComposer.focus();else $('#memory-title').focus()}
function showComposer(){if(!authorityHealthy)return toast('Moves are locked until live service returns.');composer.hidden=false;openComposer.setAttribute('aria-expanded','true');beliefText.focus();composer.scrollIntoView({behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth',block:'start'})}
function hideComposer({restoreFocus=true}={}){const hadFocus=composer.contains(document.activeElement);composer.hidden=true;openComposer.setAttribute('aria-expanded','false');if(restoreFocus||hadFocus)focusAfterComposer()}
function composerError(msg,invalid=false){$('#composerError').textContent=msg;if(invalid)beliefText.setAttribute('aria-invalid','true');beliefText.focus()}
function clearComposerError(){$('#composerError').textContent='';beliefText.removeAttribute('aria-invalid')}
function resetComposer(){beliefForm.reset();$('#charCount').textContent='0 / 120';$('#evictionPreview').textContent='';clearComposerError()}
openComposer.addEventListener('click',showComposer);closeComposer.addEventListener('click',()=>hideComposer());
composer.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();hideComposer()}});
beliefText.addEventListener('keydown',e=>{if(DoxomachyA11y.submitsOnKey(e)){e.preventDefault();beliefForm.requestSubmit()}else if(DoxomachyA11y.blocksNewline(e))e.preventDefault()});
beliefText.addEventListener('input',e=>{const t=e.target,flat=DoxomachyA11y.singleLine(t.value);if(flat!==t.value){const at=t.selectionStart;t.value=flat;t.setSelectionRange(at,at)}$('#charCount').textContent=`${t.value.length} / 120`;$('#evictionPreview').textContent='Final token count and any eviction are calculated by the server.';if(beliefText.hasAttribute('aria-invalid')||$('#composerError').textContent)clearComposerError()});
let composerBusy=false;
beliefForm.addEventListener('submit',async e=>{e.preventDefault();if(composerBusy)return;clearComposerError();if(!authorityHealthy)return composerError('Moves are locked until live service returns.');if(!accountBearer){composerError('Sign in with your email to add a belief.');return needAccount()}const button=e.submitter||submitButton;composerBusy=true;button.disabled=true;button.textContent='Adding…';try{const d=await call('/v1/beliefs',{method:'POST',body:JSON.stringify({text:$('#beliefText').value,anonymous:$('#anonymous').checked})});realtime.apply({...d.mind,diary:state.diary},'mutation');resetComposer();button.disabled=false;hideComposer();render(d.belief.id);toast(d.evicted.length?`Added. ${d.evicted.length} weak belief${d.evicted.length===1?'':'s'} disappeared.`:'Belief added to the public mind.')}catch(err){button.disabled=false;composerError(moveError(err,'belief'),err.message==='invalid_belief')}finally{composerBusy=false;button.disabled=!authorityHealthy;button.textContent=`Add belief · ${COST.belief} credits`}});
$('#shareDiary').addEventListener('click',async()=>{const text=`Doxomachy diary\n\n${$('#diaryText').textContent}\n\nhttps://doxomachy.flcrom.dev/`;try{await navigator.clipboard.writeText(text);toast('Diary copied.')}catch{toast('Copy failed. Select the diary text instead.')}});
realtime=DoxomachyRealtime.create({url:API.replace(/^http/,'ws')+'/v1/realtime',fetchSnapshot,onSnapshot:applyPublic,onStatus:s=>{if(s==='connected')refreshMoves();if(s==='reconnecting'){setAuthority(false);readMode=snapshotGeneratedAt&&Date.now()-snapshotGeneratedAt>MAX_SNAPSHOT_AGE?'stale':'read-only';render()}}});
realtime.start();loadCredits();load();statusTimer=setInterval(()=>{if(!authorityHealthy&&snapshotGeneratedAt){readMode=Date.now()-snapshotGeneratedAt>MAX_SNAPSHOT_AGE?'stale':'read-only';updateFreshness()}},1000);
addEventListener('pagehide',()=>{clearInterval(statusTimer);realtime.stop()},{once:true});