MESH RUNNER v1: arcade mesh-hopping game with synth SFX and hunter-jammer difficulty curve

This commit is contained in:
2026-09-26 20:49:29 +00:00
commit 70c401b37b
3 changed files with 709 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
import re, sys
import py_mini_racer as pyr
html = open('/workspace/mesh-runner/index.html').read()
m = re.search(r'<script>(.*)</script>', html, re.S)
assert m, 'no inline script found'
game_js = m.group(1)
HARNESS = r"""
var ctxCalls = {};
function makeCtx(){
return new Proxy({}, {
get(t, k){
if(k === 'canvas') return {width:960, height:640};
if(typeof k === 'symbol') return undefined;
if(!(k in t)){
if(k === 'fillStyle' || k === 'strokeStyle' || k === 'lineWidth' ||
k === 'font' || k === 'textAlign' || k === 'shadowColor' || k === 'shadowBlur' ||
k === 'globalAlpha'){
t[k] = '';
return t[k];
}
t[k] = function(){ ctxCalls[k] = (ctxCalls[k]||0)+1; return undefined; };
}
return t[k];
},
set(t, k, v){ t[k] = v; return true; }
});
}
var _els = { cv: { id:'cv', width:960, height:640, getContext: function(){ return makeCtx(); } } };
var listeners = {};
var document = {
getElementById: function(id){ return _els[id] || null; },
addEventListener: function(t,f){ (listeners[t]=listeners[t]||[]).push(f); }
};
var _vt = 0; // virtual clock: 16.67ms per frame, deterministic
var performance = { now: function(){ return _vt; } };
var _rafQ = [];
function requestAnimationFrame(cb){ _rafQ.push(cb); return _rafQ.length; }
function frames(n){
for(var i=0;i<n;i++){
_vt += 16.67;
var q=_rafQ; _rafQ=[];
q.forEach(function(cb){ cb(_vt); });
}
}
var localStorage = { _d:{}, getItem:function(k){return this._d[k]===undefined?null:this._d[k];},
setItem:function(k,v){this._d[k]=String(v);} };
var window = {
addEventListener: function(t,f){ (listeners[t]=listeners[t]||[]).push(f); },
localStorage: localStorage,
onerror: null,
__errs: []
};
window.window = window;
function key(type, k){
(listeners[type]||[]).forEach(function(f){ f({key:k, preventDefault:function(){}}); });
}
"""
ctx = pyr.MiniRacer()
try:
ctx.eval(HARNESS)
except Exception as e:
print('HARNESS FAIL:', e); sys.exit(1)
try:
ctx.eval(game_js)
print('LOAD: game script parsed and executed OK')
except Exception as e:
print('PARSE/LOAD FAIL:', e); sys.exit(1)
def ev(expr):
return ctx.eval(expr)
def frames(n):
ctx.eval(f'frames({n})')
def key(t, k):
ctx.eval(f'key({t!r}, {k!r})')
results = []
def check(name, cond, extra=''):
results.append((name, bool(cond)))
print(('PASS ' if cond else 'FAIL ') + name + ((' | '+str(extra)) if extra else ''))
# boot a few frames on title
frames(10)
check('title state', ev('window.__mr.state') == 'title')
check('title drew something', ev('Object.keys(ctxCalls).length') >= 5,
ev('JSON.stringify(Object.keys(ctxCalls))'))
# start with Enter
key('keydown', 'Enter')
frames(5)
check('state -> play after Enter', ev('window.__mr.state') == 'play')
check('battery full-ish', ev('window.__mr.battery') > 90, ev('window.__mr.battery'))
# autopilot soak: autoStep every ~12 frames, top up battery to test scoring not death
for i in range(600):
if i % 12 == 0:
ctx.eval('window.__autoStep();')
if ev('window.__mr.battery') < 40:
ctx.eval('window.window.__mr.battery = 100;')
frames(1)
score1 = ev('window.__mr.score'); wave1 = ev('window.__mr.wave'); cb1 = ev('window.__mr.bestCombo')
check('autopilot scored points', score1 > 0, f'score={score1} wave={wave1} bestCombo={cb1}')
check('autopilot reached wave >= 2', wave1 >= 2, f'wave={wave1}')
# jam mechanic: park a jammer on an edge touching a node, verify jam rises
ctx.eval("""
(function(){
var j = window.__mr.jammers[0];
j.e = window.__mr.edges[0];
j.t = 0.5;
})();
""")
frames(30)
maxjam = ev('Math.max.apply(null,window.__mr.nodes.map(function(n){return n.jam;}))')
check('jammer jams nodes', maxjam > 0.05, f'maxjam={maxjam}')
# battery death path
ctx.eval('window.__mr.battery = 0.5;')
frames(10)
check('battery death -> over', ev('window.__mr.state') == 'over')
check('best saved to localStorage', ev('window.localStorage.getItem("meshrunner_best")') is not None)
# retry
key('keydown', 'r')
frames(5)
check('retry -> play', ev('window.__mr.state') == 'play')
check('score reset', ev('window.__mr.score') == 0)
# hub bank: combo>=3 landing on hub awards bonus
ctx.eval('window.window.__mr.combo = 3; window.__mr.player.node = window.__mr.hub; window.__mr.player.t = 1;')
score_before = ev('window.__mr.score')
frames(3)
score_after = ev('window.__mr.score')
check('hub bank awards combo bonus', score_after > score_before, f'{score_before} -> {score_after}')
check('combo cleared after bank', ev('window.__mr.combo') == 0)
# no JS errors
errs = ev('JSON.stringify(window.__errs)')
check('no JS runtime errors', errs == '[]', errs)
# audio safety: no AudioContext in harness -> sfx must no-op without throwing
check('audio-safe without AudioContext', ev('typeof AudioContext') == 'undefined')
# mute toggle via M
key('keydown', 'm')
check('M mutes', ev('window.__mr.muted') == True)
key('keydown', 'm')
check('M unmutes', ev('window.__mr.muted') == False)
# hunter mechanic: force wave 3 rebuild, expect at least one hunter jammer
ctx.eval('window.__mr.setWave(3,16);')
n_hunters = ev('window.__mr.jammers.filter(function(j){return j.hunter;}).length')
check('hunters spawn from wave 3', n_hunters >= 1, f'hunters={n_hunters}')
# hunter re-targets toward player without crashing
ctx.eval("""
(function(){
var h = window.__mr.jammers.filter(function(j){return j.hunter;})[0];
h.x = 100; h.y = 100;
})();
""")
frames(120)
check('hunter chase runs clean', ev('window.__mr.state') in ('play', 'over'))
passed = sum(1 for _, ok in results if ok)
print(f'\n{passed}/{len(results)} checks passed')
sys.exit(0 if passed == len(results) else 1)