Build Capital Quest for CoCo 3 with maps and emulator tests

This commit is contained in:
Brian Holman committed 2026-11-09 18:44:56 -07:00
commit 9f7d6f4786
44 files changed
+6238

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import json,re
from pathlib import Path
r=Path(__file__).resolve().parents[1]
states=[line.split('\t') for line in (r/'data/states.tsv').read_text().splitlines()]
assert len(states)==50 and len({s[0] for s in states})==50
assert len({s[1] for s in states})==50 and len({s[2] for s in states})==50
assert dict((s[0],s[2]) for s in states)['AK']=='Juneau'
assert dict((s[0],s[2]) for s in states)['HI']=='Honolulu'
assert all(s[0] not in ('DC','PR') for s in states)
m=json.loads((r/'data/map-manifest.json').read_text())
s=(r/'src/map_data.c').read_text()
for view in ('national','northeast','hawaii'):
data=list(map(int,re.search(view+r'_spans\[\] = \{\s*([\d,\s]+)',s)[1].replace('\n','').split(',')))
off=list(map(int,re.search(view+r'_offsets\[51\] = \{([\d,]+)',s)[1].split(',')))
assert len(off)==51 and off[0]==0 and off[-1]==len(data)
w,h=m[view]['size']; occupied=set()
for i,state in enumerate(states):
area=0
for k in range(off[i],off[i+1],3):
y,x,n=data[k:k+3];assert 0<=y<h and 0<=x<x+n<=w
for xx in range(x,x+n):assert (xx,y) not in occupied;occupied.add((xx,y))
area+=n
assert area==m[view]['pixels'][state[0]]
if view=='national':assert area>0
for code in ('CT','DE','ME','MD','MA','NH','NJ','NY','PA','RI','VT'):assert m['northeast']['pixels'][code]>0
assert m['hawaii']['pixels']['HI']>400
assert all(n==0 for code,n in m['hawaii']['pixels'].items() if code!='HI')
print('PASS: 50 unique pairs and geometry IDs, bounded/disjoint spans, all national masks, Northeast and Hawaii detail coverage.')
+65
View File
@@ -0,0 +1,65 @@
import struct,zlib,re,json,runpy,sys,uuid,os,tempfile
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
EVIDENCE=Path(os.environ['CAPITAL_TEST_EVIDENCE'])
S=json.load(open(os.environ['CAPITAL_TEST_STATE']))
ART=next(Path(tempfile.gettempdir()).glob('vcc-c-project-*/Builds/'+S['artifact']['artifact_id']+'/build/CAPITAL.bin'))
mp=ART.with_suffix('.map').read_text()
addr=int(re.search(r'Symbol: _font5x8 .*?= ([0-9A-Fa-f]+)',mp)[1],16)
blob=ART.read_bytes();memory={};p=0
while p<len(blob):
t=blob[p];n,a=struct.unpack('>HH',blob[p+1:p+5]);p+=5
if t==255:break
for i,b in enumerate(blob[p:p+n]):memory[a+i]=b
p+=n
font=bytes(memory[addr+i] for i in range(768))
def png(path):
b=Path(path).read_bytes();p=8;dat=b''
while p<len(b):
n=int.from_bytes(b[p:p+4],'big');t=b[p+4:p+8];v=b[p+8:p+8+n];p+=n+12
if t==b'IHDR':w,h,depth,ct,*_=struct.unpack('>IIBBBBB',v)
if t==b'IDAT':dat+=v
assert depth==8 and ct in (2,6),(depth,ct)
channels=3 if ct==2 else 4;stride=w*channels;raw=zlib.decompress(dat);out=[];prior=bytearray(stride);p=0
for y in range(h):
f=raw[p];p+=1;row=bytearray(raw[p:p+stride]);p+=stride
for i in range(stride):
a=row[i-channels] if i>=channels else 0;b=prior[i];c=prior[i-channels] if i>=channels else 0
if f==1:pred=a
elif f==2:pred=b
elif f==3:pred=(a+b)//2
elif f==4:
v=a+b-c;pa=abs(v-a);pb=abs(v-b);pc=abs(v-c);pred=a if pa<=pb and pa<=pc else b if pb<=pc else c
else:pred=0
row[i]=(row[i]+pred)&255
out.append(row);prior=row
return w,h,channels,out
class Screen:
def __init__(self,path):self.w,self.h,self.ch,self.rows=png(path)
def pixel(self,x,y):return tuple(self.rows[y][x*self.ch:x*self.ch+3])
def guest(self,x,y):return self.pixel(x*2,50+y*2)
def text(self,x,y,n,color=None):
bg=self.guest(x+5,y);out=''
for j in range(n):
bits=[]
for row in range(8):
for col in range(5):bits.append(self.guest(x+j*6+col,y+row)==color if color else self.guest(x+j*6+col,y+row)!=bg)
scores=[]
for c in range(32,127):
expected=[not(font[(c-32)*8+r]&(128>>co)) for r in range(8) for co in range(5)]
scores.append((sum(a!=b for a,b in zip(bits,expected)),c))
d,c=min(scores);out+=chr(c) if d==0 else '?'
return out.rstrip()
def shuffle(seed=2709):
rng=seed
def rand(n):
nonlocal rng
while True:
x=rng;x^=x<<7;x&=65535;x^=x>>9;x^=x<<8;x&=65535;rng=x;x-=1
if x<65535-65535%n:return x%n
order=list(range(50))
for i in range(49,0,-1):j=rand(i+1);order[i],order[j]=order[j],order[i]
return order,rand
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Run real guest-input regression tests via an exported public VCC Plus grant.
Usage: python3 tests/playtest.py PRIVATE_GRANT --evidence /tmp/quiz-evidence
Requires C Assistant's Use Project Makefile and Allow Disposable Playtests.
"""
import argparse,json,os,runpy,sys,time,uuid
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
from vcc_mcp import VCC
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('grant',type=Path);p.add_argument('--evidence',required=True,type=Path)
a=p.parse_args();out=a.evidence.resolve()
if out.is_relative_to(ROOT):p.error('Keep evidence outside the watched checkout until the run completes.')
out.mkdir(parents=True,exist_ok=True)
os.environ.update(VCC_GRANT=str(a.grant.resolve()),CAPITAL_TEST_EVIDENCE=str(out),CAPITAL_TEST_STATE=str(out/'session.json'))
v=VCC(a.grant)
c=v.call('get_capabilities');assert c.get('owned_playtests'),c
b=v.call('build_project',dict(project_id=c['project_id'],revision=c['revision'],action='disk',configuration='Test',request_id=str(uuid.uuid4())))
assert 'build_id' in b,b
for _ in range(150):
receipt=v.call('get_build',dict(build_id=b['build_id']))
if receipt.get('status')!='building':break
time.sleep(.2)
assert receipt.get('status')=='succeeded',receipt
(out/'build-receipt.json').write_text(json.dumps(receipt,indent=2))
r=v.call('start_playtest',dict(project_id=c['project_id'],revision=c['revision'],artifact_id=receipt['artifact_id'],request_id=str(uuid.uuid4())))
assert 'run_id' in r,r
state=dict(cap=c,build=b,artifact=receipt,run=r)
(out/'session.json').write_text(json.dumps(state))
v.call('playtest_input',dict(run_id=r['run_id'],request_id=str(uuid.uuid4()),steps=[dict(keys=[],fields=600)]))
v.close()
support=runpy.run_path(str(Path(__file__).with_name('playtest_support.py')))
support['capture'](r['run_id'],'title');support['v'].close()
runpy.run_path(str(Path(__file__).with_name('playtest_mc.py')),run_name='__main__')
runpy.run_path(str(Path(__file__).with_name('playtest_typed.py')),run_name='__main__')
state=json.loads((out/'session.json').read_text());v=VCC(a.grant)
for r in (state['run'],state['typed_run']):
result=v.call('stop_playtest',dict(run_id=r['run_id'],request_id=str(uuid.uuid4())))
(out/('retained-'+r['run_id']+'.json')).write_text(json.dumps(result,indent=2))
v.close();print('PASS: complete multiple-choice and typed regression suite. Evidence:',out)
+53
View File
@@ -0,0 +1,53 @@
import os
from pathlib import Path
import sys,runpy,json,re
sys.path.insert(0,str(Path(__file__).parent));from framebuffer import Screen,ROOT,shuffle,EVIDENCE
c=runpy.run_path(str(Path(__file__).with_name('playtest_support.py')));run=json.load(open(os.environ['CAPITAL_TEST_STATE']))['run']['run_id']
rows=[x.split('\t') for x in (ROOT/'data/states.tsv').read_text().splitlines()]
order,_=shuffle();evidence=[];score=0;pos=[0]*4
c['key'](run,'1',8,600)
for q,state in enumerate(order):
name=f'mc-q{q+1:02d}-{rows[state][0]}'
meta=c['capture'](run,name);scr=Screen(str(EVIDENCE)+'/'+name+'.png')
heading=scr.text(8,19,45);prompt=scr.text(8,145,50)
assert heading==f'Question {q+1} of 50 Score {score}',(q,heading,score)
assert prompt==f'{rows[state][1]}: what is its capital?',(q,prompt,rows[state])
opts=[scr.text(8+(i%2)*156,157+(i//2)*12,25)[2:] for i in range(4)]
assert len(set(opts))==4 and rows[state][2] in opts,(q,opts)
correctpos=opts.index(rows[state][2]);pos[correctpos]+=1
# Confirm selected state's actual framebuffer mask has the expected gold fill.
src=(ROOT/'src/map_data.c').read_text();d=list(map(int,re.search(r'national_spans\[\] = \{\s*([\d,\s]+)',src)[1].replace('\n','').split(',')));off=list(map(int,re.search(r'national_offsets\[51\] = \{([\d,]+)',src)[1].split(',')))
points=[(6+x,33+y) for k in range(off[state],off[state+1],3) for y,x0,n in [d[k:k+3]] for x in range(x0,x0+n)]
gold=sum(scr.guest(x,y)==(255,255,0) for x,y in points)
assert gold>=len(points)*.8,(q,rows[state][0],gold,len(points))
right=q%7!=0;selection=correctpos if right else (correctpos+1)%4
if right:score+=1
# Hold the first answer through feedback: it must not advance to question 2.
c['key'](run,str(selection+1),600 if q==0 else 8,450)
feedback=c['capture'](run,name+'-feedback');fs=Screen(str(EVIDENCE)+'/'+name+'-feedback.png')
assert fs.text(8,158,24)==('CORRECT!' if right else 'NOT THIS TIME.'),(q,fs.text(8,158,24))
assert fs.text(8,170,50)==f'The capital is {rows[state][2]}.',(q,fs.text(8,170,50))
evidence.append(dict(question=q+1,code=rows[state][0],state=rows[state][1],options=opts,answer=opts[selection],correct=right,score=score,gold_pixels=gold,expected_state_pixels=len(points),field=meta['field_clock']))
c['key'](run,'space',8,500)
print(q+1,rows[state][0],score,flush=True)
result=c['capture'](run,'mc-results');s=Screen(str(EVIDENCE)+'/mc-results.png')
assert s.text(8,48,45)==f'{score} / 50 correct {score*2}%',s.text(8,48,45)
assert s.text(8,70,45)=='0 correct after spelling correction',s.text(8,70,45)
assert score==42 and all(pos)
# Review all eight misses over three pages, preserving answers.
c['key'](run,'r',8,500)
misses=[x for x in evidence if not x['correct']]
for page in range(3):
c['capture'](run,f'mc-review-{page+1}');s=Screen(f'{EVIDENCE}/mc-review-{page+1}.png')
assert s.text(8,19,30)==f'Review page {page+1}'
for i,m in enumerate(misses[page*3:page*3+3]):
assert s.text(8,39+i*43,45)==m['state']
assert s.text(8,50+i*43,48)==f"You: {m['answer']}"
cap=next(x[2] for x in rows if x[0]==m['code'])
assert s.text(8,61+i*43,48)==f'Capital: {cap}'
if page<2:c['key'](run,'n',8,500)
c['key'](run,'b',8,500);c['key'](run,'p',8,500);c['capture'](run,'replay-title');c['key'](run,'q',8,200);final=c['capture'](run,'mc-exit')
assert 'screen_text' in final or final.get('screen_text_unavailable') is None,final
json.dump(dict(score=score,positions=pos,questions=evidence,run_id=run,artifact=result['artifact_id'],exit=final),open(str(EVIDENCE)+'/mc-report.json','w'),indent=2)
print('PASS complete multiple-choice quiz, all 50 masks, 42/50, review, held-key feedback, replay, exit.',flush=True)
c['v'].close()
+18
View File
@@ -0,0 +1,18 @@
import sys,json,base64,uuid,os
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
from vcc_mcp import VCC
v=VCC(os.environ['VCC_GRANT'])
def call(n,a={}):
r=v.call(n,a)
if r.get('isError'):raise RuntimeError(r)
return r
def capture(run,name):
r=v.request('tools/call',{'name':'capture_playtest','arguments':{'run_id':run}})
meta={}
for c in r['content']:
if c['type']=='image':(Path(os.environ['CAPITAL_TEST_EVIDENCE'])/(name+'.png')).write_bytes(base64.b64decode(c['data']))
if c['type']=='text':meta=json.loads(c['text'])
(Path(os.environ['CAPITAL_TEST_EVIDENCE'])/(name+'.json')).write_text(json.dumps(meta,indent=2));return meta
def inp(run,steps):return call('playtest_input',{'run_id':run,'request_id':str(uuid.uuid4()),'steps':steps})
def key(run,k,hold=8,settle=250):return inp(run,[{'keys':[k],'fields':hold},{'keys':[],'fields':settle}])
+81
View File
@@ -0,0 +1,81 @@
import os
from pathlib import Path
import runpy,json,sys,uuid
from pathlib import Path
sys.path.insert(0,str(Path(__file__).parent));from framebuffer import Screen,ROOT,shuffle,EVIDENCE
c=runpy.run_path(str(Path(__file__).with_name('playtest_support.py')));state=json.load(open(os.environ['CAPITAL_TEST_STATE']))
r=c['call']('start_playtest',dict(project_id=state['cap']['project_id'],revision=state['cap']['revision'],artifact_id=state['artifact']['artifact_id'],request_id=str(uuid.uuid4())))
run=r['run_id'];state['typed_run']=r;Path(os.environ['CAPITAL_TEST_STATE']).write_text(json.dumps(state))
rows=[x.split('\t') for x in (ROOT/'data/states.tsv').read_text().splitlines()];order,_=shuffle();report=[];score=corrections=0
def press(k,hold=4,settle=12):return c['key'](run,k,hold,settle)
def settle():c['inp'](run,[{'keys':[],'fields':600},{'keys':[],'fields':300}])
def cap(name):c['capture'](run,name);return Screen(str(EVIDENCE)+'/'+name+'.png')
def type_text(s):
steps=[]
for ch in s:
keys=['space'] if ch==' ' else ['shift',ch.lower()] if ch.isupper() else [ch]
steps.extend([dict(keys=keys,fields=4),dict(keys=[],fields=12)])
if len(steps)>=60:c['inp'](run,steps);steps=[]
if steps:c['inp'](run,steps)
def clear_input(n):
for start in range(0,n,25):c['inp'](run,[x for _ in range(min(25,n-start)) for x in [dict(keys=['left'],fields=4),dict(keys=[],fields=12)]])
settle();c['key'](run,'2',8,600);settle();c['inp'](run,[dict(keys=['shift','0'],fields=8),dict(keys=[],fields=12)])
# First alias in mixed case. Capture input before submission to verify the real keys.
type_text('sT pAuL');s=cap('typed-alias-input');print('input=',s.text(8,158,45),flush=True)
assert s.text(8,158,45)=='sT pAuL_',s.text(8,158,45)
press('enter',8,600);s=cap('typed-alias-feedback');assert s.text(8,158,30)=='CORRECT!',s.text(8,158,30)
score=1;report.append(dict(question=1,code='MN',answer='sT pAuL',correct=True,correction=False));c['key'](run,'space',8,600);settle()
for q in range(1,50):
st=order[q];code,name,capital=rows[st];s=cap(f'typed-q{q+1:02d}-{code}')
assert s.text(8,19,45)==f'Question {q+1} of 50 Score {score}',(q,s.text(8,19,45),score)
assert s.text(8,145,50)==name+': what is its capital?'
spelling=False;right=True
if q==1 or code=='CO':
# Adjacent transpositions, followed by a real Backspace correction.
answer='Chalreston' if q==1 else 'Dnever';type_text(answer);press('enter',8,600)
s=cap('typed-near-'+code)
assert s.text(8,158,48)=='That looks close. Check your spelling'
assert s.text(8,182,35)=='No point awarded yet.'
assert s.text(8,19,45)==f'Question {q+1} of 50 Score {score}'
c['key'](run,'r',8,600);settle();clear_input(len(answer));type_text(capital);spelling=True
elif q==2:
answer='Austin';type_text(answer);right=False
elif q==3:
answer='X'*39;type_text('X'*45);settle();s=cap('typed-long-input')
assert s.text(8,158,45)=='X'*39+'_',s.text(8,158,45)
assert s.text(8,171,48)=='39 characters maximum. Backspace to edit.'
right=False
elif q==4:
answer='Tallhassee';type_text(answer);press('enter',8,600);s=cap('typed-near-reveal')
assert s.text(8,158,48)=='That looks close. Check your spelling'
c['key'](run,'v',8,600);right=False
elif q==5:
answer=' bAtOn rOuGe ';type_text(answer)
else:
answer=capital.lower() if q%3==0 else capital.upper() if q%3==1 else ''.join(ch.lower() if i%2 else ch.upper() for i,ch in enumerate(capital))
type_text(answer)
if q!=4:press('enter',8,600)
s=cap(f'typed-feedback-{q+1:02d}')
assert s.text(8,158,30)==('CORRECT!' if right else 'NOT THIS TIME.'),(q,s.text(8,158,30),answer)
assert s.text(8,170,50)==f'The capital is {capital}.'
if right:score+=1
if spelling:corrections+=1
report.append(dict(question=q+1,code=code,answer=answer,correct=right,correction=spelling))
c['key'](run,'space',8,600)
if q!=49:settle()
print(q+1,code,score,corrections,flush=True)
s=cap('typed-results');assert s.text(8,48,48)==f'{score} / 50 correct {score*2}%',s.text(8,48,48)
assert s.text(8,70,48)==f'{corrections} correct after spelling correction'
assert score==47 and corrections==2
c['key'](run,'r',8,600);s=cap('typed-review')
misses=[x for x in report if not x['correct']]
for i,m in enumerate(misses):
name,capital=next((r[1],r[2]) for r in rows if r[0]==m['code'])
assert s.text(8,39+i*43,48)==name
assert s.text(8,50+i*43,48)=='You: '+m['answer']
assert s.text(8,61+i*43,48)=='Capital: '+capital
c['key'](run,'b',8,600);c['key'](run,'q',8,300);final=c['capture'](run,'typed-exit')
assert final.get('screen_text_unavailable') is None
json.dump(dict(score=score,corrections=corrections,questions=report,run_id=run,exit=final),open(str(EVIDENCE)+'/typed-report.json','w'),indent=2)
print('PASS full typed quiz: casing, spaces, alias, transpositions, corrected spelling, wrong capital, long input, reveal, score/review/exit.',flush=True);c['v'].close()
+48
View File
@@ -0,0 +1,48 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "quiz.h"
static unsigned index_of(const char *code){unsigned i;for(i=0;i<50;++i)if(!strcmp(states[i].code,code))return i;assert(0);return 0;}
int main(void)
{
unsigned seed,i,j,p,positions[4]={0},last[50],changes=0;
char a[160],b[ANSWER_SIZE];
for(seed=1;seed<=2000;++seed){
unsigned seen[50]={0};quiz_start(seed);
for(i=0;i<50;++i){
assert(order[i]<50);++seen[order[i]];quiz_options(order[i]);p=0;
for(j=0;j<4;++j){unsigned k;assert(options[j]<50);if(options[j]==order[i]){++p;++positions[j];}for(k=0;k<j;++k)assert(options[k]!=options[j]);}
assert(p==1);if(seed>1 && last[i]!=order[i])++changes;last[i]=order[i];
}
for(i=0;i<50;++i)assert(seen[i]==1);
}
for(i=0;i<4;++i)assert(positions[i]>20000);
assert(changes>90000);
for(i=0;i<50;++i){
assert(classify(i,states[i].capital)==2);
strcpy(a,states[i].capital);
for(j=0;a[j];++j)if(a[j]>='a' && a[j]<='z')a[j]-=32;
assert(classify(i,a)==2);
for(j=0;a[j];++j)if((j&1) && a[j]>='A' && a[j]<='Z')a[j]+=32;
assert(classify(i,a)==2);
for(j=0;j<50;++j)if(j!=i)assert(classify(i,states[j].capital)==0);
}
assert(classify(index_of("MN"),"St. Paul")==2);
assert(classify(index_of("MN"),"st paul")==2);
assert(classify(index_of("MN")," sAiNt PaUl ")==2);
assert(classify(index_of("UT")," salt lake city ")==2);
assert(classify(index_of("CO"),"Dnever")==1);
assert(classify(index_of("AK"),"Juneu")==1);
assert(classify(index_of("MA"),"Austin")==0);
assert(classify(index_of("CO"),"")==0);
memset(a,'x',159);a[159]=0;assert(classify(0,a)==0);
normalize(" \t baton ROUGE \n ",b);assert(!strcmp(b,"baton rouge"));
quiz_start(2709);
for(i=0;i<50;++i){quiz_record(order[i],i%2?"wrong":states[order[i]].capital,!(i%2),!(i%4));quiz_record(order[i],"duplicate",1,1);}
assert(score==25 && corrections==13);
for(i=0;i<50;++i)assert(answered[i]==1);
quiz_start(2710);assert(score==0 && corrections==0);
for(i=0;i<50;++i)assert(!answered[i] && !responses[i][0]);
printf("PASS: 2000 shuffles, 100000 option sets, all casing / known-wrong capitals, aliases, distance, bounds, scoring, reset.\n");
return 0;
}