@topy-ai/maggie 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """PostgreSQL backend for the MaggieDash content store.
3
+
4
+ A thin compatibility shim that mimics the subset of the stdlib sqlite3
5
+ connection API that MaggieDashStore uses, so the same store code runs on
6
+ Postgres unchanged. It translates `?` placeholders to `%s`, intercepts the few
7
+ SQLite-only statements (PRAGMA foreign_keys / table_info / user_version and
8
+ `sqlite_master`), and returns rows that support both name and positional access
9
+ plus `dict(row)` — exactly like `sqlite3.Row`.
10
+
11
+ Used when a Postgres DSN is supplied (ONEMAN forbids SQLite anywhere); the
12
+ SQLite path in MaggieDashStore is untouched. Requires psycopg2 (imported
13
+ lazily, only when a Postgres backend is actually requested).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any, Sequence
19
+
20
+
21
+ class Row(dict):
22
+ """A dict that also supports positional access and `dict(row)`, like sqlite3.Row."""
23
+ __slots__ = ("_cols",)
24
+
25
+ def __init__(self, cols: Sequence[str], values: Sequence[Any]):
26
+ super().__init__(zip(cols, values))
27
+ self._cols = list(cols)
28
+
29
+ def __getitem__(self, key):
30
+ if isinstance(key, int):
31
+ return super().__getitem__(self._cols[key])
32
+ return super().__getitem__(key)
33
+
34
+
35
+ class Result:
36
+ """Cursor-like result supporting fetchone/fetchall/iteration over Row objects."""
37
+ def __init__(self, rows: list[Row]):
38
+ self._rows = rows
39
+ self._idx = 0
40
+
41
+ def fetchone(self):
42
+ if self._idx < len(self._rows):
43
+ row = self._rows[self._idx]
44
+ self._idx += 1
45
+ return row
46
+ return None
47
+
48
+ def fetchall(self):
49
+ rows = self._rows[self._idx:]
50
+ self._idx = len(self._rows)
51
+ return rows
52
+
53
+ def __iter__(self):
54
+ rows = self._rows[self._idx:]
55
+ self._idx = len(self._rows)
56
+ return iter(rows)
57
+
58
+
59
+ _PRAGMA_TABLE_INFO = re.compile(r"PRAGMA\s+table_info\s*\(\s*([^)]+?)\s*\)", re.IGNORECASE)
60
+ _USER_VERSION_SET = re.compile(r"PRAGMA\s+user_version\s*=\s*(\d+)", re.IGNORECASE)
61
+
62
+
63
+ class PgConnection:
64
+ """sqlite3.Connection-compatible shim backed by psycopg2."""
65
+ def __init__(self, dsn: str):
66
+ import psycopg2 # lazy: only when a Postgres backend is requested
67
+ self._conn = psycopg2.connect(dsn)
68
+ self._conn.autocommit = False
69
+
70
+ # -- the sqlite3.Connection surface MaggieDashStore relies on --
71
+ def execute(self, sql: str, params: Sequence[Any] | None = None) -> Result:
72
+ s = sql.strip()
73
+ up = s.upper()
74
+ if up.startswith("PRAGMA FOREIGN_KEYS"):
75
+ return Result([])
76
+ m = _PRAGMA_TABLE_INFO.search(s)
77
+ if m:
78
+ table = m.group(1).strip().strip('"').strip("'")
79
+ rows = self._run(
80
+ "SELECT column_name FROM information_schema.columns "
81
+ "WHERE table_schema='public' AND table_name=%s ORDER BY ordinal_position",
82
+ (table,))
83
+ # sqlite PRAGMA table_info columns: (cid, name, ...) -> row[1] is the name
84
+ return Result([Row(["cid", "name"], [i, r[0]]) for i, r in enumerate(rows)])
85
+ m = _USER_VERSION_SET.search(s)
86
+ if m:
87
+ self._run("CREATE TABLE IF NOT EXISTS maggiedash_meta (key TEXT PRIMARY KEY, value TEXT)")
88
+ self._run("INSERT INTO maggiedash_meta (key,value) VALUES ('user_version',%s) "
89
+ "ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value", (str(m.group(1)),))
90
+ return Result([])
91
+ if "FROM SQLITE_MASTER" in up:
92
+ rows = self._run("SELECT table_name FROM information_schema.tables WHERE table_schema='public'")
93
+ return Result([Row(["name"], [r[0]]) for r in rows])
94
+ # ordinary statement: translate placeholders and run
95
+ query = s.replace("?", "%s")
96
+ cur = self._conn.cursor()
97
+ try:
98
+ cur.execute(query, tuple(params) if params else None)
99
+ if cur.description is None:
100
+ return Result([])
101
+ cols = [d[0] for d in cur.description]
102
+ return Result([Row(cols, rec) for rec in cur.fetchall()])
103
+ finally:
104
+ cur.close()
105
+
106
+ def executescript(self, script: str) -> None:
107
+ cur = self._conn.cursor()
108
+ try:
109
+ cur.execute(script) # psycopg2 runs multiple ';'-separated statements
110
+ finally:
111
+ cur.close()
112
+
113
+ def _run(self, query: str, params: Sequence[Any] | None = None) -> list[tuple]:
114
+ cur = self._conn.cursor()
115
+ try:
116
+ cur.execute(query, tuple(params) if params else None)
117
+ return cur.fetchall() if cur.description is not None else []
118
+ finally:
119
+ cur.close()
120
+
121
+ def commit(self) -> None:
122
+ self._conn.commit()
123
+
124
+ def close(self) -> None:
125
+ if not self._conn.closed:
126
+ self._conn.close()
@@ -38,11 +38,23 @@ def checksum(value: dict[str, Any]) -> str:
38
38
 
39
39
 
40
40
  class MaggieDashStore:
41
- def __init__(self, path: Path):
42
- self.path = path.resolve()
43
- self.path.parent.mkdir(parents=True, exist_ok=True)
44
- self.connection = sqlite3.connect(self.path)
45
- self.connection.row_factory = sqlite3.Row
41
+ def __init__(self, path: Path | None = None, *, dsn: str | None = None):
42
+ # Postgres backend (ONEMAN forbids SQLite) when a DSN is supplied;
43
+ # otherwise the stdlib SQLite backend, unchanged.
44
+ if dsn:
45
+ from maggie_dash_pg import PgConnection
46
+ self.backend = "postgres"
47
+ self.path = dsn
48
+ # Never expose credentials in contracts/reports/CLI output.
49
+ self.storage_label = "env:MAGGIEDASH_DATABASE_URL"
50
+ self.connection = PgConnection(dsn)
51
+ else:
52
+ self.backend = "sqlite"
53
+ self.path = path.resolve()
54
+ self.storage_label = str(self.path)
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ self.connection = sqlite3.connect(self.path)
57
+ self.connection.row_factory = sqlite3.Row
46
58
  self.connection.execute("PRAGMA foreign_keys = ON")
47
59
  self.connection.executescript(
48
60
  """
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env python3
2
+ """Animate the Core-Agent faces through the 32-state system.
3
+
4
+ The agent's face (hair / eyes / prop) is the fixed identity; its ONEMAN O-rim
5
+ is the animatable ring — it reuses the exact motion vocabulary + palette +
6
+ badges from sprite_system.CATALOG (thinking sweeps, loading fills, critical
7
+ blinks, success turns green, awaiting shows the clock badge, …). `speaking`
8
+ also opens the mouth. Emits a PNG frame strip + still per state, a sprite sheet
9
+ and states.json per agent, into brand/<role>/.
10
+ """
11
+ import sys, os, json, math
12
+ HERE=os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0,HERE)
13
+ OUT_BASE=HERE
14
+ import spirit_face as F
15
+ from spirit_base import CATALOG, GLYPH, palette, GROUPS
16
+ from PIL import Image
17
+
18
+ W,H,cx,cy=F.W,F.H,F.cx,F.cy
19
+ R_IN,R_OUT=F.R_IN,F.R_OUT
20
+ shade=F.shade; OUT=F.OUT
21
+
22
+ # rim cells (annulus) ordered by angle from top, clockwise
23
+ RIM=[]
24
+ for y in range(H):
25
+ for x in range(W):
26
+ d=math.hypot(x-cx,y-cy)
27
+ if R_IN-0.5<=d<=R_OUT: RIM.append((x,y))
28
+ def _ang(p): return (math.atan2(p[1]-cy,p[0]-cx)+math.pi/2)%(2*math.pi)
29
+ RIM.sort(key=_ang)
30
+ ANG={p:_ang(p) for p in RIM}
31
+
32
+ BX,BY=22,0
33
+ def put_badge(px,name,bg,fg):
34
+ for yy in range(0,6):
35
+ for xx in range(21,27): px[(xx,yy)]=None
36
+ g=GLYPH[name]
37
+ for r in range(5):
38
+ for c in range(5): px[(BX+c,BY+r)]=fg if g[r][c]=="1" else bg
39
+ def tri(t,nf): x=(2*t/nf); return x if x<=1 else 2-x
40
+
41
+ WHT=F.WHT; EYE=F.EYE
42
+ EYEPROPS={"glasses","shades","monocle"} # cover the eyes -> don't redraw eyes under them
43
+ MOUTHPROPS={"cigar"} # at the mouth -> keep base mouth
44
+
45
+ # ---- expression layer: per-state face animation (generic, reusable) ----
46
+ # state -> (eye-mode, mouth-mode). Blink is added automatically on longer loops.
47
+ EXPR={
48
+ "off":("closed","flat"),"booting":("wake","small"),"idle":("open","smile_s"),
49
+ "active":("open","smile_s"),"paused":("half","flat"),"offline":("closed","flat"),
50
+ "thinking":("up","small"),"loading":("open","flat"),"syncing":("dart","flat"),
51
+ "writing":("down","small"),"streaming":("open","talk"),"queued":("open","flat"),
52
+ "scanning":("dart","flat"),"deploying":("determined","flat"),
53
+ "speaking":("open","talk"),"listening":("side","small"),
54
+ "notification":("wide","small"),"mention":("side","smile"),"recording":("open","flat"),
55
+ "success":("happy","grin"),"warning":("worried","frown"),"critical":("wide","frown"),
56
+ "blocked":("side","flat"),"error":("x","frown"),"retrying":("determined","flat"),
57
+ "awaiting":("up","small"),"approved":("happy","smile"),"rejected":("worried","frown"),
58
+ "secure":("open","smile_s"),"vulnerable":("worried","frown"),"maintenance":("down","flat"),
59
+ "celebrating":("happy","grin"),
60
+ }
61
+ def _clear(px,x0,y0,x1,y1,SK):
62
+ for y in range(y0,y1+1):
63
+ for x in range(x0,x1+1): px[(x,y)]=SK
64
+ def draw_eyes_expr(px,mode,t,nf,SK,lash,blink):
65
+ for s in(-1,1):
66
+ _clear(px,cx+s*3 if s>0 else cx-6, cy-3, cx+6 if s>0 else cx-3, cy, SK)
67
+ if blink: mode="closed"
68
+ def whites(s):
69
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-2)]=WHT; px[(x,cy-1)]=WHT
70
+ def lashmark(s):
71
+ if lash: px[(cx+s*6,cy-2)]=OUT; px[(cx+s*5,cy-3)]=OUT
72
+ for s in(-1,1):
73
+ if mode=="closed":
74
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=OUT
75
+ lashmark(s); continue
76
+ if mode=="half":
77
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=WHT
78
+ px[(cx+s*4,cy-1)]=EYE
79
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-2)]=OUT
80
+ continue
81
+ if mode=="happy":
82
+ px[(cx+s*5,cy-1)]=OUT; px[(cx+s*4,cy-2)]=OUT; px[(cx+s*3,cy-1)]=OUT; continue
83
+ if mode=="x":
84
+ px[(cx+s*3,cy-2)]=OUT; px[(cx+s*4,cy-1)]=OUT; px[(cx+s*5,cy)]=OUT
85
+ px[(cx+s*5,cy-2)]=OUT; px[(cx+s*3,cy)]=OUT; continue
86
+ whites(s); lashmark(s)
87
+ pdx, pdy = 0, 0
88
+ if mode=="up": pdy=-1
89
+ elif mode=="down": pdy=0; px[(cx+s*3,cy-2)]=EYE # lowered lid feel
90
+ elif mode=="side": pdx=1
91
+ elif mode=="dart": pdx=1 if (t%2==0) else -1
92
+ elif mode=="wide": pass
93
+ elif mode=="worried": px[(cx+s*4 if s<0 else cx+s*3,cy-3)]=OUT # inner brow down
94
+ elif mode=="determined": px[(cx+s*5,cy-3)]=OUT # outer brow
95
+ px[(cx+s*(4+pdx), cy-1+pdy)]=EYE
96
+ if mode not in("wide","up"): px[(cx+s*4, cy-2)]=EYE if pdy==0 else WHT
97
+ if mode=="wake" and t< nf//2:
98
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=OUT; px[(x,cy-2)]=SK
99
+ def draw_mouth_expr(px,mode,t,SK,mcol):
100
+ _clear(px,cx-2,cy+3,cx+2,cy+5,SK)
101
+ if mode=="smile" or mode=="smile_s":
102
+ px[(cx-2,cy+4)]=OUT;px[(cx-1,cy+5)]=OUT;px[(cx,cy+5)]=OUT;px[(cx+1,cy+5)]=OUT;px[(cx+2,cy+4)]=OUT
103
+ elif mode=="grin":
104
+ px[(cx-2,cy+4)]=OUT;px[(cx+2,cy+4)]=OUT
105
+ for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT; px[(x,cy+5)]=WHT
106
+ elif mode=="flat":
107
+ for x in range(cx-2,cx+2): px[(x,cy+4)]=OUT
108
+ elif mode=="frown":
109
+ px[(cx-2,cy+5)]=OUT;px[(cx-1,cy+4)]=OUT;px[(cx,cy+4)]=OUT;px[(cx+1,cy+4)]=OUT;px[(cx+2,cy+5)]=OUT
110
+ elif mode=="small":
111
+ px[(cx,cy+4)]=OUT; px[(cx-1,cy+4)]=OUT
112
+ elif mode=="talk":
113
+ if t%2:
114
+ for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT;px[(x,cy+5)]=OUT
115
+ px[(cx,cy+5)]=F._rgb('7a1a12')
116
+ else:
117
+ for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT
118
+ if mcol and mode in("smile","smile_s","flat","grin"): # lipstick tint
119
+ for x in range(cx-2,cx+3):
120
+ if px.get((x,cy+4))==OUT: px[(x,cy+4)]=mcol
121
+
122
+ # ---- per-agent signature micro-animation on the prop (always-on identity) ----
123
+ def prop_anim(px,prop,t,nf,propcol):
124
+ ph=t%max(1,nf); frac=t/max(1,nf); rgb=F._rgb
125
+ if prop=="cigar":
126
+ for k in range(2): # smoke curl rising
127
+ yy=(cy+2)-((ph+k*2)%6); xx=cx+9+((ph+k)%2)
128
+ if yy>=cy-4: px[(xx,yy)]=rgb('9a9aa8') if k==0 else rgb('6a6a7a')
129
+ px[(cx+8,cy+4)]=rgb('ff4422') if ph%2==0 else rgb('ff7a44') # ember flicker
130
+ elif prop=="headset":
131
+ px[(cx-6,cy+4)]=rgb('ff4422') if ph%2==0 else rgb('5a1a10') # mic light blink
132
+ elif prop in("shades","glasses"):
133
+ gx=cx-5+int(frac*11) # glint sweeps both lenses
134
+ for lx0,lx1 in((cx-5,cx-1),(cx+2,cx+6)):
135
+ if lx0<=gx<=lx1: px[(gx,cy-1)]=WHT
136
+ elif prop=="monocle":
137
+ gx=cx+2+int(frac*4)
138
+ if cx+2<=gx<=cx+6: px[(gx,cy-1)]=WHT
139
+ px[(cx+4+(1 if ph%2 else 0),cy+2)]=rgb('d4af37') # chain sway
140
+ elif prop=="pen":
141
+ if ph%2==0: px[(cx+8,cy-1)]=rgb('f2c75a') # write spark at the nib
142
+ elif prop=="earbuds":
143
+ w=rgb('9fe8ff')
144
+ if ph%2==0: px[(cx-10,cy)]=w; px[(cx+10,cy)]=w # sound waves pulse out
145
+ else: px[(cx-11,cy-1)]=w; px[(cx+11,cy-1)]=w
146
+ elif prop=="bandana":
147
+ dy=1 if ph%2 else 0; BC=rgb(propcol or 'ef4444')
148
+ px[(cx-11,cy+dy)]=BC; px[(cx-11,cy+1+dy)]=shade(BC,-22) # tail flutter
149
+
150
+ def founder_anim(px,t,nf):
151
+ rgb=F._rgb; ph=t%max(1,nf); frac=t/max(1,nf)
152
+ gx=cx-5+int(frac*11) # glasses glint sweep
153
+ for lx0,lx1 in((cx-5,cx-3),(cx+3,cx+5)):
154
+ if lx0<=gx<=lx1: px[(gx,cy-1)]=WHT
155
+ GL = rgb('39d353') if ph%2==0 else rgb('1f7a3a') # green mask-vent pulse
156
+ for k in range(3): px[(cx+3,cy+4+k)]=GL
157
+ BC=rgb('e23b3b'); BCd=shade(BC,-24); w=1 if ph%2 else 0 # right-side tails ripple in the wind
158
+ for strand in(0,1):
159
+ for k in range(2,6):
160
+ xx=cx+8+k; yy=(cy-2-strand*2)-(k*2)//3-(w if k>=3 else 0)
161
+ if 0<=xx<W and 0<=yy<H: px[(xx,yy)]=BC if k<3 else BCd
162
+
163
+ NF={"breathe":10,"breathe_slow":10,"pulse":8,"blink":2,"arc":12,"scan":12,"spin":10,
164
+ "dualarc":12,"fill":12,"boot":10,"dots":6,"sparkout":4,"sparkin":4,"celebrate":8}
165
+
166
+ def frames(base, rim_live, state, P, ctx):
167
+ grp,role,motion,badge,fps=CATALOG[state]
168
+ col=P[role] if role in P else P["accent"]
169
+ nf=NF[motion]; out=[]
170
+ SK=F.SKINS[ctx["skin"]]; lash=(ctx["gender"]=="f")
171
+ eyemode,mouthmode=EXPR[state]
172
+ for t in range(nf):
173
+ px=dict(base)
174
+ def setrim(fn):
175
+ for i,p in enumerate(rim_live): px[p]=fn(i,p)
176
+ n=len(rim_live)
177
+ if motion in("breathe","breathe_slow"):
178
+ cc=shade(col,(10 if motion=="breathe_slow" else 16)*tri(t,nf)); setrim(lambda i,p:cc)
179
+ elif motion=="pulse":
180
+ cc=shade(col,42*tri(t,nf)); setrim(lambda i,p:cc)
181
+ elif motion=="blink":
182
+ cc=col if t%2==0 else shade(col,-60); setrim(lambda i,p:cc)
183
+ elif motion in("arc","scan","spin"):
184
+ win={"arc":80,"scan":34,"spin":54}[motion]; a0=(t/nf)*2*math.pi; w=math.radians(win)
185
+ setrim(lambda i,p:(P["acc_hot"] if min((ANG[p]-a0)%(2*math.pi),(a0-ANG[p])%(2*math.pi))<=w/2 else P["acc_dim"]))
186
+ elif motion in("fill","boot"):
187
+ prog=(t+1)/nf; setrim(lambda i,p:(col if (i/max(1,n))<=prog else P["acc_dim"]))
188
+ elif motion=="dualarc":
189
+ a0=(t/nf)*2*math.pi; w=math.radians(70)
190
+ def cm(i,p):
191
+ for b in(a0,a0+math.pi):
192
+ if min((ANG[p]-b)%(2*math.pi),(b-ANG[p])%(2*math.pi))<=w/2: return P["acc_hot"]
193
+ return P["acc_dim"]
194
+ setrim(cm)
195
+ elif motion=="dots":
196
+ cc=shade(col,14*tri(t,nf)); setrim(lambda i,p:cc)
197
+ elif motion=="sparkin":
198
+ cc=shade(col,16*tri(t,nf)); setrim(lambda i,p:cc)
199
+ elif motion=="celebrate":
200
+ cyc=[P["green"],P["amber"],P["accent"],P["acc_spark"]]; cc=cyc[t%len(cyc)]; setrim(lambda i,p:cc)
201
+ else: # sparkout + fallbacks: steady rim
202
+ setrim(lambda i,p:col)
203
+ # ---- personalized expression (eyes + mouth per state) ----
204
+ blink = (nf>=6 and t==nf-1) # a blink each loop on longer states
205
+ eyeCov=(ctx["prop"] in EYEPROPS) or ctx.get("specs")
206
+ mouthCov=(ctx["prop"] in MOUTHPROPS) or ctx.get("mask")
207
+ if not eyeCov:
208
+ draw_eyes_expr(px,eyemode,t,nf,SK,lash,blink)
209
+ if not mouthCov:
210
+ mcol=F._rgb('c0392b') if ctx.get("mouth")=="lips" else None
211
+ draw_mouth_expr(px,mouthmode,t,SK,mcol)
212
+ if ctx.get("founder"): founder_anim(px,t,nf) # founder signature anim
213
+ else: prop_anim(px, ctx["prop"], t, nf, ctx.get("propcol"))
214
+ if badge:
215
+ nm,bg,fg=badge; put_badge(px,nm,P[bg],P[fg])
216
+ out.append(px)
217
+ return out, fps
218
+
219
+ def gen_agent(role, accent, gender, params, scale=8):
220
+ P=palette(accent)
221
+ base=F.face(accent, gender=gender, **params)
222
+ rim_live=[p for p in RIM if base.get(p) in (F._rgb(accent), shade(F._rgb(accent),-30))]
223
+ ctx={"skin":params.get("skin","mid"),"gender":gender,"prop":params.get("prop"),
224
+ "mouth":params.get("mouth"),"propcol":params.get("propcol"),
225
+ "specs":params.get("specs"),"mask":params.get("mask"),"founder":(role=="founder")}
226
+ out=os.path.join(OUT_BASE, role); os.makedirs(out, exist_ok=True)
227
+ # wipe old O+1 agent files
228
+ for fn in os.listdir(out):
229
+ if fn.endswith(('.png','.svg','.json')): os.remove(os.path.join(out,fn))
230
+ meta={"title":role.upper(),"accent":accent,"gender":gender,"W":W,"H":H,"scale":scale,"groups":GROUPS,"states":{}}
231
+ def render(px): return F.render(px,scale)
232
+ for s in CATALOG:
233
+ frs,fps=frames(base,rim_live,s,P,ctx)
234
+ imgs=[render(px) for px in frs]
235
+ w=sum(i.width for i in imgs); strip=Image.new("RGBA",(w,imgs[0].height),(0,0,0,0)); x=0
236
+ for i in imgs: strip.paste(i,(x,0)); x+=i.width
237
+ strip.save(os.path.join(out,f"anim-{s}.png"))
238
+ render(frs[0]).save(os.path.join(out,f"still-{s}.png"))
239
+ grp,role_,motion,badge,_=CATALOG[s]
240
+ meta["states"][s]={"group":grp,"frames":len(frs),"fps":fps,"motion":motion,"badge":badge[0] if badge else None}
241
+ # sprite sheet (8x4, representative frame)
242
+ def rep(m,n): return {"blink":0,"fill":int(n*.62),"boot":int(n*.62),"arc":n//4,"scan":n//4,"spin":n//4,"dualarc":n//4,"dots":1}.get(m,n//2)
243
+ cols,rows,cell=8,4,6
244
+ tw=W*cell*0+W; # overview uses small renders
245
+ cellimg=lambda px: F.render(px, 5)
246
+ order=[s for g in GROUPS for s in CATALOG if CATALOG[s][0]==g]
247
+ tile=W*5; pad=6
248
+ sheet=Image.new("RGBA",(cols*tile+(cols+1)*pad, rows*(H*5)+(rows+1)*pad),(16,16,24,255))
249
+ for i,s in enumerate(order):
250
+ frs,_=frames(base,rim_live,s,P,ctx); im=cellimg(frs[rep(CATALOG[s][2],len(frs))])
251
+ X=pad+(i%cols)*(tile+pad); Y=pad+(i//cols)*(H*5+pad); sheet.paste(im,(X,Y),im)
252
+ sheet.save(os.path.join(out,"sprite-sheet.png"))
253
+ json.dump(meta, open(os.path.join(out,"states.json"),"w"), indent=1)
254
+ return meta
255
+
256
+ if __name__=="__main__":
257
+ only=sys.argv[1] if len(sys.argv)>1 else None
258
+ for role,acc,g,p in list(F.CAST)+[F.FOUNDER]:
259
+ r=role.lower()
260
+ if only and r!=only: continue
261
+ m=gen_agent(r,acc,g,p)
262
+ print(f"{role} {acc} {g}: {len(m['states'])} states -> brand/{r}/")