Spaces:
Running
Running
File size: 33,927 Bytes
f6f0269 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | 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];
|