← back to CelebritySignatures
apps/mobile/scripts/game-engine-harness.mjs
87 lines
// 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();