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.xJSON.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.xr.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{ 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={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;jpaint(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;ibodyCells(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=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;j0?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=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([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;viewview===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;iclick(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){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={};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=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();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<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=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];