File size: 35,506 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
162
163
164
import {type Environment,type GameAction,type PixelObservation,random,clone,grid,action} from './engine-spec.ts';

/* Author-only constructors. None of these strings or structures enter observations. */
type P=[number,number];
type S={level:number;n:number;v:number[];d:any;sel:number;pick:number};
type Rule={name:string;mechanics:string[];novelty:string;make:(l:number,r:()=>number)=>S;goal:(s:S)=>boolean;draw:(s:S,p:number[][])=>void;change:(s:S,i:number,dir:number)=>void;positions?:(s:S)=>P[];custom?:(s:S,a:GameAction)=>void;seed?:(s:S,r:()=>number)=>GameAction[]};
const C={off:1,line:7,good:11,bad:8};
const mod=(x:number,n:number)=>(x%n+n)%n;
const sum=(a:number[])=>a.reduce((x,y)=>x+y,0);
const eq=(a:any,b:any)=>JSON.stringify(a)===JSON.stringify(b);
const rngInt=(r:()=>number,n:number)=>Math.floor(r()*n);
function shuffle<T>(a:T[],r:()=>number){for(let i=a.length-1;i>0;i--){let j=rngInt(r,i+1);[a[i],a[j]]=[a[j],a[i]];}return a;}
const base=(l:number,v:number[],d:any={}):S=>({level:l,n:v.length,v,d,sel:0,pick:-1});
const positions=(s:S):P[]=>s.v.map((_,i)=>[5+8*(i%7),6+13*Math.floor(i/7)]);
function dot(p:number[][],x:number,y:number,c:number){if(y>=0&&y<p.length&&x>=0&&x<p[0].length)p[y][x]=mod(c,16);}
function rect(p:number[][],x:number,y:number,w:number,h:number,c:number,fill=false){for(let yy=0;yy<h;yy++)for(let xx=0;xx<w;xx++)if(fill||!xx||!yy||xx===w-1||yy===h-1)dot(p,x+xx,y+yy,c);}
function line(p:number[][],a:P,b:P,c:number){let dx=b[0]-a[0],dy=b[1]-a[1],n=Math.max(Math.abs(dx),Math.abs(dy));for(let t=0;t<=n;t++)dot(p,a[0]+Math.round(dx*t/(n||1)),a[1]+Math.round(dy*t/(n||1)),c);}
function tally(p:number[][],x:number,y:number,n:number,c=7,max=14){for(let k=0;k<Math.min(n,max);k++)dot(p,x+k%7,y+Math.floor(k/7)*2,c);}
function identity(p:number[][],x:number,y:number,i:number){rect(p,x-2,y-2,5,5,2+mod(i,12));}
function node(p:number[][],at:P,i:number,v:number,selected:boolean){identity(p,at[0],at[1],i);rect(p,at[0]-1,at[1]-1,3,3,2+mod(v,12),true);if(selected)rect(p,at[0]-3,at[1]-3,7,7,15);}
function indicator(p:number[][],x:number,y:number,ok:boolean){rect(p,x,y,2,2,ok?C.good:C.bad,true);}
function link(p:number[][],x:number,y:number,a:number,b:number,kind:number,ok:boolean,k=0){identity(p,x+2,y+2,a);identity(p,x+20,y+2,b);line(p,[x+5,y+2],[x+17,y+2],7);if(kind===0){dot(p,x+11,y+1,0);dot(p,x+11,y+3,0);}if(kind===1){line(p,[x+14,y],[x+17,y+2],7);line(p,[x+14,y+4],[x+17,y+2],7);}if(kind===2)rect(p,x+9,y,5,5,7);if(kind===3){line(p,[x+9,y],[x+13,y+4],8);line(p,[x+13,y],[x+9,y+4],8);}if(k)tally(p,x+7,y+6,k,14,9);indicator(p,x+25,y+1,ok);}
function rules(p:number[][],rs:{a:number;b:number;k?:number;t:number;ok:boolean}[],y=22){rs.slice(0,12).forEach((q,i)=>link(p,2+(i%2)*30,y+Math.floor(i/2)*6,q.a,q.b,q.t,q.ok,q.k));}
function masks(p:number[][],m:number[][],y=14){m.forEach((a,i)=>a.forEach((v,j)=>dot(p,3+8*(i%7)+j,y+Math.floor(i/7)*13,2+v)));}
function drawNodes(s:S,p:number[][],pos=positions(s)){s.v.forEach((v,i)=>node(p,pos[i],i,v,s.sel===i));}
function locate(s:S,a:GameAction,pos:P[]){if(!Number.isInteger(a.x)||!Number.isInteger(a.y))return -1;return pos.findIndex(([x,y])=>Math.abs(x-a.x!)<=3&&Math.abs(y-a.y!)<=3);}
function edit(s:S,a:GameAction,r:Rule){const pos=(r.positions??positions)(s);if(r.custom){r.custom(s,a);return;}if(a.button===6){let i=locate(s,a,pos);if(i>=0)s.sel=i;}else if(a.button===1)s.sel=mod(s.sel-1,s.n);else if(a.button===2)s.sel=mod(s.sel+1,s.n);else if(a.button===3||a.button===4)r.change(s,s.sel,a.button===3?1:-1);}
function click(s:S,r:Rule,i:number){let [x,y]=(r.positions??positions)(s)[i];return action(6,x,y);}
function breakPermutation(s:S,r:Rule,ops:number[]){if(!r.goal(s))return;let target:number[]|undefined;const trial=clone(s);function visit(v:number[],rest:number[]){if(target)return;if(!rest.length){trial.v=v;if(!r.goal(trial))target=v;return;}for(let i=0;i<rest.length&&!target;i++)visit([...v,rest[i]],rest.filter((_,j)=>j!==i));}visit([],Array.from({length:s.n},(_,i)=>i));if(!target)throw Error(r.name+' has no failing permutation');for(let i=0;i<s.n;i++){let j=s.v.indexOf(target[i]);while(j>i){r.change(s,j-1,1);ops.push(j-1);j--;}}}
function env(id:string,r:Rule):Environment<S>{return{id,name:r.name,family:'logic',version:'1.0.0',levels:6,mechanics:r.mechanics,stateVariables:['object attributes','visible relations','selection','constraint parameters'],novelty:r.novelty,
  init(level,seed){if(!Number.isInteger(level)||level<1||level>6)throw Error('level');let rand=random(seed),s=r.make(level,rand),w:GameAction[]=[];
    if(r.seed)w=r.seed(s,rand);else{if(!r.goal(s))throw Error(id+' construction');const undo:GameAction[][]=[];for(let j=0;j<5+level*2;j++){let i=rngInt(rand,s.n);r.change(s,i,1);undo.push([click(s,r,i),action(4)]);}for(let i=0;r.goal(s)&&i<s.n*2;i++){let j=i%s.n;r.change(s,j,1);undo.push([click(s,r,j),action(4)]);}for(let i=0;r.goal(s)&&i<s.n;i++){let old=clone(s),extra:GameAction[][]=[];for(let j=0;j<12&&r.goal(s);j++){r.change(s,i,1);extra.push([click(s,r,i),action(4)]);}if(r.goal(s))s=old;else undo.push(...extra);}w=undo.reverse().flat();}
    s.sel=0;s.pick=-1;if(r.goal(s))throw Error(id+' initially solved');return{state:s,witness:w};},
  step(state,a){const s=clone(state);if(![1,2,3,4,5,6].includes(a.button))return s;edit(s,a,r);return s;},
  observe(s):PixelObservation{let p=grid(64,64);r.draw(s,p);return{pixels:p,actions:r.custom?[1,2,4,6]:[1,2,3,4,6],level:s.level,status:r.goal(s)?'won':'playing'};},solved:r.goal};}

// 1. Colors are freely editable; relations and population constraints compete.
const chromatic:Rule={name:'Chromatic relations',mechanics:['vertex recoloring','equality and inequality','color population quotas','local palettes'],novelty:'A relational coloring CSP with equality links and population constraints.',
 make(l,r){let n=l<3?4:6,k=l===1?2:3,v=Array.from({length:n},(_,i)=>i%k),edges:any[]=[];for(let i=0;i<n-1;i++)edges.push([i,i+1,false]);if(l>=3)edges.push([0,3,true]);if(l>=5)edges.push([1,5,false],[0,2,false]);let allow=v.map(x=>l>=6?shuffle([x,mod(x+1,k)],r):Array.from({length:k},(_,j)=>j));return base(l,v,{k,edges,allow,quota:l>=4?Array.from({length:k},(_,j)=>v.filter(x=>x===j).length):null});},
 goal(s){return s.d.edges.every(([a,b,e]:any)=>(s.v[a]===s.v[b])===e)&&s.v.every((v,i)=>s.d.allow[i].includes(v))&&(!s.d.quota||s.d.quota.every((q:number,i:number)=>s.v.filter(x=>x===i).length===q));},
 change(s,i,d){s.v[i]=mod(s.v[i]+d,s.d.k);},
 draw(s,p){drawNodes(s,p);masks(p,s.d.allow);rules(p,s.d.edges.map(([a,b,e]:any)=>({a,b,t:e?0:3,ok:(s.v[a]===s.v[b])===e})));if(s.d.quota)s.d.quota.forEach((q:number,i:number)=>{rect(p,3+i*19,58,3,3,i+2,true);tally(p,8+i*19,57,q,7);tally(p,8+i*19,60,s.v.filter(x=>x===i).length,11);});}};

// 2. Persistent identities move through positions; constraints refer to identities.
const precedence:Rule={name:'Precedence braid',mechanics:['adjacent transpositions','partial order','distance constraints','fixed positions','alternating lanes'],novelty:'Permutation scheduling combines precedence, adjacency, spacing and anchors.',
 make(l,r){let n=l<3?5:7,v=shuffle(Array.from({length:n},(_,i)=>i),r),rel:any[]=[];for(let i=0;i<(l===1?2:n-1);i++)rel.push([v[i],v[i+1],0,1]);if(l>=3)rel.push([v[1],v[2],1,1]);if(l>=4)rel.push([v[0],v[n-1],2,n-2]);return base(l,v,{rel,anchor:l>=5?[v[2],2]:null,parity:l>=6?[v[4],0]:null});},
 goal(s){let at=(x:number)=>s.v.indexOf(x);return s.d.rel.every(([a,b,t,k]:any)=>t===0?at(a)<at(b):t===1?Math.abs(at(a)-at(b))===k:Math.abs(at(a)-at(b))>=k)&&(!s.d.anchor||at(s.d.anchor[0])===s.d.anchor[1])&&(!s.d.parity||at(s.d.parity[0])%2===s.d.parity[1]);},
 change(s,i,d){let j=mod(i+(d>0?1:-1),s.n);[s.v[i],s.v[j]]=[s.v[j],s.v[i]];},
 seed(s,r){let w:GameAction[]=[];const pairs:number[]=[];for(let j=0;j<8+s.level;j++){let i=rngInt(r,s.n-1);precedence.change(s,i,1);pairs.push(i);}breakPermutation(s,precedence,pairs);for(let i of pairs.reverse())w.push(click(s,precedence,i+1),action(4));return w;},
 draw(s,p){s.v.forEach((v,i)=>{identity(p,5+8*i,6,v);if(s.sel===i)rect(p,2+8*i,3,7,7,15);dot(p,5+8*i,13,7);if(s.level>=6)dot(p,5+8*i,15,2+i%2);});rules(p,s.d.rel.map(([a,b,t,k]:any)=>({a,b,t:t===0?1:t===1?0:2,k:t? k:0,ok:t===0?s.v.indexOf(a)<s.v.indexOf(b):t===1?Math.abs(s.v.indexOf(a)-s.v.indexOf(b))===k:Math.abs(s.v.indexOf(a)-s.v.indexOf(b))>=k})));if(s.d.anchor){let[a,x]=s.d.anchor;identity(p,7,58,a);rect(p,15+x*5,55,5,6,14);}if(s.d.parity){identity(p,48,58,s.d.parity[0]);dot(p,56,58,2);}}};

// 3. Rewiring preserves a bijection; acceptable resource sets are public.
const matching:Rule={name:'Exclusive correspondences',mechanics:['bijection-preserving exchange','bipartite eligibility','pair compatibility','resource ordering'],novelty:'Ownership exchange under overlapping eligibility and pair constraints.',
 make(l,r){let n=l<3?4:6,v=shuffle(Array.from({length:n},(_,i)=>i),r),allow=v.map((x,i)=>shuffle(Array.from(new Set([x,mod(x+1,n),...(l<4?[mod(x+2,n)]:[])])),r)),rel:any[]=[];if(l>=2)rel.push([0,1,Math.abs(v[0]-v[1])%2,0]);if(l>=4)rel.push([2,3,v[2]<v[3]?1:-1,1]);if(l>=5)rel.push([4,5,Math.abs(v[4]-v[5]),2]);return base(l,v,{allow,rel,quota:l>=6?v.slice(0,3).filter(x=>x<3).length:null});},
 goal(s){return new Set(s.v).size===s.n&&s.v.every((v,i)=>s.d.allow[i].includes(v))&&s.d.rel.every(([a,b,k,t]:any)=>t===0?Math.abs(s.v[a]-s.v[b])%2===k:t===1?(s.v[a]<s.v[b]?1:-1)===k:Math.abs(s.v[a]-s.v[b])===k)&&(s.d.quota===null||s.v.slice(0,3).filter(x=>x<3).length===s.d.quota);},
 change(s,i,d){let j=mod(i+(d>0?1:-1),s.n);[s.v[i],s.v[j]]=[s.v[j],s.v[i]];},
 seed(s,r){let undo:number[]=[];for(let j=0;j<7+s.level;j++){let i=rngInt(r,s.n-1);matching.change(s,i,1);undo.push(i);}breakPermutation(s,matching,undo);return undo.reverse().flatMap(i=>[click(s,matching,i+1),action(4)]);},
 draw(s,p){drawNodes(s,p);s.d.allow.forEach((a:number[],i:number)=>a.forEach((x,j)=>rect(p,3+8*i,14+j*3,4,2,x+2,true)));rules(p,s.d.rel.map(([a,b,k,t]:any)=>({a:t===1&&k<0?b:a,b:t===1&&k<0?a:b,k:t===1?0:k,t:t===1?1:t===2?2:0,ok:t===0?Math.abs(s.v[a]-s.v[b])%2===k:t===1?(s.v[a]<s.v[b]?1:-1)===k:Math.abs(s.v[a]-s.v[b])===k})),32);if(s.d.quota!==null){rect(p,2,54,23,8,14);[0,1,2].forEach((x,i)=>dot(p,32+i*3,56,x+2));tally(p,44,56,s.d.quota,7);tally(p,44,59,s.v.slice(0,3).filter(x=>x<3).length,11);}}};

// 4. Moving a piece changes several bucket totals at once.
const scales:Rule={name:'Coupled balances',mechanics:['weighted partition','simultaneous bucket totals','composition constraints','pair separation'],novelty:'Conserved weighted pieces couple quantity, composition and exclusion.',
 make(l,r){let k=l>=3?3:2,w=l===1?[1,1,1,1,1,1]:[1,3,1,3,2,2],v=k===2?[0,0,1,1,0,1]:[0,0,1,1,2,2],color=k===2?[0,1,0,1,0,1]:[0,1,0,1,0,1],target=Array.from({length:k},(_,b)=>sum(w.filter((_,i)=>v[i]===b)));let perm=shuffle([0,1,2,3,4,5],r);return base(l,perm.map(i=>v[i]),{k,w:perm.map(i=>w[i]),color:perm.map(i=>color[i]),target,heavy:l>=5?Array.from({length:k},(_,b)=>w.filter((x,i)=>v[i]===b&&x>=3).length):null,separate:l>=6?[perm.indexOf(0),perm.indexOf(2)]:null});},
 goal(s){return s.d.target.every((q:number,b:number)=>sum(s.d.w.filter((_:number,i:number)=>s.v[i]===b))===q)&&(s.level<4||Array.from({length:s.d.k},(_,b)=>b).every(b=>[0,1].every(c=>s.v.some((x,i)=>x===b&&s.d.color[i]===c))))&&(!s.d.heavy||s.d.heavy.every((q:number,b:number)=>s.v.filter((x,i)=>x===b&&s.d.w[i]>=3).length===q))&&(!s.d.separate||s.v[s.d.separate[0]]!==s.v[s.d.separate[1]]);},
 change(s,i,d){s.v[i]=mod(s.v[i]+d,s.d.k);},
 draw(s,p){drawNodes(s,p);s.d.w.forEach((w:number,i:number)=>{tally(p,3+8*i,13,w,7);if(s.level>=4)rect(p,3+8*i,17,4,2,12+s.d.color[i],true);});for(let b=0;b<s.d.k;b++){let y=25+b*10;rect(p,2,y,5,7,b+2);tally(p,10,y,s.d.target[b],7);tally(p,10,y+3,sum(s.d.w.filter((_:number,i:number)=>s.v[i]===b)),11);if(s.d.heavy){rect(p,32,y,3,3,14);tally(p,38,y,s.d.heavy[b],7);tally(p,38,y+3,s.v.filter((x,i)=>x===b&&s.d.w[i]>=3).length,11);}}if(s.d.separate)rules(p,[{a:s.d.separate[0],b:s.d.separate[1],t:3,ok:s.v[s.d.separate[0]]!==s.v[s.d.separate[1]]}],57);}};

function distances(n:number,e:number[][],v:number[]){let a:number[][]=Array.from({length:n},(_,i)=>Array.from({length:n},(_,j)=>i===j?0:99));e.forEach(([i,j],k)=>{if(v[k])a[i][j]=a[j][i]=1;});for(let k=0;k<n;k++)for(let i=0;i<n;i++)for(let j=0;j<n;j++)a[i][j]=Math.min(a[i][j],a[i][k]+a[k][j]);return a;}
// 5. The same edge affects every transitive relationship.
const connectivity:Rule={name:'Transitive islands',mechanics:['edge switching','transitive connectivity','separation','acyclic edge budget','path and degree bounds'],novelty:'A reachability puzzle with global consequences from each edge.',
 positions(s){return s.v.map((_,i)=>[5+(i%6)*10,25+Math.floor(i/6)*10]);},
 make(l,r){let n=l<3?4:6,groups=Array.from({length:n},(_,i)=>l>=2&&i>=n-2?1:0),e:number[][]=[];for(let a=0;a<n;a++)for(let b=a+1;b<n;b++)e.push([a,b]);let v=e.map(([a,b])=>b===a+1&&groups[a]===groups[b]?1:0);let rel:any[]=[];for(let i=1;i<n;i++)rel.push([i-1,i,groups[i-1]===groups[i]]);return base(l,v,{nodes:n,e,rel,limit:l>=3?sum(v):null,path:l>=4?[0,n-3,n-3]:null,degree:l>=5?2:null,bridge:l>=6?[0,2]:null});},
 goal(s){let ds=distances(s.d.nodes,s.d.e,s.v),deg=Array(s.d.nodes).fill(0);s.d.e.forEach(([a,b]:number[],i:number)=>{if(s.v[i]){deg[a]++;deg[b]++;}});return s.d.rel.every(([a,b,c]:any)=>(ds[a][b]<99)===c)&&(s.d.limit===null||sum(s.v)<=s.d.limit)&&(!s.d.path||ds[s.d.path[0]][s.d.path[1]]<=s.d.path[2])&&(s.d.degree===null||deg.every(x=>x<=s.d.degree))&&(!s.d.bridge||ds[s.d.bridge[0]][s.d.bridge[1]]===2);},
 change(s,i){s.v[i]=1-s.v[i];},
 draw(s,p){const ps:P[]=Array.from({length:s.d.nodes},(_,i)=>[5+9*i,6]);s.d.e.forEach(([a,b]:number[],i:number)=>{if(s.v[i])line(p,ps[a],ps[b],9);});ps.forEach((q,i)=>identity(p,q[0],q[1],i));const ds=distances(s.d.nodes,s.d.e,s.v);s.d.rel.forEach(([a,b,c]:any,i:number)=>{let x=2+i*10;dot(p,x,13,a+2);dot(p,x+4,13,b+2);dot(p,x+2,12,c?7:8);indicator(p,x+1,16,(ds[a][b]<99)===c);});let pos=connectivity.positions!(s);s.v.forEach((v,i)=>{let[x,y]=pos[i];rect(p,x-3,y-3,7,7,i===s.sel?15:1);dot(p,x-2,y,s.d.e[i][0]+2);dot(p,x+2,y,s.d.e[i][1]+2);line(p,[x-1,y],[x+1,y],v?11:7);});if(s.d.limit!==null){tally(p,3,58,s.d.limit,7);tally(p,3,61,sum(s.v),11);}if(s.d.path){dot(p,22,58,s.d.path[0]+2);dot(p,32,58,s.d.path[1]+2);tally(p,25,61,s.d.path[2],14);}if(s.d.degree)rect(p,39,56,5,5,14);if(s.d.bridge){dot(p,50,58,s.d.bridge[0]+2);dot(p,58,58,s.d.bridge[1]+2);dot(p,54,58,14);}}};

// 6. Selected nodes cover neighbors, consume a budget and conflict with one another.
const coverage:Rule={name:'Independent sentinels',mechanics:['neighborhood coverage','selection conflict','weighted budget','redundancy','forbidden nodes'],novelty:'Covering, independence and redundancy pull a shared selection in different directions.',
 make(l,r){let n=7,e:number[][]=[];for(let i=0;i<6;i++)e.push([i,i+1]);if(l>=3)e.push([0,3],[3,6]);let v=[0,1,0,1,0,1,0],cost=Array.from({length:n},()=>1+rngInt(r,3)),need=Array(n).fill(1);if(l>=5){need[2]=2;need[4]=2;}return base(l,v,{e,cost,need,budget:l>=3?sum(cost.filter((_,i)=>v[i])):null,forbid:l>=6?[0,6]:[],split:l>=4});},
 goal(s){let counts=s.v.map((x,i)=>x+s.d.e.reduce((z:number,[a,b]:number[])=>z+(a===i?s.v[b]:b===i?s.v[a]:0),0));return counts.every((x,i)=>x>=s.d.need[i])&&(s.level<2||s.d.e.every(([a,b]:number[])=>!s.v[a]||!s.v[b]))&&(s.d.budget===null||sum(s.d.cost.filter((_:number,i:number)=>s.v[i]))<=s.d.budget)&&s.d.forbid.every((i:number)=>!s.v[i])&&(!s.d.split||s.v.slice(0,3).some(Boolean)&&s.v.slice(4).some(Boolean));},
 change(s,i){s.v[i]=1-s.v[i];},
 draw(s,p){let pos=positions(s);s.d.e.forEach(([a,b]:number[])=>line(p,[pos[a][0],20],[pos[b][0],20],7));drawNodes(s,p);s.v.forEach((v,i)=>{tally(p,3+8*i,13,s.d.cost[i],14);tally(p,3+8*i,16,s.d.need[i],7);let c=v+s.d.e.reduce((z:number,[a,b]:number[])=>z+(a===i?s.v[b]:b===i?s.v[a]:0),0);indicator(p,4+8*i,23,c>=s.d.need[i]);if(s.d.forbid.includes(i)){line(p,[2+8*i,3],[8+8*i,9],8);}});rules(p,s.d.e.map(([a,b]:number[])=>({a,b,t:3,ok:s.level<2||!s.v[a]||!s.v[b]})),30);if(s.d.budget!==null){tally(p,3,59,s.d.budget,7);tally(p,24,59,sum(s.d.cost.filter((_:number,i:number)=>s.v[i])),11);}if(s.d.split){rect(p,2,27,22,2,14);rect(p,34,27,22,2,14);}}};

function normalize(v:number[]){let ids:number[]=[];return v.map(x=>{if(!ids.includes(x))ids.push(x);return ids.indexOf(x);});}
// 7. Merging changes an entire equivalence class; splitting releases one object.
const partitions:Rule={name:'Equivalence chambers',mechanics:['class union','singleton split','must-link and cannot-link','class capacity','composition diversity'],novelty:'Operations manipulate equivalence classes rather than independent labels.',
 make(l,r){let n=l<3?4:6,target=l<3?[0,0,1,1]:[0,0,0,1,1,1],rel:any[]=[[0,1,true],[n-2,n-1,true]];if(l>=2)rel.push([0,n-1,false]);if(l>=3)rel.push([1,2,true]);if(l>=4)rel.push([2,3,false]);return base(l,Array.from({length:n},(_,i)=>i),{rel,max:l>=3?3:n,diverse:l>=5,color:target.map((_,i)=>i%3),groups:l>=6?2:null,join:target});},
 goal(s){let groups=[...new Set(s.v)];return s.d.rel.every(([a,b,e]:any)=>(s.v[a]===s.v[b])===e)&&groups.every(g=>s.v.filter(x=>x===g).length<=s.d.max)&&(!s.d.diverse||groups.every(g=>new Set(s.d.color.filter((_:number,i:number)=>s.v[i]===g)).size>=2))&&(!s.d.groups||groups.length===s.d.groups);},
 change(){},custom(s,a){if(a.button===6){let i=locate(s,a,positions(s));if(i<0)return;s.sel=i;if(s.pick<0)s.pick=i;else{let from=s.v[i],to=s.v[s.pick];s.v=normalize(s.v.map(x=>x===from?to:x));s.pick=-1;}}else if(a.button===4&&s.pick>=0){s.v[s.pick]=Math.max(...s.v)+1;s.v=normalize(s.v);s.pick=-1;}else if(a.button===1||a.button===2){s.sel=mod(s.sel+(a.button===1?-1:1),s.n);s.pick=s.sel;}},
 seed(s){let target=s.d.join as number[];delete s.d.join;let w:GameAction[]=[];for(let i=0;i<s.n;i++){let a=target.indexOf(target[i]);if(a!==i)w.push(click(s,partitions,a),click(s,partitions,i));}return w;},
 draw(s,p){drawNodes(s,p);if(s.pick>=0)rect(p,1+s.pick*8,2,9,9,14);if(s.d.diverse)s.d.color.forEach((c:number,i:number)=>dot(p,5+i*8,14,c+12));let g=[...new Set(s.v)];g.forEach((x,i)=>{let y=18+i*3;s.v.forEach((v,j)=>{if(v===x)dot(p,5+j*8,y,x+2);});});rules(p,s.d.rel.map(([a,b,e]:any)=>({a,b,t:e?0:3,ok:(s.v[a]===s.v[b])===e})),39);tally(p,3,60,s.d.max,7);if(s.d.groups)tally(p,25,60,s.d.groups,14);}};

const tilePos=(s:S):P[]=>s.v.map((_,i)=>[s.d.cols===4?8+(i%4)*15:10+(i%s.d.cols)*19,9+Math.floor(i/s.d.cols)*19]);
function edgeColor(s:S,i:number,d:number){return s.d.faces[i][mod(d-s.v[i],4)];}
function edgePole(s:S,i:number,d:number){return s.d.poles[i][mod(d-s.v[i],4)];}
// 8. Faces rotate; coupled gears make orientation effects nonlocal.
const tiles:Rule={name:'Rotating correspondences',mechanics:['oriented edge matching','boundary compatibility','paired gear rotation','counterrotation'],novelty:'Rotational tile matching with geometric coupling, without translation.',positions:tilePos,
 make(l,r){let cols=l===1?4:3,rows=l===1?1:2,n=cols*rows,faces=Array.from({length:n},()=>[0,0,0,0]),poles=Array.from({length:n},()=>[0,0,0,0]);for(let i=0;i<n;i++){if(i%cols<cols-1){let c=1+rngInt(r,2),q=l>=3?(rngInt(r,2)?1:-1):0;faces[i][1]=c;faces[i+1][3]=c;poles[i][1]=q;poles[i+1][3]=-q;}if(i+cols<n){let c=1+rngInt(r,2),q=l>=3?(rngInt(r,2)?1:-1):0;faces[i][2]=c;faces[i+cols][0]=c;poles[i][2]=q;poles[i+cols][0]=-q;}}return base(l,Array(n).fill(0),{cols,rows,faces,poles,gears:l>=5?[[0,1,1],...(l>=6?[[3,4,-1],[1,4,1]]:[])]:[],boundary:l>=4});},
 goal(s){for(let i=0;i<s.n;i++){if(i%s.d.cols<s.d.cols-1&&(edgeColor(s,i,1)!==edgeColor(s,i+1,3)||edgePole(s,i,1)+edgePole(s,i+1,3)!==0))return false;if(i+s.d.cols<s.n&&(edgeColor(s,i,2)!==edgeColor(s,i+s.d.cols,0)||edgePole(s,i,2)+edgePole(s,i+s.d.cols,0)!==0))return false;if(s.d.boundary&&((i<s.d.cols&&edgeColor(s,i,0)!==0)||(i%s.d.cols===0&&edgeColor(s,i,3)!==0)||(i%s.d.cols===s.d.cols-1&&edgeColor(s,i,1)!==0)||(i+s.d.cols>=s.n&&edgeColor(s,i,2)!==0)))return false;}return true;},
 change(s,i,d){s.v[i]=mod(s.v[i]+d,4);for(let[a,b,sign]of s.d.gears){if(i===a)s.v[b]=mod(s.v[b]+d*sign,4);if(i===b)s.v[a]=mod(s.v[a]+d*sign,4);}},
 draw(s,p){let pos=tilePos(s);s.v.forEach((v,i)=>{let[x,y]=pos[i];rect(p,x-7,y-7,15,15,s.sel===i?15:7);identity(p,x,y,i);[[0,-5],[5,0],[0,5],[-5,0]].forEach(([dx,dy],d)=>{rect(p,x+dx-1,y+dy-1,3,3,edgeColor(s,i,d)?edgeColor(s,i,d)+2:1,true);let pole=edgePole(s,i,d);if(pole)dot(p,x+dx,y+dy,pole===1?12:13);});});s.d.gears.forEach(([a,b,k]:number[])=>{let A=pos[a],B=pos[b];let x=Math.round((A[0]+B[0])/2),y=Math.round((A[1]+B[1])/2);rect(p,x-1,y-1,3,3,k===1?14:8);});if(s.d.boundary)rect(p,1,1,59,s.d.rows*19-1,14);}};

function route(s:S,i:number){let seen=new Set<number>(),p=i,len=0;while(p>=s.d.sinks){if(seen.has(p))return[-1,99];seen.add(p);p=s.v[p];len++;}return[p,len];}
// 9. Pointer edits redirect entire descendant chains; cycles are real failures.
const routing:Rule={name:'Successor basins',mechanics:['functional graph routing','cycle avoidance','sink ownership','fan-in capacity','depth bounds'],novelty:'Pointer routing requires reasoning about descendants and terminal basins.',
 make(l,r){let n=6,sinks=l>=2?2:1,v=sinks===1?[0,0,1,2,3,4]:[0,1,0,1,2,3],owners=v.map((_,i)=>i<sinks?i:sinks===1?0:i%2),allow=v.map((_,i)=>Array.from({length:n},(_,j)=>j));if(l>=6)allow[4]=[0,2,3,4,5];return base(l,v,{sinks,owners,allow,cap:l>=3?2:null,depth:l>=4?[0,0,1,1,2,2]:null,span:l>=5?sum(v.map((x,i)=>i>=sinks?Math.abs(i-x):0)):null});},
 goal(s){let rr=s.v.map((_,i)=>route(s,i)),deg=Array(s.n).fill(0);for(let i=s.d.sinks;i<s.n;i++)deg[s.v[i]]++;return rr.every(([sink,len],i)=>sink>=0&&(s.level<2||sink===s.d.owners[i])&&(!s.d.depth||len<=s.d.depth[i]))&&(!s.d.cap||deg.every(x=>x<=s.d.cap))&&s.v.every((v,i)=>s.d.allow[i].includes(v))&&(s.d.span===null||sum(s.v.map((x,i)=>i>=s.d.sinks?Math.abs(i-x):0))<=s.d.span);},
 change(s,i,d){if(i>=s.d.sinks)s.v[i]=mod(s.v[i]+d,s.n);},
 draw(s,p){drawNodes(s,p);for(let i=0;i<s.n;i++){if(i<s.d.sinks)rect(p,1+8*i,2,9,9,14);else{dot(p,5+8*i,14,s.d.owners[i]+2);if(s.d.depth)tally(p,3+8*i,17,s.d.depth[i],7);indicator(p,4+8*i,21,route(s,i)[0]===s.d.owners[i]);}}rules(p,s.v.slice(s.d.sinks).map((v,j)=>({a:j+s.d.sinks,b:v,t:1,ok:route(s,j+s.d.sinks)[0]>=0})),29);masks(p,s.d.allow,49);if(s.d.cap)tally(p,3,60,s.d.cap,14);if(s.d.span!==null)tally(p,23,60,s.d.span,7);}};

const schedulePos=(s:S):P[]=>s.v.map((_,i)=>[5,6+i*9]);
// 10. Occupancy intervals compete in time; coupled edits shift other intervals.
const schedule:Rule={name:'Interval concordance',mechanics:['interval placement','resource overlap','precedence','cooldown','coupled shifts','release windows'],novelty:'Temporal resource constraints with linked scheduling controls.',positions:schedulePos,
 make(l,r){let v=[0,2,4,6],duration=l===1?[1,1,1,1]:[2,1,2,1],resources=l>=3?[1,3,2,3]:[1,1,1,1],rel=l>=2?[[0,2,0]]:[];if(l>=4)rel.push([1,3,1]);return base(l,v,{duration,resources,rel,gears:l>=5?[[0,2,1],...(l>=6?[[1,3,-1]]:[])]:[],windows:l>=6?[[0,2],[1,4],[3,6],[5,8]]:Array.from({length:4},()=>[0,8])});},
 goal(s){for(let a=0;a<s.n;a++){if(s.v[a]<s.d.windows[a][0]||s.v[a]>s.d.windows[a][1])return false;for(let b=a+1;b<s.n;b++)if((s.d.resources[a]&s.d.resources[b])&&s.v[a]<s.v[b]+s.d.duration[b]&&s.v[b]<s.v[a]+s.d.duration[a])return false;}return s.d.rel.every(([a,b,g]:number[])=>s.v[a]+s.d.duration[a]+g<=s.v[b]);},
 change(s,i,d){s.v[i]=mod(s.v[i]+d,9);for(let[a,b,k]of s.d.gears){if(i===a)s.v[b]=mod(s.v[b]+d*k,9);if(i===b)s.v[a]=mod(s.v[a]+d*k,9);}},
 draw(s,p){s.v.forEach((v,i)=>{let y=6+i*9;identity(p,5,y,i);if(s.sel===i)rect(p,2,y-3,7,7,15);for(let t=0;t<11;t++){dot(p,12+t*4,y+3,7);if(t>=s.d.windows[i][0]&&t<=s.d.windows[i][1])dot(p,12+t*4,y-3,1);}rect(p,12+v*4,y-1,s.d.duration[i]*4-1,3,2+i,true);if(s.d.resources[i]&1)dot(p,9,y-1,12);if(s.d.resources[i]&2)dot(p,9,y+1,13);});rules(p,s.d.rel.map(([a,b,g]:number[])=>({a,b,t:1,k:g,ok:s.v[a]+s.d.duration[a]+g<=s.v[b]})),44);s.d.gears.forEach(([a,b,k]:number[],j:number)=>{dot(p,5+j*18,59,a+2);dot(p,15+j*18,59,b+2);dot(p,10+j*18,59,k===1?14:8);});}};

function blocking(s:S){let bad:number[][]=[];for(let a=0;a<s.n;a++)for(let b=0;b<s.n;b++){let holder=s.v.indexOf(b);if(b!==s.v[a]&&s.d.left[a][b]<s.d.left[a][s.v[a]]&&s.d.right[b][a]<s.d.right[b][holder])bad.push([a,b]);}return bad;}
function stable(left:number[][],right:number[][]){let n=left.length,v=Array(n).fill(-1),owner=Array(n).fill(-1),next=Array(n).fill(0),order=left.map(r=>Array.from({length:n},(_,i)=>i).sort((a,b)=>r[a]-r[b]));while(v.includes(-1)){let a=v.indexOf(-1),b=order[a][next[a]++];if(owner[b]<0){owner[b]=a;v[a]=b;}else if(right[b][a]<right[b][owner[b]]){v[owner[b]]=-1;owner[b]=a;v[a]=b;}}return v;}
// 11. A satisfied individual pair can still destabilize a different pair.
const preferences:Rule={name:'Stable reciprocities',mechanics:['two-sided rankings','blocking pairs','preference ties','regional quota','accepted pair constraints'],novelty:'Stability is relational and differs from maximizing individual preference.',
 make(l,r){let n=l===1?3:l===6?6:4,rank=()=>{let order=shuffle(Array.from({length:n},(_,i)=>i),r);return Array.from({length:n},(_,i)=>order.indexOf(i));},left=Array.from({length:n},rank),right=Array.from({length:n},rank);if(l>=3){left[0]=left[0].map(x=>Math.floor(x/2));right[1]=right[1].map(x=>Math.floor(x/2));}let v=stable(left,right);return base(l,v,{left,right,quota:l>=4?v.slice(0,2).filter(x=>x<2).length:null,anchor:l>=5?[0,v[0]]:null,cost:l>=6?Math.max(...v.map((b,a)=>left[a][b]+right[b][a])):null});},
 goal(s){return new Set(s.v).size===s.n&&!blocking(s).length&&(s.d.quota===null||s.v.slice(0,2).filter(x=>x<2).length===s.d.quota)&&(!s.d.anchor||s.v[s.d.anchor[0]]===s.d.anchor[1])&&(s.d.cost===null||Math.max(...s.v.map((b,a)=>s.d.left[a][b]+s.d.right[b][a]))<=s.d.cost);},
 change(s,i,d){let j=mod(i+(d>0?1:-1),s.n);[s.v[i],s.v[j]]=[s.v[j],s.v[i]];},
 seed(s,r){let ops:number[]=[];for(let j=0;j<9+s.level;j++){let i=rngInt(r,s.n-1);preferences.change(s,i,1);ops.push(i);}breakPermutation(s,preferences,ops);return ops.reverse().flatMap(i=>[click(s,preferences,i+1),action(4)]);},
 draw(s,p){drawNodes(s,p);for(let side=0;side<2;side++){let rank=side?s.d.right:s.d.left,x=side?35:4;rect(p,x-2,12,5,3,side?14:7);for(let a=0;a<s.n;a++){let y=19+a*6;identity(p,x,y,a);let order=Array.from({length:s.n},(_,i)=>i).sort((a1,b)=>rank[a][a1]-rank[a][b]);order.forEach((b,j)=>{dot(p,x+7+j*4,y,b+2);if(j&&rank[a][b]===rank[a][order[j-1]])line(p,[x+3+j*4,y+2],[x+7+j*4,y+2],7);});}}blocking(s).slice(0,5).forEach(([a,b],i)=>{dot(p,3+i*10,55,a+2);dot(p,7+i*10,55,b+2);indicator(p,5+i*10,57,false);});if(s.d.quota!==null)tally(p,2,61,s.d.quota,14);if(s.d.anchor){dot(p,25,61,s.d.anchor[0]+2);dot(p,29,61,s.d.anchor[1]+2);}if(s.d.cost!==null)tally(p,40,61,s.d.cost,7);}};

const barterPos=(s:S):P[]=>s.v.map((_,i)=>[7+(i%2)*8,6+Math.floor(i/2)*12]);
// 12. Swapping inventory slots conserves every resource across bundles.
const barter:Rule={name:'Reciprocal bundles',mechanics:['conserved multiset exchange','bundle demands','diversity','wealth fairness','restricted trading graph'],novelty:'Multi-item ownership introduces conserved inventory and transfer routing.',positions:barterPos,
 make(l,r){let v=shuffle([0,1,1,2,2,0,0,2],r),demand=Array.from({length:4},(_,i)=>v[2*i]),wealth=Array.from({length:4},(_,i)=>v[2*i]+v[2*i+1]+2);return base(l,v,{demand,variety:l>=2?v.filter((_,i)=>i%2===0).map((x,i)=>x!==v[2*i+1]):null,minimum:l>=3?wealth.map(x=>Math.max(2,x-1)):null,fair:l>=4?Math.max(...wealth)-Math.min(...wealth):null,adjacent:l>=5,paired:l>=6});},
 goal(s){let bundles=Array.from({length:4},(_,i)=>s.v.slice(2*i,2*i+2)),wealth=bundles.map(a=>sum(a)+2);return bundles.every((b,i)=>b.includes(s.d.demand[i])&&(!s.d.variety||!s.d.variety[i]||b[0]!==b[1])&&(!s.d.minimum||wealth[i]>=s.d.minimum[i]))&&(s.d.fair===null||Math.max(...wealth)-Math.min(...wealth)<=s.d.fair);},
 change(){},custom(s,a){if(a.button===6){let i=locate(s,a,barterPos(s));if(i<0)return;s.sel=i;if(s.pick<0)s.pick=i;else{let j=s.pick,oi=Math.floor(i/2),oj=Math.floor(j/2),valid=!s.d.adjacent||oi===oj||Math.abs(oi-oj)===1||Math.abs(oi-oj)===3;if(s.d.paired&&oi!==oj)valid=valid&&(i%2===j%2);if(valid)[s.v[i],s.v[j]]=[s.v[j],s.v[i]];s.pick=-1;}}else if(a.button===4)s.pick=-1;else if(a.button===1||a.button===2){s.sel=mod(s.sel+(a.button===1?-1:1),s.n);s.pick=s.sel;}},
 seed(s,r){let undo:number[][]=[];for(let j=0;j<10+s.level;j++){let i=rngInt(r,s.n),o=Math.floor(i/2),b=s.d.adjacent?mod(o+1,4):rngInt(r,4),k=s.d.paired?b*2+i%2:b*2+rngInt(r,2);[s.v[i],s.v[k]]=[s.v[k],s.v[i]];undo.push([i,k]);}if(barter.goal(s)){let done=false;for(let i=0;i<8&&!done;i++)for(let k=0;k<8&&!done;k++){let a=clone(s);barter.custom!(a,click(a,barter,i));barter.custom!(a,click(a,barter,k));if(!barter.goal(a)){s.v=a.v;undo.push([i,k]);done=true;}}}return undo.reverse().flatMap(([i,k])=>[click(s,barter,i),click(s,barter,k)]);},
 draw(s,p){s.v.forEach((v,i)=>{let[x,y]=barterPos(s)[i];rect(p,x-2,y-2,5,5,v+2,true);if(s.sel===i)rect(p,x-3,y-3,7,7,15);});for(let i=0;i<4;i++){let y=6+i*12;identity(p,24,y,i);rect(p,31,y-1,3,3,s.d.demand[i]+2,true);if(s.d.variety?.[i]){dot(p,38,y-1,7);dot(p,41,y+1,7);}if(s.d.minimum)tally(p,46,y-1,s.d.minimum[i],7);if(s.d.adjacent&&i<3)line(p,[24,y+3],[24,y+9],14);}if(s.pick>=0){let[x,y]=barterPos(s)[s.pick];rect(p,x-4,y-4,9,9,14);}if(s.d.fair!==null)tally(p,3,60,s.d.fair,14);if(s.d.paired){line(p,[7,49],[7,57],14);line(p,[15,49],[15,57],14);}}};

// 13. Renaming one binding updates every occurrence; only the free color is usable.
const substitution:Rule={name:'Shared bindings',mechanics:['simultaneous substitution','spare symbol','global occurrence coupling','intersecting palettes','relational exclusions'],novelty:'A binding operation affects all occurrences while preserving distinct symbols.',
 make(l,r){let n=l===1?2:l===2?3:4,v=shuffle(Array.from({length:n},(_,i)=>i),r),occ=Array.from({length:l>=4?n*2:n},(_,i)=>i%n),allow=occ.map((b,i)=>[v[b],mod(v[b]+(i<n?1:2),n+1)]),rel:any[]=[];if(l>=3)rel.push([0,1,v[0]<v[1]?1:-1]);if(l>=5)rel.push([2,3,v[2]<v[3]?1:-1]);return base(l,v,{k:n+1,occ,allow,rel,span:l>=6?Math.abs(v[0]-v[3]):null});},
 goal(s){return new Set(s.v).size===s.n&&s.d.occ.every((b:number,i:number)=>s.d.allow[i].includes(s.v[b]))&&s.d.rel.every(([a,b,k]:number[])=>(s.v[a]<s.v[b]?1:-1)===k)&&(s.d.span===null||Math.abs(s.v[0]-s.v[3])===s.d.span);},
 change(s,i){let free=Array.from({length:s.d.k},(_,j)=>j).find(x=>!s.v.includes(x))!;s.v[i]=free;},
 draw(s,p){drawNodes(s,p);let free=Array.from({length:s.d.k},(_,j)=>j).find(x=>!s.v.includes(x))!;rect(p,55,4,7,7,14);rect(p,57,6,3,3,free+2,true);s.d.occ.forEach((b:number,i:number)=>{let x=5+(i%4)*15,y=23+Math.floor(i/4)*13;identity(p,x,y,b);rect(p,x-1,y-1,3,3,s.v[b]+2,true);s.d.allow[i].forEach((v:number,j:number)=>rect(p,x-3+j*4,y+5,3,2,v+2,true));});rules(p,s.d.rel.map(([a,b,k]:number[])=>({a:k===1?a:b,b:k===1?b:a,t:1,ok:(s.v[a]<s.v[b]?1:-1)===k})),48);if(s.d.span!==null)tally(p,4,62,s.d.span,14);}};

function support(s:S,i:number){return s.d.influence[i].reduce((n:number,[j,sign]:number[])=>n+(s.v[j]===sign?1:0),0);}
// 14. A node can change only while neighboring opinions license the transition.
const coalition:Rule={name:'Conditional coalitions',mechanics:['guarded binary transition','neighbor thresholds','signed influence','fixed anchors','relational consensus'],novelty:'Reachable transitions depend on local context, making move order causal.',
 make(l,r){let n=6,v=Array.from({length:n},(_,i)=>l>=4?i%2:1),influence=Array.from({length:n},(_,i)=>[[mod(i-1,n),v[mod(i-1,n)]],[mod(i+1,n),v[mod(i+1,n)]]]),threshold=Array.from({length:n},(_,i)=>l>=3&&i%2===0?2:1);if(l>=5){influence[2].push([4,1-v[4]]);influence[4].push([1,1-v[1]]);}let rel=Array.from({length:n-1},(_,i)=>[i,i+1,v[i]===v[i+1]]);return base(l,v,{influence,threshold,rel,anchors:l>=2?[[0,v[0]]]:[],protected:l>=6?[0]:[]});},
 goal(s){return s.d.rel.every(([a,b,e]:any)=>(s.v[a]===s.v[b])===e)&&s.d.anchors.every(([i,v]:number[])=>s.v[i]===v);},
 change(s,i){if(!s.d.protected.includes(i)&&support(s,i)>=s.d.threshold[i])s.v[i]=1-s.v[i];},
 draw(s,p){drawNodes(s,p);s.v.forEach((v,i)=>{tally(p,3+8*i,13,s.d.threshold[i],7);indicator(p,4+8*i,17,!s.d.protected.includes(i)&&support(s,i)>=s.d.threshold[i]);});s.d.influence.forEach((a:number[][],i:number)=>a.forEach(([j,sign],k)=>{dot(p,3+8*i+k*2,22,j+2);dot(p,3+8*i+k*2,24,sign+2);}));rules(p,s.d.rel.map(([a,b,e]:any)=>({a,b,t:e?0:3,ok:(s.v[a]===s.v[b])===e})),31);s.d.anchors.forEach(([i,v]:number[])=>{identity(p,5+i*8,57,i);dot(p,5+i*8,61,v+2);});s.d.protected.forEach((i:number)=>rect(p,1+8*i,2,9,9,14));}};

const containerPos=(s:S):P[]=>s.v.map((_,i)=>[7,5+i*8]);
function ancestors(s:S,i:number){let out:number[]=[],j=s.v[i];while(j>=0&&!out.includes(j)){out.push(j);j=s.v[j];}return out;}
function loads(s:S){return s.v.map((_,i)=>sum(s.d.weight.filter((_:number,j:number)=>ancestors(s,j).includes(i))));}
// 15. Moving a container relocates a whole subtree and changes every ancestor load.
const containment:Rule={name:'Nested jurisdictions',mechanics:['subtree reparenting','ancestry constraints','cycle prevention','capacity','depth','sibling exclusions'],novelty:'Containment has transitive ancestry and subtree weight effects.',positions:containerPos,
 make(l,r){let n=l===1?4:6,target=n===4?[-1,-1,0,1]:[-1,-1,0,0,2,1],weight=Array.from({length:n},()=>1+rngInt(r,l>=5?3:1)),s=base(l,target,{weight}),cap=loads(s).map(x=>Math.max(2,x)),rel:any[]=[[0,2,0],[1,n-1,0]];if(l>=2)rel.push([0,1,2]);if(l>=3)rel.push([0,4,1]);if(l>=4)rel.push([2,5,2]);return base(l,Array(n).fill(-1),{weight,cap:l>=5?cap:Array(n).fill(30),rel,depth:l>=2?2:n,sibling:l>=6?[3,5]:null,build:target});},
 goal(s){return s.d.rel.every(([a,b,t]:number[])=>t===0?s.v[b]===a:t===1?ancestors(s,b).includes(a):!ancestors(s,a).includes(b)&&!ancestors(s,b).includes(a))&&s.v.every((_,i)=>ancestors(s,i).length<=s.d.depth)&&loads(s).every((x,i)=>x<=s.d.cap[i])&&(!s.d.sibling||s.v[s.d.sibling[0]]!==s.v[s.d.sibling[1]]);},
 change(){},custom(s,a){if(a.button===6){let i=locate(s,a,containerPos(s));if(i<0)return;s.sel=i;if(s.pick<0)s.pick=i;else{let child=s.pick;s.pick=-1;if(i===child||ancestors(s,i).includes(child))return;let old=s.v[child];s.v[child]=i;if(s.v.some((_,j)=>ancestors(s,j).length>s.d.depth)||loads(s).some((x,j)=>x>s.d.cap[j]))s.v[child]=old;}}else if(a.button===4){if(s.pick>=0)s.v[s.pick]=-1;s.pick=-1;}else if(a.button===1||a.button===2){s.sel=mod(s.sel+(a.button===1?-1:1),s.n);s.pick=s.sel;}},
 seed(s){let target=s.d.build;delete s.d.build;return target.flatMap((p:number,i:number)=>p<0?[]:[click(s,containment,i),click(s,containment,p)]);},
 draw(s,p){let load=loads(s);s.v.forEach((v,i)=>{let y=5+i*8;identity(p,7,y,i);if(s.sel===i)rect(p,4,y-3,7,7,15);if(s.pick===i)rect(p,3,y-4,9,9,14);let aa=ancestors(s,i);aa.forEach((x,j)=>rect(p,16+j*5,y-2,4,5,x+2));tally(p,32,y-1,s.d.weight[i],7);if(s.level>=5){tally(p,42,y-2,s.d.cap[i],7);tally(p,42,y+1,load[i],11);}});rules(p,s.d.rel.map(([a,b,t]:number[])=>({a,b,t:t===2?3:t===1?2:1,ok:t===0?s.v[b]===a:t===1?ancestors(s,b).includes(a):!ancestors(s,a).includes(b)&&!ancestors(s,b).includes(a)})),51);if(s.d.sibling){dot(p,55,45,s.d.sibling[0]+2);dot(p,59,45,s.d.sibling[1]+2);dot(p,57,43,8);}tally(p,52,49,s.d.depth,14);}};

export const environments:Environment[]=[chromatic,precedence,matching,scales,connectivity,coverage,partitions,tiles,routing,schedule,preferences,barter,substitution,coalition,containment].map((r,i)=>env(`e${String(i+1).padStart(3,'0')}`,r));