Recursive-Play / engine-spatial.ts
EnvLoopBot's picture
Publish Recursive Play research Space
f6f0269 verified
Raw History Blame Contribute Delete
33.9 kB
import {clone, random, grid, action, type Environment, type GameAction, type Button} from './engine-spec.ts';
// Only the rectangular pixel observation crosses the blind-solver boundary.
// Targets are reachable spatial configurations, never an action sequence or step count.
type XY={x:number;y:number};
type State={level:number;d:any;goal:number[][];past:any[]};
type Design={id:string;name:string;mechanics:string[];variables:string[];novelty:string;
initial:(l:number,r:()=>number)=>any;
move:(d:any,a:GameAction,l:number)=>void;
view:(d:any,l:number)=>number[][];
objective?:(d:any,l:number)=>number[][];
choices:(d:any,l:number)=>GameAction[];
controls?:(l:number)=>Button[];
undo?:Button;
recipe?:(d:any,l:number,r:()=>number)=>GameAction[];
targetValid?:(start:any,target:any,l:number)=>boolean;
};
const dirs:XY[]=[{x:0,y:-1},{x:1,y:0},{x:0,y:1},{x:-1,y:0}];
const mod=(n:number,m:number)=>(n%m+m)%m;
const inside=(p:XY,n:number)=>p.x>=0&&p.y>=0&&p.x<n&&p.y<n;
const eq=(a:any,b:any)=>JSON.stringify(a)===JSON.stringify(b);
const key=(p:XY)=>p.x+','+p.y;
const at=(a:XY,b:XY)=>a.x===b.x&&a.y===b.y;
const click=(x:number,y:number)=>action(6,x+1,y+1);
const point=(a:GameAction):XY=>({x:(a.x??-100)-1,y:(a.y??-100)-1});
const buttons=(xs:number[])=>xs.map(b=>action(b as Button));
function paint(g:number[][],p:XY,c:number){if(g[p.y]&&p.x>=0&&p.x<g[0].length)g[p.y][p.x]=c}
function compose(a:number[][],b:number[][]){const h=Math.max(a.length,b.length)+2,w=a[0].length+b[0].length+5,g=grid(w,h);for(let y=0;y<h;y++)for(let x=0;x<w;x++)if(y===0||y===h-1||x===0||x===a[0].length+1||x===a[0].length+3||x===w-1)g[y][x]=1;for(let y=0;y<a.length;y++)for(let x=0;x<a[0].length;x++)g[y+1][x+1]=a[y][x];for(let y=0;y<b.length;y++)for(let x=0;x<b[0].length;x++)g[y+1][x+a[0].length+4]=b[y][x];return g}
function frame(g:number[][],colors:number[]){const out=g.map(r=>r.slice());out.push(Array(g[0].length).fill(0));out.push(Array.from({length:g[0].length},(_,x)=>colors[x]??0));return out}
function make(design:Design):Environment<State>{
const objective=design.objective??design.view;
const raw=(d:any,a:GameAction,l:number)=>{const out=clone(d);design.move(out,a,l);return out};
const env:Environment<State>={id:design.id,name:design.name,family:'spatial',version:'1.0.0',levels:6,mechanics:design.mechanics,stateVariables:design.variables,novelty:design.novelty,
init(level,seed){if(!Number.isInteger(level)||level<1||level>6)throw Error('level must be 1..6');const r=random((seed^(Number(design.id.slice(1))*7919)^(level*104729))>>>0);for(let attempt=0;attempt<24;attempt++){const start=design.initial(level,r);let d=clone(start);const witness:GameAction[]=[];if(design.recipe){for(const a of design.recipe(d,level,r)){d=raw(d,a,level);witness.push(a)}}else{for(let i=0;i<9+level*4;i++){const candidates=design.choices(d,level).filter(a=>a.button!==design.undo);const offset=Math.floor(r()*candidates.length);let chosen:GameAction|undefined;for(let j=0;j<candidates.length;j++){const a=candidates[(offset+j)%candidates.length],next=raw(d,a,level);if(!eq(d,next)){chosen=a;d=next;break}}if(chosen)witness.push(chosen)}}const goal=objective(d,level);if(!eq(objective(start,level),goal)&&(!design.targetValid||design.targetValid(start,d,level))){let probe=clone(start),end=witness.length;for(let i=0;i<witness.length;i++){probe=raw(probe,witness[i],level);if(eq(objective(probe,level),goal)){end=i+1;break}}return {state:{level,d:start,goal,past:[]},witness:witness.slice(0,end)}}}throw Error(design.id+' failed constructive generation')},
observe(s){return{pixels:compose(design.view(s.d,s.level),s.goal),actions:design.controls?.(s.level)??[1,2,3,4,5,6],level:s.level,status:env.solved(s)?'won':'playing'}},
step(s,a){if(env.solved(s))return clone(s);const controls=design.controls?.(s.level)??[1,2,3,4,5,6];if(!controls.includes(a.button)||a.button===6&&((a.x!==undefined&&!Number.isInteger(a.x))||(a.y!==undefined&&!Number.isInteger(a.y))))return clone(s);const out=clone(s);if(a.button===design.undo){const prev=out.past.pop();if(prev)out.d=prev;return out}const next=raw(out.d,a,out.level);if(!eq(next,out.d)){if(design.undo)out.past.push(clone(out.d));out.d=next}return out},
solved(s){return eq(objective(s.d,s.level),s.goal)}
};return env;
}
function walls(l:number,n=9):XY[]{return l<3?[]:l<5?[{x:4,y:4}]:[{x:4,y:4},{x:2,y:5},{x:6,y:3}]}
function wallGrid(n:number,ws:XY[]){const g=grid(n,n);ws.forEach(p=>paint(g,p,1));return g}
function selectPoints(d:any,a:GameAction,points:XY[],field='sel'){if(a.button===6){const p=point(a),i=points.findIndex(q=>at(p,q));if(i>=0)d[field]=i}}
// a001: rigid bodies preserve their local shape; pivots and collision matter.
function bodyCells(p:any){const shape:XY[]=p.shape;return shape.map(q=>{let x=q.x,y=q.y;for(let i=0;i<p.r;i++)[x,y]=[-y,x];return{x:p.x+x,y:p.y+y}})}
function rigidImage(d:any){const g=wallGrid(9,d.walls);d.pieces.forEach((p:any,i:number)=>bodyCells(p).forEach(q=>paint(g,q,3+i*3)));return g}
const pivot=make({id:'a001',name:'Pivot cargo',mechanics:['rigid translation','pivot rotation','obstacle collision','multiple-body blocking'],variables:['piece poses','selected body','fixed obstacles'],novelty:'Rotating a non-point body changes its occupied cells and the routes available to other bodies.',
initial(l,r){return {sel:0,walls:walls(l),pieces:[{x:2,y:2,r:Math.floor(r()*4),shape:l<2?[{x:0,y:0},{x:1,y:0}]:[{x:0,y:0},{x:1,y:0},{x:0,y:1}]},...(l>=4?[{x:6,y:6,r:0,shape:l<6?[{x:0,y:0},{x:0,y:1}]:[{x:0,y:0},{x:1,y:0},{x:1,y:1}]}]:[])]}},
move(d,a,l){if(a.button===6){const p=point(a),i=d.pieces.findIndex((q:any)=>bodyCells(q).some(c=>at(c,p)));if(i>=0)d.sel=i;return}const p=d.pieces[d.sel],prev=clone(p);if(a.button<=4){p.x+=dirs[a.button-1].x;p.y+=dirs[a.button-1].y}else if(a.button===5&&l>=2)p.r=mod(p.r+1,4);const blocked=[...d.walls,...d.pieces.flatMap((q:any,i:number)=>i===d.sel?[]:bodyCells(q))];if(bodyCells(p).some(q=>!inside(q,9)||blocked.some((b:XY)=>at(q,b))))d.pieces[d.sel]=prev},
objective:rigidImage,view(d){return frame(rigidImage(d),[3+d.sel*3])},choices(d,l){return[...buttons(l<2?[1,2,3,4]:[1,2,3,4,5]),...d.pieces.map((p:XY)=>click(p.x,p.y))]},controls:l=>l<2?[1,2,3,4,6]:[1,2,3,4,5,6]});
// a002: mirror operators act on a pair simultaneously, under a selectable axis.
function mirrorImage(d:any){const g=wallGrid(9,d.walls);d.points.forEach((p:XY,i:number)=>paint(g,p,3+i*4));if(at(d.points[0],d.points[1]))paint(g,d.points[0],12);return g}
const twins=make({id:'a002',name:'Mirrored twins',mechanics:['reflected coupled displacement','movable reference axis','independent collision cancellation','active twin selection'],variables:['two positions','reflection axis','selected twin','obstacles'],novelty:'One input causes opposite signed displacement in another body; later the two motions may be independently blocked.',
initial(l){return{points:[{x:2,y:2},{x:6,y:6}],axis:l===1?0:1,sel:0,walls:l>=3?[{x:3,y:4},{x:5,y:4},...(l>=5?[{x:4,y:2},{x:4,y:6}]:[])]:[]}},
move(d,a,l){if(a.button===6&&l>=4){selectPoints(d,a,d.points);return}if(a.button===5&&l>=2){d.axis=mod(d.axis+1,l>=6?4:2);return}if(a.button>4)return;const v=dirs[a.button-1],ref=d.axis===0?{x:-v.x,y:v.y}:d.axis===1?{x:v.x,y:-v.y}:d.axis===2?{x:v.y,y:v.x}:{x:-v.y,y:-v.x};const vs=d.sel===0?[v,ref]:[ref,v],next=d.points.map((p:XY,i:number)=>({x:p.x+vs[i].x,y:p.y+vs[i].y}));const valid=next.map((p:XY)=>inside(p,9)&&!d.walls.some((w:XY)=>at(p,w)));if(l<3&&!valid.every(Boolean))return;for(let i=0;i<2;i++)if(valid[i])d.points[i]=next[i]},
objective:mirrorImage,view(d){const g=mirrorImage(d);if(at(d.points[0],d.points[1]))paint(g,d.points[0],12);return frame(g,[3+d.sel*4,0,...[0,1,2,3].map(i=>d.axis===i?9:1)])},choices(d,l){return[...buttons(l>=2?[1,2,3,4,5]:[1,2,3,4]),...(l>=4?d.points.map((p:XY)=>click(p.x,p.y)):[])]},controls:l=>l===1?[1,2,3,4]:l<4?[1,2,3,4,5]:[1,2,3,4,5,6]});
// a003: two families of overlapping permutations cannot be optimized independently.
function loomImage(d:any){return d.cells.map((r:number[])=>r.slice())}
const loom=make({id:'a003',name:'Row column loom',mechanics:['cyclic row shift','cyclic column shift','whole-board transpose','checkerboard anchored cells','linked neighboring strips'],variables:['colored matrix','selected intersection','anchor mask'],novelty:'A row operation changes future column membership; high levels add fixed cells and opposite neighboring-strip motion.',
initial(l,r){const n=5,cells=grid(n,n);for(let y=0;y<n;y++)for(let x=0;x<n;x++)cells[y][x]=2+Math.floor(r()*5);return{cells,sel:{x:2,y:2},anchors:l>=4?[{x:0,y:0},{x:4,y:4}]:[]}},
move(d,a,l){if(a.button===6){const p=point(a);if(inside(p,5))d.sel=p;return}if(a.button===5&&l>=3){d.cells=d.cells[0].map((_:any,x:number)=>d.cells.map((row:number[])=>row[x]));return}if(a.button>4)return;const row=a.button<=2,index=row?d.sel.y:d.sel.x,delta=a.button===1||a.button===3?-1:1;const shift=(ix:number,step:number)=>{const ps=Array.from({length:5},(_,i)=>row?{x:i,y:ix}:{x:ix,y:i}).filter(p=>!d.anchors.some((q:XY)=>at(p,q)));const vals=ps.map(p=>d.cells[p.y][p.x]);ps.forEach((p,i)=>d.cells[p.y][p.x]=vals[mod(i-step,ps.length)])};shift(index,delta);if(l>=5)shift(mod(index+(l===6?2:1),5),-delta)},
objective:loomImage,view(d){return frame(loomImage(d),[d.sel.x+2,d.sel.y+2,...d.anchors.map(()=>1)])},choices(d,l){return[...buttons(l>=3?[1,2,3,4,5]:l===1?[1,2]:[1,2,3,4]),...Array.from({length:5},(_,i)=>click(i,mod(i+2,5)))]},controls:l=>l===1?[1,2,6]:l===2?[1,2,3,4,6]:[1,2,3,4,5,6]});
// a004: spatially overlapping rotation windows, versus translations in a003.
function rotateWindow(c:number[][],x:number,y:number,size:number,sign:number){const tmp=Array.from({length:size},(_,j)=>c[y+j].slice(x,x+size));for(let j=0;j<size;j++)for(let i=0;i<size;i++)c[y+j][x+i]=sign>0?tmp[size-1-i][j]:tmp[i][size-1-j]}
const quarters=make({id:'a004',name:'Overlapping turntables',mechanics:['overlapping sub-board rotations','opposite rotation direction','linked opposite window','central interleaving rotation'],variables:['mosaic','rotation direction','linked mode'],novelty:'Rotating intersecting windows moves tiles across both axes and creates noncommuting local permutations.',
initial(l,r){return{cells:Array.from({length:7},()=>Array.from({length:7},()=>2+Math.floor(r()*5))),sign:1,linked:false}},
move(d,a,l){if(a.button===5&&l>=2){d.sign=-d.sign;return}if(a.button===6){if(l===6&&a.x===undefined){rotateWindow(d.cells,2,2,3,d.sign);return}if(l>=4)d.linked=!d.linked;return}if(a.button>4||a.button>2&&l===1)return;const windows=[[0,0],[3,0],[3,3],[0,3]],i=a.button-1;rotateWindow(d.cells,...windows[i] as [number,number],4,d.sign);if(l>=4&&d.linked)rotateWindow(d.cells,...windows[(i+2)%4] as [number,number],4,l>=5?-d.sign:d.sign)},
objective:d=>d.cells,view(d){return frame(d.cells,[d.sign>0?8:9,d.linked?11:1])},choices(d,l){return[...buttons(l===1?[1,2]:l<3?[1,2,5]:l<4?[1,2,3,4,5]:[1,2,3,4,5,6]),...(l===6?[click(0,0)]:[])]},controls:l=>l===1?[1,2]:l===2?[1,2,5]:l===3?[1,2,3,4,5]:[1,2,3,4,5,6]});
// a005: commands are body-relative, and floor transformations change that frame.
function roverImage(d:any){const g=wallGrid(9,d.walls);d.pads.forEach((p:any)=>paint(g,p,p.kind===1?10:11));paint(g,d.p,3);const v=dirs[d.dir];paint(g,{x:d.p.x+v.x,y:d.p.y+v.y},d.hand===1?6:7);d.cargo.forEach((p:XY)=>paint(g,p,12));return g}
const rover=make({id:'a005',name:'Local frame rover',mechanics:['egocentric forward reverse','heading rotation','turntable floor','reflection floor','trail cargo exchange'],variables:['rover position','heading','handedness','cargo','floor transformations'],novelty:'The same forward input has different world displacement after rotation/reflection pads; cargo can only be exchanged from the body-relative rear.',
initial(l){return{p:{x:2,y:2},dir:1,hand:1,walls:l>=3?[{x:4,y:3},{x:4,y:5}]:[],pads:l>=2?[{x:4,y:2,kind:1},...(l>=4?[{x:2,y:4,kind:2}]:[])]:[],cargo:l>=5?[{x:1,y:2},...(l>=6?[{x:6,y:6}]:[])]:[]}},
move(d,a,l){if(a.button===3||a.button===4){d.dir=mod(d.dir+(a.button===3?-1:1)*d.hand,4);return}if(a.button===5&&l>=5){const v=dirs[d.dir],p={x:d.p.x-v.x,y:d.p.y-v.y};if(!inside(p,9)||d.walls.some((q:XY)=>at(q,p)))return;const i=d.cargo.findIndex((q:XY)=>at(q,p));if(i>=0){d.cargo.splice(i,1);d.cargo.push({...d.p});d.p=p}return}if(a.button===6&&l>=6){d.hand=-d.hand;return}if(a.button>2)return;const v=dirs[d.dir],sign=a.button===1?1:-1,p={x:d.p.x+v.x*sign,y:d.p.y+v.y*sign};if(!inside(p,9)||d.walls.some((q:XY)=>at(p,q))||d.cargo.some((q:XY)=>at(p,q)))return;d.p=p;const pad=d.pads.find((q:XY)=>at(p,q));if(pad?.kind===1)d.dir=mod(d.dir+1,4);if(pad?.kind===2){d.hand=-d.hand;d.dir=mod(-d.dir,4)}},
objective:roverImage,view(d){return frame(roverImage(d),[d.hand===1?6:7])},choices(d,l){return buttons(l<5?[1,2,3,4]:l<6?[1,2,3,4,5]:[1,2,3,4,5,6])},controls:l=>l<5?[1,2,3,4]:l<6?[1,2,3,4,5]:[1,2,3,4,5,6]});
// a006: stamping is XOR, so overlaps remove rather than merge colored cells.
function stencilCells(d:any,l:number){let ps:XY[]=l===1?[{x:0,y:0}]:l<4?[{x:0,y:0},{x:1,y:0}]:[{x:0,y:0},{x:1,y:0},{x:0,y:1}];return ps.map(p=>{let x=p.x,y=p.y;for(let k=0;k<d.r;k++)[x,y]=[-y,x];return{x:mod(d.p.x+x,7),y:mod(d.p.y+y,7)}})}
const stencil=make({id:'a006',name:'Parity stencil',mechanics:['XOR stamping','stencil translation','stencil rotation','wrapped support','two-channel ink'],variables:['ink board','stencil location','rotation','active ink channel'],novelty:'A second overlapping stamp erases existing ink; matching a target requires reasoning about parity rather than transporting objects.',
initial(){return{cells:grid(7,7),p:{x:3,y:3},r:0,ink:2}},
move(d,a,l){if(a.button<=4){const v=dirs[a.button-1],p={x:d.p.x+v.x,y:d.p.y+v.y};if(l>=3){p.x=mod(p.x,7);p.y=mod(p.y,7)}if(inside(p,7))d.p=p}else if(a.button===5)stencilCells(d,l).forEach(p=>{d.cells[p.y][p.x]^=d.ink;if(l===6)d.cells[6-p.y][6-p.x]^=d.ink===2?4:2});else if(a.button===6){if(l>=5&&a.x!==undefined){d.ink=d.ink===2?4:2}else if(l>=2)d.r=mod(d.r+1,4)}},
objective:d=>d.cells.map((row:number[])=>row.map(v=>v===0?0:v===2?3:v===4?6:9)),view(d,l){const g=d.cells.map((row:number[])=>row.map(v=>v===0?0:v===2?3:v===4?6:9));stencilCells(d,l).forEach(p=>{if(g[p.y][p.x]===0)g[p.y][p.x]=1});return frame(g,[d.ink===2?3:6,d.r+2])},choices(d,l){return[...buttons(l===1?[1,2,3,4,5]:[1,2,3,4,5,6]),...(l>=5?[click(d.p.x,d.p.y)]:[])]},controls:l=>l===1?[1,2,3,4,5]:[1,2,3,4,5,6]});
// a007: selecting a piece does not let it stop midway through an inertial move.
function slideImage(d:any){const g=wallGrid(9,d.walls);d.brakes.forEach((p:XY)=>paint(g,p,10));d.tokens.forEach((p:XY,i:number)=>paint(g,p,3+i*3));return g}
const ice=make({id:'a007',name:'Inertial docks',mechanics:['slide until collision','movable token as stopper','brake cells','wrapped edge lanes','undo'],variables:['token positions','selection','walls','brakes'],novelty:'A token can stop only at collisions/brakes; arranging another token creates a new stopping location.',
initial(l){return{tokens:[{x:2,y:2},...(l>=2?[{x:6,y:6}]:[]),...(l>=5?[{x:2,y:6}]:[])],sel:0,walls:[{x:4,y:4},...(l>=3?[{x:4,y:1},{x:4,y:7}]:[])],brakes:l>=4?[{x:2,y:4},{x:6,y:4}]:[]}},
move(d,a,l){if(a.button===6){selectPoints(d,a,d.tokens);return}if(a.button>4)return;const v=dirs[a.button-1],p=d.tokens[d.sel],start={...p},seen=new Set<string>([key(p)]);for(let k=0;k<18;k++){let q={x:p.x+v.x,y:p.y+v.y};if(l===6){q={x:mod(q.x,9),y:mod(q.y,9)}}if(seen.has(key(q))){Object.assign(p,start);break}if(!inside(q,9)||d.walls.some((w:XY)=>at(q,w))||d.tokens.some((w:XY,i:number)=>i!==d.sel&&at(q,w)))break;Object.assign(p,q);seen.add(key(q));if(d.brakes.some((b:XY)=>at(p,b)))break}},
objective:slideImage,view(d){return frame(slideImage(d),[3+d.sel*3])},choices:d=>[...buttons([1,2,3,4]),...d.tokens.map((p:XY)=>click(p.x,p.y))],undo:5});
// a008: the whole configuration settles, with color-dependent gravity at high levels.
function gravityImage(d:any){const g=wallGrid(8,d.walls);if(!d.gateOpen)d.gates.forEach((p:XY)=>paint(g,p,11));else d.gates.forEach((p:XY)=>paint(g,p,1));d.tokens.forEach((p:any)=>paint(g,p,p.c));return g}
const gravity=make({id:'a008',name:'Split gravity',mechanics:['global simultaneous settling','particle collision','opposed material gravity','switchable shutter','sticky floor','undo'],variables:['colored particles','gate state','fixed walls','sticky cells'],novelty:'Every particle moves under a global field; later materials fall in opposite directions and gates change their joint packing.',
initial(l){return{tokens:[{x:2,y:2,c:3},{x:5,y:4,c:6},...(l>=2?[{x:2,y:5,c:3}]:[]),...(l>=5?[{x:5,y:2,c:6}]:[])],walls:l>=2?[{x:3,y:3},{x:4,y:3}]:[],gates:l>=4?[{x:3,y:5},{x:4,y:5}]:[],gateOpen:false,sticky:l>=6?[{x:1,y:1},{x:6,y:6}]:[]}},
move(d,a,l){if(a.button===6&&l>=4){if(d.gates.some((g:XY)=>d.tokens.some((p:XY)=>at(p,g))))return;d.gateOpen=!d.gateOpen;return}if(a.button>4)return;const base=dirs[a.button-1];for(let k=0;k<16;k++){let changed=false;const ordered=d.tokens.map((p:any,i:number)=>({p,i})).sort((a:any,b:any)=>(b.p.x*base.x+b.p.y*base.y)-(a.p.x*base.x+a.p.y*base.y));for(const {p} of ordered){if(d.sticky.some((q:XY)=>at(p,q)))continue;const sign=l>=3&&p.c===6?-1:1,q={x:p.x+base.x*sign,y:p.y+base.y*sign};if(!inside(q,8)||d.walls.some((w:XY)=>at(q,w))||!d.gateOpen&&d.gates.some((w:XY)=>at(q,w))||d.tokens.some((w:XY)=>w!==p&&at(q,w)))continue;Object.assign(p,q);changed=true}if(!changed)break}},
objective:gravityImage,view(d){const g=gravityImage(d);d.sticky.forEach((p:XY)=>{if(g[p.y][p.x]===0)paint(g,p,10)});return frame(g,[d.gateOpen?11:1])},choices:(_d,l)=>buttons(l>=4?[1,2,3,4,6]:[1,2,3,4]),undo:5,controls:l=>l<4?[1,2,3,4,5]:[1,2,3,4,5,6]});
// a009: different sculptures can satisfy the same orthogonal silhouettes.
function projections(d:any,l:number){const count=l===1?1:l===6?3:2,g=grid(count*6-1,4);for(let view=0;view<count;view++)for(let x=0;x<5;x++)for(let h=1;h<=3;h++){const ray=Array.from({length:5},(_,k)=>view===0?{x,y:k}:view===1?{x:k,y:x}:{x,y:4-k});const p=ray.find(p=>d.heights[p.y][p.x]>=h);if(p)g[3-h][view*6+x]=l>=4?d.material[p.y][p.x]:3}return g}
const shadows=make({id:'a009',name:'Orthogonal silhouettes',mechanics:['finite cube inventory','height editing','two orthogonal projections','view rotation','colored occlusion','supported construction'],variables:['height field','material field','cube bank','selection'],novelty:'Completion is equality of occluding projections, not equality of the underlying sculpture; depth and inventory create underdetermined spatial constraints.',
initial(l){return{heights:[[0,0,0,0,0],[0,1,0,0,0],[0,0,2,0,0],[0,0,0,1,0],[0,0,0,0,0]],material:Array.from({length:5},(_,y)=>Array.from({length:5},(_,x)=>l>=4&&(x+y)%2?6:3)),bank:9,sel:{x:2,y:2}}},
move(d,a,l){if(a.button===6){const p=point(a);if(inside(p,5))d.sel=p;return}if((a.button===1||a.button===2)&&l>=3){rotateWindow(d.heights,0,0,5,a.button===1?1:-1);rotateWindow(d.material,0,0,5,a.button===1?1:-1);return}const{x,y}=d.sel;if(a.button===3&&d.bank>0&&d.heights[y][x]<3){const h=d.heights[y][x];if(l>=5&&h>0&&!dirs.some(v=>inside({x:x+v.x,y:y+v.y},5)&&d.heights[y+v.y][x+v.x]>=h))return;d.heights[y][x]++;d.bank--}if(a.button===4&&d.heights[y][x]>0){d.heights[y][x]--;d.bank++}if(a.button===5&&l>=4){d.material[y][x]=d.material[y][x]===3?6:3;if(l===6)d.material[4-y][4-x]=d.material[4-y][4-x]===3?6:3}},
objective:projections,view(d,l){const g=grid(11,8);for(let y=0;y<5;y++)for(let x=0;x<5;x++){g[y][x]=d.heights[y][x]===0?0:2+d.heights[y][x];g[y][x+6]=d.material[y][x]}for(let i=0;i<d.bank;i++)g[6+Math.floor(i/11)][i%11]=8;return frame(g,[d.sel.x+2,d.sel.y+2,0,...projections(d,l)[0].slice(0,5)])},choices(d,l){return[...buttons(l<3?[3,4]:l<4?[1,2,3,4]:[1,2,3,4,5]),...Array.from({length:25},(_,i)=>click(i%5,Math.floor(i/5)))]},controls:l=>l<3?[3,4,6]:l===3?[1,2,3,4,6]:[1,2,3,4,5,6]});
// a010: tension transfers motion to the passive endpoint, optionally through a pulley.
const man=(a:XY,b:XY)=>Math.abs(a.x-b.x)+Math.abs(a.y-b.y);
function cableLength(d:any){return d.routed?man(d.nodes[0],d.peg)+man(d.nodes[1],d.peg):man(d.nodes[0],d.nodes[1])}
function tetherImage(d:any){const g=wallGrid(9,d.walls);const draw=(a:XY,b:XY)=>{let p={...a};for(let k=0;k<18&&!at(p,b);k++){if(p.x!==b.x)p.x+=Math.sign(b.x-p.x);else p.y+=Math.sign(b.y-p.y);if(g[p.y][p.x]===0)paint(g,p,2)}};if(d.routed){draw(d.nodes[0],d.peg);draw(d.nodes[1],d.peg)}else draw(d.nodes[0],d.nodes[1]);if(d.peg)paint(g,d.peg,d.routed?10:11);d.nodes.forEach((p:XY,i:number)=>paint(g,p,3+i*3));return g}
const tether=make({id:'a010',name:'Tension and pulley',mechanics:['slack cable','passive endpoint dragging','fixed pulley routing','route disengagement','movable pulley','undo'],variables:['two endpoints','cable capacity','pulley position','route engagement','selection'],novelty:'Pulling one endpoint can move a different object; routing through a pulley changes the direction of that transferred motion.',
initial(l){return{nodes:[{x:2,y:4},{x:6,y:4}],sel:0,length:l<3?4:6,peg:l>=3?{x:4,y:4}:null,routed:l>=3,walls:l>=5?[{x:3,y:2},{x:5,y:6}]:[]}},
move(d,a,l){if(a.button===6){const p=point(a);if(d.peg&&at(p,d.peg)){if(l>=6)d.sel=2;else if(l>=4){d.routed=!d.routed;if(cableLength(d)>d.length)d.routed=!d.routed}return}if(l>=2)selectPoints(d,a,d.nodes);return}if(a.button>4)return;const old=clone(d),v=dirs[a.button-1],active=d.sel===2?d.peg:d.nodes[d.sel];active.x+=v.x;active.y+=v.y;if(!inside(active,9)||d.walls.some((w:XY)=>at(active,w))){Object.assign(d,old);return}for(let k=0;k<20&&cableLength(d)>d.length;k++){const passive=d.nodes[d.sel===0?1:0],target=d.routed?d.peg:d.nodes[d.sel];const dx=target.x-passive.x,dy=target.y-passive.y;if(Math.abs(dx)>=Math.abs(dy)&&dx)passive.x+=Math.sign(dx);else if(dy)passive.y+=Math.sign(dy);else break;if(d.walls.some((w:XY)=>at(passive,w))){Object.assign(d,old);return}}if(cableLength(d)>d.length||at(d.nodes[0],d.nodes[1]))Object.assign(d,old)},
objective:tetherImage,view(d){return frame(tetherImage(d),[d.sel===2?10:3+d.sel*3,...Array(d.length-cableLength(d)).fill(8)])},choices(d,l){return[...buttons([1,2,3,4]),...(l>=2?d.nodes.map((p:XY)=>click(p.x,p.y)):[]),...(l>=4?[click(d.peg.x,d.peg.y)]:[])]},undo:5});
// a011: edges induce interior regions; open strokes do not constitute an enclosure.
const edgeKey=(a:XY,b:XY)=>key(a)<key(b)?key(a)+'|'+key(b):key(b)+'|'+key(a);
function endpoints(e:string):XY[]{return e.split('|').map(p=>{const[x,y]=p.split(',').map(Number);return{x,y}})}
function enclosure(d:any){const g=grid(8,8);for(let ink=0;ink<2;ink++){const es=Object.keys(d.edges[ink]),degrees:Record<string,number>={};es.forEach(e=>endpoints(e).forEach(p=>degrees[key(p)]=(degrees[key(p)]??0)+1));if(Object.values(degrees).some(n=>n%2))continue;for(let y=0;y<8;y++)for(let x=0;x<8;x++){const crossings=es.filter(e=>{const[a,b]=endpoints(e);return a.x===b.x&&a.x>x&&Math.min(a.y,b.y)===y}).length;if(crossings%2)g[y][x]|=ink===0?1:2}}return g.map(r=>r.map(v=>[0,3,6,9][v]))}
const enclosureGame=make({id:'a011',name:'Enclosed regions',mechanics:['edge drawing and erasure','closed-loop interior','pen lifting','reflected dual pen','two-layer region overlap'],variables:['edge sets','pen position','pen engagement','reflection mode','ink layer','blocked vertices'],novelty:'The goal concerns the regions enclosed by a planar edge graph; arbitrary open strokes cannot satisfy it, even when they pass through target cells.',
initial(l){return{p:{x:1,y:1},down:true,mirror:l>=4,ink:0,edges:[{},{}],blocked:l>=3?[{x:4,y:4}]:[]}},
move(d,a,l){if(a.button===5&&l>=2){d.down=!d.down;return}if(a.button===6&&l>=4){if(l===6&&a.x!==undefined)d.ink=1-d.ink;else d.mirror=!d.mirror;return}if(a.button>4)return;const v=dirs[a.button-1],q={x:d.p.x+v.x,y:d.p.y+v.y};if(!inside(q,9)||d.blocked.some((p:XY)=>at(p,q)))return;const pairs:[[XY,XY]]|[XY,XY][]=[[d.p,q]];if(d.mirror)pairs.push([{x:8-d.p.x,y:d.p.y},{x:8-q.x,y:q.y}]);if(d.down)for(const[a,b]of pairs){const k=edgeKey(a,b);if(d.edges[d.ink][k])delete d.edges[d.ink][k];else d.edges[d.ink][k]=1}d.p=q},
objective:enclosure,view(d){const g=grid(17,17),fill=enclosure(d);for(let y=0;y<8;y++)for(let x=0;x<8;x++)g[2*y+1][2*x+1]=fill[y][x];for(let ink=0;ink<2;ink++)for(const e of Object.keys(d.edges[ink])){const[a,b]=endpoints(e);g[a.y+b.y][a.x+b.x]=ink===0?3:6;g[a.y*2][a.x*2]=ink===0?3:6;g[b.y*2][b.x*2]=ink===0?3:6}d.blocked.forEach((p:XY)=>paint(g,{x:p.x*2,y:p.y*2},1));paint(g,{x:d.p.x*2,y:d.p.y*2},12);return frame(g,[d.down?3:1,d.mirror?10:1,d.ink===0?3:6])},
choices:()=>buttons([1,2,3,4,5,6]),recipe(d,l,r){const out:GameAction[]=[],walk=(b:Button,n:number)=>{for(let i=0;i<n;i++)out.push(action(b))};const w=l>=3?2:2+Math.floor(r()*3),h=2+Math.floor(r()*3);walk(2,w);walk(3,h);walk(4,w);walk(1,h);if(l>=5){out.push(action(5));walk(3,5);out.push(action(5));walk(2,2);walk(3,1);walk(4,2);walk(1,1)}if(l===6){out.push(action(5));walk(1,4);out.push(action(5),click(0,0));walk(2,2);walk(3,2);walk(4,2);walk(1,2)}return out},controls:l=>l===1?[1,2,3,4]:l<4?[1,2,3,4,5]:[1,2,3,4,5,6]});
// a012: goals constrain the propagated light, not mirror coordinates.
function rays(d:any){const g=grid(9,9),queue=d.sources.map((p:any)=>({...p})),seen=new Set<string>();for(let n=0;queue.length&&n<600;n++){const p=queue.shift()!,k=[p.x,p.y,p.dir,p.c].join(',');if(seen.has(k)||!inside(p,9)||d.walls.some((w:XY)=>at(p,w)))continue;seen.add(k);g[p.y][p.x]=g[p.y][p.x]&&g[p.y][p.x]!==p.c?9:p.c;const filter=d.filters.find((f:XY)=>at(p,f));if(filter)p.c=p.c===3?6:3;const mirror=d.mirrors.find((m:XY)=>at(p,m));if(mirror){const ref=mirror.r===0?[1,0,3,2][p.dir]:[3,2,1,0][p.dir];if(mirror.split){const v=dirs[p.dir];queue.push({...p,x:p.x+v.x,y:p.y+v.y})}p.dir=ref}const v=dirs[p.dir];queue.push({...p,x:p.x+v.x,y:p.y+v.y})}return g}
const beam=make({id:'a012',name:'Reflected light field',mechanics:['ray reflection','mirror transport','reflection orientation','beam splitting','color filter','two-source interference'],variables:['mirror positions and orientations','source poses','filters','fixed absorbers','selection'],novelty:'Moving or flipping one mirror changes an entire downstream light path, with branching and color mixing at higher levels.',
initial(l,r){const count=l===1?1:l<4?2:3,x=3+Math.floor(r()*2);return{mirrors:[{x,y:5,r:0,split:l>=4},{x,y:2,r:0,split:false},{x:7,y:2,r:1,split:false}].slice(0,count),sel:0,sources:[{x:0,y:4,dir:1,c:3},...(l===6?[{x:8,y:1,dir:3,c:6}]:[])],filters:l>=5?[{x:6,y:4}]:[],walls:l>=3?[{x:7,y:1}]:[]}},
move(d,a){if(a.button===6){selectPoints(d,a,d.mirrors);return}const m=d.mirrors[d.sel];if(a.button===5){m.r=1-m.r;return}if(a.button>4)return;const v=dirs[a.button-1],p={x:m.x+v.x,y:m.y+v.y};if(!inside(p,9)||d.walls.some((q:XY)=>at(q,p))||d.mirrors.some((q:XY,i:number)=>i!==d.sel&&at(q,p))||d.sources.some((q:XY)=>at(q,p))||d.filters.some((q:XY)=>at(q,p)))return;Object.assign(m,p)},
objective:rays,view(d){const g=rays(d);d.walls.forEach((p:XY)=>paint(g,p,1));d.mirrors.forEach((p:any)=>paint(g,p,p.split?12:p.r===0?10:11));d.filters.forEach((p:XY)=>paint(g,p,14));return frame(g,[d.sel+2,d.mirrors[d.sel].r===0?10:11])},choices:d=>[...buttons([1,2,3,4,5]),...d.mirrors.map((p:XY)=>click(p.x,p.y))],recipe(d){return d.mirrors.flatMap((m:XY)=>[click(m.x,m.y),action(4),action(1)])}});
// a013: a visible pixel is determined by transparency and depth order.
function layerCells(p:any){return bodyCells(p)}
function composite(d:any,l:number){const g=grid(9,9);for(const i of d.order)for(const p of layerCells(d.layers[i])){if(d.blend){g[p.y][p.x]^=1<<i}else g[p.y][p.x]=3+i*3}return d.blend?g.map(r=>r.map(v=>v?2+v:0)):g}
const layers=make({id:'a013',name:'Transparent depth layers',mechanics:['transparent masks','planar translation','mask rotation','depth ordering','additive bit-plane compositing'],variables:['mask poses','depth permutation','selected layer','compositing mode'],novelty:'Only the visible composite is constrained; the same mask arrangement can change its result solely by changing which layer is in front.',
initial(l){const count=l===1?1:l<3?2:3;return{layers:[{x:2,y:2,r:0,shape:[{x:0,y:0},{x:1,y:0},{x:0,y:1}]},{x:3,y:3,r:0,shape:[{x:0,y:0},{x:1,y:0},{x:1,y:1}]},{x:4,y:4,r:0,shape:l>=5?[{x:-1,y:0},{x:1,y:0},{x:0,y:-1},{x:0,y:1}]:[{x:0,y:0},{x:1,y:0},{x:0,y:1}]}].slice(0,count),order:Array.from({length:count},(_,i)=>i),sel:0,blend:false}},
move(d,a,l){if(a.button===6){const p=point(a);if(l===6&&p.y===10&&p.x<9){d.blend=!d.blend;return}if(p.x>=10){const i=Math.floor((p.x-10)/10);if(i<d.layers.length){if(i===d.sel&&l>=4){const j=d.order.indexOf(i);d.order.splice(j,1);d.order.unshift(i)}d.sel=i}}else{const i=[...d.order].reverse().find((i:number)=>layerCells(d.layers[i]).some(q=>at(q,p)));if(i!==undefined)d.sel=i}return}const p=d.layers[d.sel],old=clone(p);if(a.button<=4){p.x+=dirs[a.button-1].x;p.y+=dirs[a.button-1].y}else if(a.button===5&&l>=3)p.r=mod(p.r+1,4);if(layerCells(p).some(q=>!inside(q,9)))d.layers[d.sel]=old},
objective:composite,view(d,l){const g=grid(10*(d.layers.length+1)-1,11),main=composite(d,l);for(let y=0;y<9;y++)for(let x=0;x<9;x++)g[y][x]=main[y][x];d.layers.forEach((p:any,i:number)=>layerCells(p).forEach(q=>paint(g,{x:q.x+10*(i+1),y:q.y},3+i*3)));d.order.forEach((i:number,j:number)=>g[10][10+j]=3+i*3);g[10][0]=d.blend?13:1;g[10][3]=3+d.sel*3;return g},choices(d,l){return[...buttons(l>=3?[1,2,3,4,5]:[1,2,3,4]),...d.layers.map((p:XY,i:number)=>click(10*(i+1)+p.x,p.y)),...(l===6?[click(0,10)]:[])]},controls:l=>l<3?[1,2,3,4,6]:[1,2,3,4,5,6]});
// a014: clutch engagement makes ring phases move in different ratios.
const spokes:XY[]=[{x:0,y:-1},{x:1,y:-1},{x:1,y:0},{x:1,y:1},{x:0,y:1},{x:-1,y:1},{x:-1,y:0},{x:-1,y:-1}];
function orbitImage(d:any,l:number){const g=grid(13,13);for(let i=0;i<3;i++){const radius=2+i*2;for(let k=0;k<8;k++)paint(g,{x:6+spokes[k].x*radius,y:6+spokes[k].y*radius},1);const v=spokes[d.phases[i]];paint(g,{x:6+v.x*radius,y:6+v.y*radius},3+i*3);if(l>=5){const v2=spokes[mod(d.phases[i]+3+i,8)];paint(g,{x:6+v2.x*radius,y:6+v2.y*radius},4+i*3)}}if(l>=4)paint(g,{x:6,y:12},11);return g}
const gears=make({id:'a014',name:'Orbital clutch',mechanics:['ring phase rotation','clutched counterrotation','independent ring drive','blocked detent','intermittent tooth engagement','variable gear ratio'],variables:['three phases','clutch mode','detent location'],novelty:'Inputs act on coupled cyclic coordinates, with gearing, disengagement, and intermittent teeth changing the motion transferred to other rings.',
// With the initial clutch disengaged, one repeated control can change only one ring.
// Final-level requests therefore require a physical change in at least two rings.
targetValid:(start,target,l)=>l<6||target.phases.filter((v:number,i:number)=>v!==start.phases[i]).length>=2,
initial(){return{phases:[0,2,6],clutch:0}},
move(d,a,l){if(a.button===5&&l>=2){d.clutch=mod(d.clutch+1,l===6?3:2);return}const old=d.phases.slice(),i=a.button<=2?0:a.button<=4?1:a.button===6?2:-1;if(i<0||i===1&&l<3||i===2&&l<4)return;const sign=a.button===2||a.button===4||a.button===6&&a.x!==undefined?-1:1;d.phases[i]=mod(d.phases[i]+sign,8);if(d.clutch&&i<2){const engaged=l<5||Math.min(old[i],d.phases[i])%2===0;if(engaged)d.phases[i+1]=mod(d.phases[i+1]-sign*(d.clutch===2?2:1),8);if(i===0&&l>=3&&d.clutch===2)d.phases[2]=mod(d.phases[2]+sign,8)}if(l>=4&&d.phases[2]===4)d.phases=old},
objective:orbitImage,view(d,l){return frame(orbitImage(d,l),[d.clutch===0?1:d.clutch===1?10:14])},choices(_d,l){return[...buttons(l===1?[1,2]:l===2?[1,2,5]:l===3?[1,2,3,4,5]:[1,2,3,4,5,6]),...(l>=4?[click(0,0)]:[])]},controls:l=>l===1?[1,2]:l===2?[1,2,5]:l===3?[1,2,3,4,5]:[1,2,3,4,5,6]});
// a015: crossing a seam transforms coordinates, orientation, and handedness.
function seamImage(d:any){const g=grid(21,21);d.walls.forEach((p:XY)=>{for(let y=0;y<3;y++)for(let x=0;x<3;x++)paint(g,{x:p.x*3+x,y:p.y*3+y},1)});d.units.forEach((p:any,i:number)=>{const c={x:p.x*3+1,y:p.y*3+1},v=dirs[p.dir];paint(g,c,3+i*3);paint(g,{x:c.x+v.x,y:c.y+v.y},3+i*3);paint(g,{x:c.x+v.y*p.hand,y:c.y-v.x*p.hand},8+i)});return g}
const seams=make({id:'a015',name:'Twisted seams',mechanics:['periodic horizontal boundary','reflected seam coordinates','handedness reversal','alternative seam gluing','quarter-turn vertical seam','seam-coupled companion'],variables:['oriented unit poses','handedness','active chart','selected unit','obstacles'],novelty:'Crossing an edge changes both position and the reference frame; the board has glued boundaries rather than ordinary Euclidean adjacency.',
initial(l){return{units:[{x:3,y:3,dir:1,hand:1},...(l>=5?[{x:5,y:5,dir:3,hand:1}]:[])],sel:0,chart:0,walls:l>=5?[{x:2,y:2},{x:4,y:4}]:[]}},
move(d,a,l){if(a.button===5&&l>=3){d.chart=1-d.chart;return}if(a.button===6&&l>=5){const p=point(a),i=d.units.findIndex((q:XY)=>Math.floor(p.x/3)===q.x&&Math.floor(p.y/3)===q.y);if(i>=0)d.sel=i;return}if(a.button>4)return;const old=clone(d.units),p=d.units[d.sel],v=dirs[a.button-1];p.x+=v.x;p.y+=v.y*p.hand;p.dir=a.button-1;let crossed=false;if(p.x<0||p.x>6){p.x=mod(p.x,7);crossed=true;if(l>=2){if(d.chart===0){p.y=6-p.y;p.hand=-p.hand;p.dir=mod(2-p.dir,4)}else p.y=mod(p.y+3,7)}}if(p.y<0||p.y>6){if(l<4){d.units=old;return}const y=p.y;p.y=mod(p.x,7);p.x=y<0?6:0;p.dir=mod(p.dir+1,4);crossed=true}if(l===6&&crossed){const other=d.units[1-d.sel];other.x=mod(other.x+1,7);other.y=6-other.y;other.hand=-other.hand;other.dir=mod(2-other.dir,4)}if(d.units.some((q:XY)=>d.walls.some((w:XY)=>at(q,w)))||d.units.length>1&&at(d.units[0],d.units[1]))d.units=old},
objective:seamImage,view(d){return frame(seamImage(d),[d.chart===0?10:11,3+d.sel*3])},choices(d,l){return[...buttons(l<3?[1,2,3,4]:[1,2,3,4,5]),...(l>=5?d.units.map((p:XY)=>click(p.x*3+1,p.y*3+1)):[])]},controls:l=>l<3?[1,2,3,4]:l<5?[1,2,3,4,5]:[1,2,3,4,5,6]});
export const environments:Environment[]=[pivot,twins,loom,quarters,rover,stencil,ice,gravity,shadows,tether,enclosureGame,beam,layers,gears,seams];