← back to CelebritySignatures
mobile: add game-engine validation harness (6/6 games ALL PASS vs live data)
dd93fc79feec7b419cffbc3fac1c510b091d3647 · 2026-08-06 06:16:33 -0700 · steve
Reimplements buildRounds 1:1 and asserts every game yields valid decks against
live celebsignatures.com + artic.edu data. Run: node scripts/game-engine-harness.mjs
Files touched
A apps/mobile/scripts/game-engine-harness.mjs
Diff
commit dd93fc79feec7b419cffbc3fac1c510b091d3647
Author: steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 06:16:33 2026 -0700
mobile: add game-engine validation harness (6/6 games ALL PASS vs live data)
Reimplements buildRounds 1:1 and asserts every game yields valid decks against
live celebsignatures.com + artic.edu data. Run: node scripts/game-engine-harness.mjs
---
apps/mobile/scripts/game-engine-harness.mjs | 86 +++++++++++++++++++++++++++++
1 file changed, 86 insertions(+)
diff --git a/apps/mobile/scripts/game-engine-harness.mjs b/apps/mobile/scripts/game-engine-harness.mjs
new file mode 100644
index 0000000..2297938
--- /dev/null
+++ b/apps/mobile/scripts/game-engine-harness.mjs
@@ -0,0 +1,86 @@
+// Standalone validation of the ported buildRounds engine (GameScreen.tsx) against
+// LIVE celebsignatures.com + artic.edu data. Mirrors the port's logic 1:1.
+const BASE = 'https://celebsignatures.com';
+
+const shuffle = (arr) => { const a=[...arr]; for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];} return a; };
+const notability = (r) => { const m=(r.reason_for_ranking||'').match(/(\d+) language/); return m?+m[1]:0; };
+const centuryOf = (r) => { const y=parseInt(String(r.death_date).slice(0,4),10); return isNaN(y)?null:Math.ceil(y/100); };
+const qidOf = (r) => { const m=(r.wikidata||'').match(/Q\d+/); return m?m[0]:null; };
+const DIFF_N = { icons:100, scholar:500, curator:2000, deep:Infinity };
+
+const ART_CACHE = {};
+async function artworkFor(r){
+ const qid = qidOf(r)||r.full_name;
+ if (qid in ART_CACHE) return ART_CACHE[qid];
+ let out=null;
+ try{
+ const last=r.full_name.toLowerCase().split(' ').pop()||'';
+ const res=await fetch(`https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(r.full_name)}&query[term][is_public_domain]=true&fields=id,title,image_id,artist_title,date_display&limit=8`);
+ const j=await res.json();
+ const hits=(j.data||[]).filter(a=>a.image_id&&(a.artist_title||'').toLowerCase().includes(last));
+ if(hits.length){ const a=hits[Math.floor(Math.random()*Math.min(hits.length,4))];
+ out={img:`https://www.artic.edu/iiif/2/${a.image_id}/full/600,/0/default.jpg`,title:a.title||'Untitled',date:a.date_display||'',url:`https://www.artic.edu/artworks/${a.id}`}; }
+ }catch{}
+ ART_CACHE[qid]=out; return out;
+}
+function distractors(answer,p,n=3){
+ const near=p.filter(r=>r!==answer&&r.category===answer.category);
+ const ranked=[...near].sort((a,b)=>Math.abs(notability(a)-notability(answer))-Math.abs(notability(b)-notability(answer)));
+ const ds=shuffle(ranked.slice(0,24)).slice(0,n);
+ while(ds.length<n){const x=shuffle(p).find(r=>r!==answer&&!ds.includes(r)); if(!x)break; ds.push(x);}
+ return ds;
+}
+function poolFor(gameKey,diff,cat,ALL){
+ const c=(gameKey==='whose'||gameKey==='century')?cat:'Artists';
+ const p=c==='All'?ALL:ALL.filter(r=>r.category===c);
+ return [...p].sort((a,b)=>notability(b)-notability(a)).slice(0,DIFF_N[diff]);
+}
+async function buildRounds(gameKey,diff,cat,ALL,EVO,setPrep=()=>{}){
+ const p=poolFor(gameKey,diff,cat,ALL); const rounds=[]; const TOTAL=10;
+ if(gameKey==='whose'){ if(p.length<8)throw new Error('Not enough signatures'); for(const answer of shuffle(p).slice(0,TOTAL)) rounds.push({kind:'whose',answer,options:shuffle([answer,...distractors(answer,p)])}); return rounds; }
+ if(gameKey==='century'){ const dated=p.filter(r=>centuryOf(r)); if(dated.length<TOTAL)throw new Error('Not enough dated'); for(const answer of shuffle(dated).slice(0,TOTAL)){ const c=centuryOf(answer); const opts=new Set([c]); let step=1,guard=0; while(opts.size<4&&guard++<200){const cand=c+(Math.random()<.5?-step:step); if(cand>=6&&cand<=21)opts.add(cand); step=Math.min(step+1,6);} for(let d=1;opts.size<4&&d<=15;d++){if(c-d>=6)opts.add(c-d); if(opts.size<4&&c+d<=21)opts.add(c+d);} rounds.push({kind:'century',answer,century:c,options:shuffle([...opts])}); } return rounds; }
+ if(gameKey==='early'){ const people=Object.entries(EVO).map(([qid,e])=>({qid,e,dated:(e.sigs||[]).filter(s=>s.year)})).filter(x=>new Set(x.dated.map(s=>s.year)).size>=2); if(people.length<5)throw new Error('Evolution loading'); for(let i=0;i<TOTAL;i++){const P=people[Math.floor(Math.random()*people.length)]; const years=shuffle([...new Set(P.dated.map(s=>s.year))]); const [y1,y2]=[years[0],years[1]].sort((a,b)=>a-b); const sigA=P.dated.find(s=>s.year===y1),sigB=P.dated.find(s=>s.year===y2); const flip=Math.random()<.5; rounds.push({kind:'early',person:P.e.name,qid:P.qid,leftUrl:flip?sigB.url:sigA.url,rightUrl:flip?sigA.url:sigB.url,earlier:flip?'right':'left',years:[y1,y2]});} return rounds; }
+ if(gameKey==='lightning'){ const cands=shuffle(p); let idx=0; const paintings=[]; while(paintings.length<8&&idx<cands.length){const batch=cands.slice(idx,idx+6);idx+=6; setPrep(`${paintings.length}/8`); const arts=await Promise.all(batch.map(artworkFor)); for(let i=0;i<batch.length&&paintings.length<8;i++){if(arts[i])paintings.push({artist:batch[i],title:arts[i].title,img:arts[i].img,url:arts[i].url});}} if(paintings.length<5)throw new Error('Not enough paintings'); const nameOf=x=>x.artist.full_name,titleOf=x=>x.title; for(const pt of paintings){const otherA=shuffle(paintings.filter(x=>x!==pt&&nameOf(x)!==nameOf(pt))).slice(0,3); const otherT=shuffle(paintings.filter(x=>x!==pt&&titleOf(x)!==titleOf(pt))).slice(0,3); if(otherA.length<3||otherT.length<3)continue; rounds.push({kind:'lightning',q:'artist',pid:pt.url,painting:pt,correct:nameOf(pt),options:shuffle([nameOf(pt),...otherA.map(nameOf)])}); rounds.push({kind:'lightning',q:'title',pid:pt.url,painting:pt,correct:titleOf(pt),options:shuffle([titleOf(pt),...otherT.map(titleOf)])});} if(rounds.length<8)throw new Error('Not enough distinct paintings'); return rounds; }
+ const cands=shuffle(p); let idx=0; const staged=[];
+ while(staged.length<TOTAL&&idx<cands.length){const batch=cands.slice(idx,idx+6);idx+=6; setPrep(`${staged.length}/10`); const arts=await Promise.all(batch.map(artworkFor)); for(let i=0;i<batch.length&&staged.length<TOTAL;i++){if(arts[i])staged.push({answer:batch[i],artwork:arts[i]});}}
+ if(gameKey==='art'){ for(const s of staged)rounds.push({kind:'art',answer:s.answer,artwork:s.artwork,options:shuffle([s.answer,...distractors(s.answer,p)])}); if(rounds.length<6)throw new Error('Not enough artworks'); return rounds; }
+ for(const s of staged){const ds=distractors(s.answer,p,8); const arts=[]; for(const d of ds){if(arts.length>=3)break; const a=await artworkFor(d); if(a)arts.push({owner:d,art:a});} if(arts.length<3)continue; rounds.push({kind:'match',answer:s.answer,artwork:s.artwork,options:shuffle([{owner:s.answer,art:s.artwork},...arts.slice(0,3)])});}
+ if(rounds.length<6)throw new Error('Not enough artworks'); return rounds.slice(0,TOTAL);
+}
+
+function validate(gameKey, rounds){
+ const errs=[];
+ rounds.forEach((r,idx)=>{
+ if(r.kind==='whose'||r.kind==='art'){ if(r.options.length!==4)errs.push(`r${idx}: ${r.options.length} opts`); if(!r.options.includes(r.answer))errs.push(`r${idx}: answer not in opts`); if(r.kind==='art'&&!r.artwork.img)errs.push(`r${idx}: no artwork img`); if(r.kind==='whose'&&!r.answer.signature_image_url)errs.push(`r${idx}: no sig img`);}
+ else if(r.kind==='century'){ if(r.options.length!==4)errs.push(`r${idx}: ${r.options.length} century opts`); if(!r.options.includes(r.century))errs.push(`r${idx}: century not in opts`);}
+ else if(r.kind==='match'){ if(r.options.length!==4)errs.push(`r${idx}: ${r.options.length} match opts`); if(!r.options.some(o=>o.owner===r.answer))errs.push(`r${idx}: owner missing`); if(r.options.some(o=>!o.art.img))errs.push(`r${idx}: match opt no img`);}
+ else if(r.kind==='early'){ if(!r.leftUrl||!r.rightUrl)errs.push(`r${idx}: missing sig url`); if(!(r.earlier==='left'||r.earlier==='right'))errs.push(`r${idx}: bad earlier`); if(r.years[0]>r.years[1])errs.push(`r${idx}: years unsorted`);}
+ else if(r.kind==='lightning'){ if(r.options.length!==4)errs.push(`r${idx}: ${r.options.length} ln opts`); if(!r.options.includes(r.correct))errs.push(`r${idx}: correct not in opts`); if(!r.painting.img)errs.push(`r${idx}: no painting img`);}
+ });
+ return errs;
+}
+
+const main = async () => {
+ const [sigs, evo] = await Promise.all([
+ fetch(`${BASE}/api/signatures`).then(r=>r.json()),
+ fetch(`${BASE}/api/signature-evolution`).then(r=>r.json()).catch(()=>({})),
+ ]);
+ const ALL = sigs.filter(r=>r.signature_image_url && /wikimedia|upload\./.test(r.signature_image_url));
+ console.log(`catalog: ${ALL.length} sig-image rows, ${Object.keys(evo).length} evolution people\n`);
+ const games=['whose','century','early','art','match','lightning'];
+ let pass=0, fail=0;
+ for(const g of games){
+ const diff = (g==='art'||g==='match'||g==='lightning') ? 'scholar' : 'icons';
+ try{
+ const t0=Date.now();
+ const rounds = await buildRounds(g, diff, 'All', ALL, evo);
+ const errs = validate(g, rounds);
+ const ms = Date.now()-t0;
+ if(errs.length===0){ console.log(`OK ${g.padEnd(9)} @${diff}: ${rounds.length} valid rounds (${ms}ms)`); pass++; }
+ else { console.log(`ERR ${g.padEnd(9)} @${diff}: ${rounds.length} rounds, ${errs.length} errors: ${errs.slice(0,3).join('; ')}`); fail++; }
+ }catch(e){ console.log(`ERR ${g.padEnd(9)} @${diff}: THREW "${e.message}"`); fail++; }
+ }
+ console.log(`\n${pass}/${games.length} games produced valid decks. ${fail?'FAIL':'ALL PASS'}`);
+ process.exit(fail?1:0);
+};
+main();
← 7c16c4c auto-save: 2026-08-06T04:48:33 (8 files) — screenrecord/debu
·
back to CelebritySignatures
·
chore: bump mobile v1.0.0 → v1.1.0 (6-game suite, session cl c148585 →