novac 2.3.0 → 2.4.0

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,575 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * kitlife — Conway's Game of Life
5
+ *
6
+ * Full interactive terminal Life sim with:
7
+ * - Toroidal (wrapping) or bounded grid
8
+ * - Speed control (1–10)
9
+ * - 30+ named preset patterns
10
+ * - Draw mode: place/erase cells with cursor
11
+ * - Stats: generation, population, births, deaths, peak
12
+ * - Pattern library browser
13
+ * - Random fill
14
+ * - Step mode (advance one generation at a time)
15
+ *
16
+ * kitdef API:
17
+ * .start() reset to blank/preset
18
+ * .input(key) see controls below
19
+ * .tick(dt) advance simulation
20
+ * .frame() → ANSI string
21
+ * .state → 'running'|'paused'|'drawing'
22
+ * .score → generation count
23
+ *
24
+ * Controls:
25
+ * SPACE pause/resume
26
+ * ENTER step one generation (while paused)
27
+ * Arrow keys move cursor (in draw/pattern mode)
28
+ * D toggle draw mode (click cells alive/dead)
29
+ * X toggle cell under cursor (draw mode)
30
+ * C clear grid
31
+ * R random fill
32
+ * +/- speed up / slow down
33
+ * P open pattern browser
34
+ * T toggle toroidal wrapping
35
+ * W/H (with shift) resize grid (cycle presets)
36
+ */
37
+
38
+ // ─── ANSI / CANVAS ───────────────────────────────────────────────────────────
39
+
40
+ const FC = {
41
+ blk:30,red:31,grn:32,yel:33,blu:34,mag:35,cyn:36,wht:37,
42
+ BLK:90,RED:91,GRN:92,YEL:93,BLU:94,MAG:95,CYN:96,WHT:97,
43
+ };
44
+ const BC = {
45
+ blk:40,red:41,grn:42,yel:43,blu:44,mag:45,cyn:46,wht:47,
46
+ BLK:100,RED:101,GRN:102,YEL:103,BLU:104,MAG:105,CYN:106,WHT:107,
47
+ };
48
+ const RST = '\x1b[0m';
49
+ const BLD = '\x1b[1m';
50
+
51
+ function makeCanvas(W, H) {
52
+ const ch = Array.from({length:H}, () => new Array(W).fill(' '));
53
+ const fg = Array.from({length:H}, () => new Array(W).fill(FC.wht));
54
+ const bg = Array.from({length:H}, () => new Array(W).fill(BC.blk));
55
+ const cv = {
56
+ W, H,
57
+ clear(c=' ', f=FC.wht, b=BC.blk) {
58
+ for (let y=0;y<H;y++) for (let x=0;x<W;x++) { ch[y][x]=c;fg[y][x]=f;bg[y][x]=b; }
59
+ },
60
+ set(x, y, c, f=FC.wht, b=BC.blk) {
61
+ if (x<0||x>=W||y<0||y>=H) return;
62
+ ch[y][x]=(String(c)[0])||' '; fg[y][x]=f; bg[y][x]=b;
63
+ },
64
+ put(x, y, s, f=FC.wht, b=BC.blk) {
65
+ for (let i=0;i<s.length;i++) cv.set(x+i, y, s[i], f, b);
66
+ },
67
+ fill(x, y, w, h, c, f=FC.wht, b=BC.blk) {
68
+ for (let dy=0;dy<h;dy++) for (let dx=0;dx<w;dx++) cv.set(x+dx,y+dy,c,f,b);
69
+ },
70
+ box(x, y, w, h, f=FC.wht, b=BC.blk) {
71
+ cv.put(x, y, '┌'+'─'.repeat(w-2)+'┐', f, b);
72
+ for (let dy=1;dy<h-1;dy++) { cv.set(x,y+dy,'│',f,b); cv.set(x+w-1,y+dy,'│',f,b); }
73
+ cv.put(x, y+h-1, '└'+'─'.repeat(w-2)+'┘', f, b);
74
+ },
75
+ render() {
76
+ const lines = [];
77
+ for (let y=0;y<H;y++) {
78
+ let row='', cf=-1, cb=-1;
79
+ for (let x=0;x<W;x++) {
80
+ const f=fg[y][x], b=bg[y][x];
81
+ if (f!==cf||b!==cb) { row+=`\x1b[${f};${b}m`; cf=f; cb=b; }
82
+ row+=ch[y][x];
83
+ }
84
+ lines.push(row+RST);
85
+ }
86
+ return lines.join('\n');
87
+ },
88
+ };
89
+ return cv;
90
+ }
91
+
92
+ function makeEmitter() {
93
+ const L = new Map();
94
+ return {
95
+ on(ev,fn) { if(!L.has(ev)) L.set(ev,[]); L.get(ev).push(fn); },
96
+ off(ev,fn) { if(L.has(ev)) L.set(ev,L.get(ev).filter(f=>f!==fn)); },
97
+ emit(ev,...a) { (L.get(ev)??[]).forEach(fn=>fn(...a)); },
98
+ };
99
+ }
100
+
101
+ const rng = n => Math.random()*n|0;
102
+ const clamp = (x,lo,hi) => Math.max(lo,Math.min(hi,x));
103
+
104
+ // ─── PATTERN LIBRARY ─────────────────────────────────────────────────────────
105
+ // Patterns stored as arrays of [col, row] offsets from center, or RLE strings.
106
+ // Using coordinate arrays for simplicity.
107
+
108
+ const PATTERNS = {
109
+ // ── Still lifes ──
110
+ 'Block': {
111
+ cells: [[0,0],[1,0],[0,1],[1,1]],
112
+ desc: 'Simplest still life',
113
+ },
114
+ 'Beehive': {
115
+ cells: [[1,0],[2,0],[0,1],[3,1],[1,2],[2,2]],
116
+ desc: 'Common still life',
117
+ },
118
+ 'Loaf': {
119
+ cells: [[1,0],[2,0],[0,1],[3,1],[1,2],[3,2],[2,3]],
120
+ desc: 'Still life',
121
+ },
122
+ 'Boat': {
123
+ cells: [[0,0],[1,0],[0,1],[2,1],[1,2]],
124
+ desc: 'Still life',
125
+ },
126
+
127
+ // ── Oscillators ──
128
+ 'Blinker': {
129
+ cells: [[0,1],[1,1],[2,1]],
130
+ desc: 'Period-2 oscillator',
131
+ },
132
+ 'Toad': {
133
+ cells: [[1,0],[2,0],[3,0],[0,1],[1,1],[2,1]],
134
+ desc: 'Period-2 oscillator',
135
+ },
136
+ 'Beacon': {
137
+ cells: [[0,0],[1,0],[0,1],[3,2],[2,3],[3,3]],
138
+ desc: 'Period-2 oscillator',
139
+ },
140
+ 'Pulsar': {
141
+ cells: [
142
+ [2,0],[3,0],[4,0],[8,0],[9,0],[10,0],
143
+ [0,2],[5,2],[7,2],[12,2],
144
+ [0,3],[5,3],[7,3],[12,3],
145
+ [0,4],[5,4],[7,4],[12,4],
146
+ [2,5],[3,5],[4,5],[8,5],[9,5],[10,5],
147
+ [2,7],[3,7],[4,7],[8,7],[9,7],[10,7],
148
+ [0,8],[5,8],[7,8],[12,8],
149
+ [0,9],[5,9],[7,9],[12,9],
150
+ [0,10],[5,10],[7,10],[12,10],
151
+ [2,12],[3,12],[4,12],[8,12],[9,12],[10,12],
152
+ ],
153
+ desc: 'Period-3 oscillator',
154
+ },
155
+ 'Pentadecathlon': {
156
+ cells: [[1,0],[2,0],[0,1],[3,1],[1,2],[2,2],[1,3],[2,3],[1,4],[2,4],[1,5],[2,5],[0,6],[3,6],[1,7],[2,7]],
157
+ desc: 'Period-15 oscillator',
158
+ },
159
+ 'Clock': {
160
+ cells: [[1,0],[2,1],[0,1],[1,2],[2,2],[3,1]],
161
+ desc: 'Period-2 oscillator',
162
+ },
163
+
164
+ // ── Spaceships ──
165
+ 'Glider': {
166
+ cells: [[1,0],[2,1],[0,2],[1,2],[2,2]],
167
+ desc: 'Simplest spaceship',
168
+ },
169
+ 'LWSS': {
170
+ cells: [[1,0],[4,0],[0,1],[0,2],[4,2],[0,3],[1,3],[2,3],[3,3]],
171
+ desc: 'Light-weight spaceship',
172
+ },
173
+ 'MWSS': {
174
+ cells: [[2,0],[0,1],[4,1],[0,2],[0,3],[4,3],[0,4],[1,4],[2,4],[3,4]],
175
+ desc: 'Middle-weight spaceship',
176
+ },
177
+ 'HWSS': {
178
+ cells: [[2,0],[3,0],[0,1],[5,1],[0,2],[0,3],[5,3],[0,4],[1,4],[2,4],[3,4],[4,4]],
179
+ desc: 'Heavy-weight spaceship',
180
+ },
181
+
182
+ // ── Guns ──
183
+ 'Gosper Glider Gun': {
184
+ cells: [
185
+ [24,0],
186
+ [22,1],[24,1],
187
+ [12,2],[13,2],[20,2],[21,2],[34,2],[35,2],
188
+ [11,3],[15,3],[20,3],[21,3],[34,3],[35,3],
189
+ [0,4],[1,4],[10,4],[16,4],[20,4],[21,4],
190
+ [0,5],[1,5],[10,5],[14,5],[16,5],[17,5],[22,5],[24,5],
191
+ [10,6],[16,6],[24,6],
192
+ [11,7],[15,7],
193
+ [12,8],[13,8],
194
+ ],
195
+ desc: 'Produces a glider every 30 generations',
196
+ },
197
+
198
+ // ── Methuselahs ──
199
+ 'R-pentomino': {
200
+ cells: [[1,0],[2,0],[0,1],[1,1],[1,2]],
201
+ desc: 'Lives 1103 generations',
202
+ },
203
+ 'Diehard': {
204
+ cells: [[6,0],[0,1],[1,1],[1,2],[5,2],[6,2],[7,2]],
205
+ desc: 'Dies after 130 generations',
206
+ },
207
+ 'Acorn': {
208
+ cells: [[1,0],[3,1],[0,2],[1,2],[4,2],[5,2],[6,2]],
209
+ desc: 'Lives 5206 generations',
210
+ },
211
+
212
+ // ── Fun patterns ──
213
+ 'Glider Fleet': {
214
+ cells: [
215
+ [1,0],[2,1],[0,2],[1,2],[2,2],
216
+ [11,0],[12,1],[10,2],[11,2],[12,2],
217
+ [21,0],[22,1],[20,2],[21,2],[22,2],
218
+ [1,10],[2,11],[0,12],[1,12],[2,12],
219
+ [11,10],[12,11],[10,12],[11,12],[12,12],
220
+ ],
221
+ desc: 'Five gliders in formation',
222
+ },
223
+ 'Cross': {
224
+ cells: [
225
+ [2,0],[2,1],[0,2],[1,2],[2,2],[3,2],[4,2],[2,3],[2,4],
226
+ ],
227
+ desc: 'Simple cross — evolves interestingly',
228
+ },
229
+ 'Pi heptomino': {
230
+ cells: [[0,0],[1,0],[2,0],[0,1],[2,1],[0,2],[1,2],[2,2]],
231
+ desc: 'Chaotic methuselah',
232
+ },
233
+ 'Infinite growth': {
234
+ cells: [
235
+ [0,0],[1,0],[2,0],[3,0],[4,0],[5,0],[6,0],[7,0],
236
+ [9,0],[10,0],[11,0],[12,0],[13,0],
237
+ [17,0],[18,0],[19,0],
238
+ [26,0],[27,0],[28,0],[29,0],[30,0],[31,0],[32,0],
239
+ [34,0],[35,0],[36,0],[37,0],[38,0],
240
+ ],
241
+ desc: 'Population grows without bound',
242
+ },
243
+ };
244
+
245
+ const PATTERN_KEYS = Object.keys(PATTERNS);
246
+
247
+ // ─── LIFE ENGINE ─────────────────────────────────────────────────────────────
248
+
249
+ function makeLife(opts = {}) {
250
+ const GW = opts.width ?? 80;
251
+ const GH = opts.height ?? 40;
252
+ const em = makeEmitter();
253
+
254
+ // Use two flat Uint8Arrays and ping-pong
255
+ let cells = new Uint8Array(GW * GH);
256
+ let next_ = new Uint8Array(GW * GH);
257
+
258
+ let gen, population, births, deaths, peakPop;
259
+ let toroidal, drawMode, speed, tickAcc, state;
260
+ let curX, curY;
261
+ let patternMenu, patternIdx;
262
+ let score;
263
+ let ageCells; // for color-by-age rendering
264
+
265
+ function idx(x, y) { return y * GW + x; }
266
+ function wrap(x, y) {
267
+ if (toroidal) return [(x + GW) % GW, (y + GH) % GH];
268
+ return [clamp(x, 0, GW-1), clamp(y, 0, GH-1)];
269
+ }
270
+
271
+ function aliveAt(x, y) {
272
+ if (!toroidal && (x < 0 || x >= GW || y < 0 || y >= GH)) return 0;
273
+ const [wx, wy] = wrap(x, y);
274
+ return cells[idx(wx, wy)];
275
+ }
276
+
277
+ function countNeighbors(x, y) {
278
+ return aliveAt(x-1,y-1) + aliveAt(x,y-1) + aliveAt(x+1,y-1) +
279
+ aliveAt(x-1,y) + aliveAt(x+1,y) +
280
+ aliveAt(x-1,y+1) + aliveAt(x,y+1) + aliveAt(x+1,y+1);
281
+ }
282
+
283
+ function step() {
284
+ let born = 0, died = 0;
285
+ for (let y = 0; y < GH; y++) {
286
+ for (let x = 0; x < GW; x++) {
287
+ const n = countNeighbors(x, y);
288
+ const was = cells[idx(x, y)];
289
+ let now = 0;
290
+ if (was) {
291
+ now = (n === 2 || n === 3) ? 1 : 0;
292
+ } else {
293
+ now = n === 3 ? 1 : 0;
294
+ }
295
+ next_[idx(x, y)] = now;
296
+ if (!was && now) { born++; ageCells[idx(x,y)] = 0; }
297
+ if ( was && !now) { died++; ageCells[idx(x,y)] = 0; }
298
+ if ( was && now) ageCells[idx(x,y)] = Math.min(ageCells[idx(x,y)] + 1, 15);
299
+ }
300
+ }
301
+ // swap buffers
302
+ [cells, next_] = [next_, cells];
303
+ births += born;
304
+ deaths += died;
305
+ gen += 1;
306
+ population = cells.reduce((s, v) => s + v, 0);
307
+ peakPop = Math.max(peakPop, population);
308
+ score = gen;
309
+ em.emit('score', score);
310
+ }
311
+
312
+ function setCell(x, y, v) {
313
+ if (x < 0 || x >= GW || y < 0 || y >= GH) return;
314
+ cells[idx(x, y)] = v;
315
+ if (v) ageCells[idx(x, y)] = 0;
316
+ }
317
+
318
+ function placePattern(key, cx, cy) {
319
+ const pat = PATTERNS[key];
320
+ if (!pat) return;
321
+ for (const [dx, dy] of pat.cells) {
322
+ setCell(cx + dx, cy + dy, 1);
323
+ }
324
+ population = cells.reduce((s,v)=>s+v,0);
325
+ }
326
+
327
+ function randomFill(density = 0.3) {
328
+ for (let i = 0; i < GW * GH; i++) {
329
+ cells[i] = Math.random() < density ? 1 : 0;
330
+ ageCells[i] = 0;
331
+ }
332
+ population = cells.reduce((s,v)=>s+v,0);
333
+ }
334
+
335
+ function clearGrid() {
336
+ cells.fill(0);
337
+ ageCells.fill(0);
338
+ population = 0;
339
+ }
340
+
341
+ function start(presetKey) {
342
+ cells = new Uint8Array(GW * GH);
343
+ next_ = new Uint8Array(GW * GH);
344
+ ageCells = new Uint8Array(GW * GH);
345
+ gen = 0; births = 0; deaths = 0; population = 0; peakPop = 0;
346
+ toroidal = opts.toroidal ?? true;
347
+ drawMode = false;
348
+ speed = opts.speed ?? 5; // 1..10
349
+ tickAcc = 0;
350
+ state = 'running';
351
+ curX = GW >> 1;
352
+ curY = GH >> 1;
353
+ patternMenu = false;
354
+ patternIdx = 0;
355
+ score = 0;
356
+
357
+ const key = presetKey ?? opts.preset ?? 'Gosper Glider Gun';
358
+ if (PATTERNS[key]) {
359
+ const cx = (GW >> 1) - 20;
360
+ const cy = (GH >> 1) - 6;
361
+ placePattern(key, cx, cy);
362
+ } else {
363
+ randomFill();
364
+ }
365
+ population = cells.reduce((s,v)=>s+v,0);
366
+ em.emit('start');
367
+ }
368
+
369
+ // ── speed → ms per generation ──────────────────────────────────────────────
370
+ function msPerGen() {
371
+ // speed 1 = 1000ms, speed 10 = 30ms
372
+ return Math.round(1000 / (speed * speed * 0.1 + 0.1));
373
+ }
374
+
375
+ // ── input ──────────────────────────────────────────────────────────────────
376
+ function input(key) {
377
+ // Pattern menu navigation
378
+ if (patternMenu) {
379
+ if (key === 'escape' || key === 'p') { patternMenu = false; return; }
380
+ if (key === 'up') { patternIdx = (patternIdx - 1 + PATTERN_KEYS.length) % PATTERN_KEYS.length; return; }
381
+ if (key === 'down') { patternIdx = (patternIdx + 1) % PATTERN_KEYS.length; return; }
382
+ if (key === 'enter' || key === 'space') {
383
+ clearGrid();
384
+ placePattern(PATTERN_KEYS[patternIdx], (GW>>1)-10, (GH>>1)-7);
385
+ gen=0; births=0; deaths=0; score=0;
386
+ patternMenu = false;
387
+ state = 'paused';
388
+ }
389
+ return;
390
+ }
391
+
392
+ if (key === 'p') { patternMenu = true; return; }
393
+ if (key === 'space') { state = state === 'running' ? 'paused' : 'running'; return; }
394
+ if (key === 'enter' && state==='paused') { step(); return; }
395
+ if (key === 'd') { drawMode = !drawMode; return; }
396
+ if (key === 't') { toroidal = !toroidal; return; }
397
+ if (key === 'c') { clearGrid(); gen=0; births=0; deaths=0; return; }
398
+ if (key === 'r') { randomFill(); gen=0; births=0; deaths=0; return; }
399
+ if (key === '+' || key === '=') { speed = clamp(speed+1, 1, 10); return; }
400
+ if (key === '-') { speed = clamp(speed-1, 1, 10); return; }
401
+
402
+ // cursor movement
403
+ const step_ = drawMode ? 1 : 3;
404
+ if (key === 'up') curY = clamp(curY - step_, 0, GH-1);
405
+ if (key === 'down') curY = clamp(curY + step_, 0, GH-1);
406
+ if (key === 'left') curX = clamp(curX - step_, 0, GW-1);
407
+ if (key === 'right') curX = clamp(curX + step_, 0, GW-1);
408
+
409
+ // draw mode: x toggles cell, f fills 3x3, e erases 3x3
410
+ if (drawMode) {
411
+ if (key === 'x' || key === 'z') {
412
+ setCell(curX, curY, cells[idx(curX,curY)] ? 0 : 1);
413
+ population = cells.reduce((s,v)=>s+v,0);
414
+ }
415
+ if (key === 'f') {
416
+ for (let dy=-1;dy<=1;dy++) for (let dx=-1;dx<=1;dx++) setCell(curX+dx, curY+dy, 1);
417
+ population = cells.reduce((s,v)=>s+v,0);
418
+ }
419
+ if (key === 'e') {
420
+ for (let dy=-2;dy<=2;dy++) for (let dx=-2;dx<=2;dx++) setCell(curX+dx, curY+dy, 0);
421
+ population = cells.reduce((s,v)=>s+v,0);
422
+ }
423
+ }
424
+ }
425
+
426
+ // ── tick ───────────────────────────────────────────────────────────────────
427
+ function tick(dt = 16) {
428
+ if (state !== 'running') return;
429
+ tickAcc += dt;
430
+ const threshold = msPerGen();
431
+ while (tickAcc >= threshold) {
432
+ tickAcc -= threshold;
433
+ step();
434
+ }
435
+ }
436
+
437
+ // ── frame ──────────────────────────────────────────────────────────────────
438
+
439
+ // Map age → color (young=bright, old=dim)
440
+ const AGE_COLORS = [
441
+ FC.WHT, FC.CYN, FC.GRN, FC.YEL, FC.yel,
442
+ FC.grn, FC.cyn, FC.BLK, FC.BLK, FC.BLK,
443
+ ];
444
+
445
+ function frame() {
446
+ // We render the grid using half-block trick: each terminal row = 2 grid rows
447
+ // '▀' = top cell alive, '▄' = bottom cell alive, '█' = both, ' ' = neither
448
+ const PANEL_W = 22;
449
+ const DISP_W = GW; // grid cols = terminal cols (1 char per cell for speed)
450
+ const DISP_H = Math.ceil(GH / 2); // half-block rows
451
+ const TOTAL_W = DISP_W + PANEL_W + 1;
452
+ const TOTAL_H = DISP_H + 3;
453
+
454
+ const cv = makeCanvas(TOTAL_W, TOTAL_H);
455
+ cv.clear(' ', FC.wht, BC.blk);
456
+
457
+ // ── header ──
458
+ const stateStr = patternMenu ? 'PATTERN' : state.toUpperCase();
459
+ const modeStr = drawMode ? ' DRAW' : '';
460
+ cv.put(0, 0,
461
+ ` LIFE Gen:${String(gen).padStart(6,' ')} Pop:${String(population).padStart(5,' ')}` +
462
+ ` Spd:${speed} ${stateStr}${modeStr} ${toroidal?'⟳ Wrap':'■ Bound'}`,
463
+ FC.YEL, BC.blk);
464
+ cv.put(0, 1, '─'.repeat(Math.min(TOTAL_W, 80)), FC.BLK, BC.blk);
465
+
466
+ if (patternMenu) {
467
+ // ── pattern browser overlay ──
468
+ const W2=50, H2=Math.min(PATTERN_KEYS.length+4, 30);
469
+ const ox=(TOTAL_W-W2)>>1, oy=2;
470
+ cv.fill(ox, oy, W2, H2, ' ', FC.wht, BC.blk);
471
+ cv.box(ox, oy, W2, H2, FC.CYN, BC.blk);
472
+ cv.put(ox+2, oy, ' PATTERN LIBRARY ', FC.YEL, BC.blk);
473
+ const visible = 20;
474
+ const start_ = Math.max(0, patternIdx - (visible>>1));
475
+ for (let i=0; i<visible; i++) {
476
+ const ki = start_ + i;
477
+ if (ki >= PATTERN_KEYS.length) break;
478
+ const k = PATTERN_KEYS[ki];
479
+ const sel = ki === patternIdx;
480
+ const pat = PATTERNS[k];
481
+ cv.put(ox+2, oy+1+i,
482
+ `${sel?'▶':' '} ${k.padEnd(22,' ')} ${pat.desc.slice(0,18)}`,
483
+ sel ? FC.YEL : FC.wht, BC.blk);
484
+ }
485
+ cv.put(ox+2, oy+H2-2, '↑↓ browse ENTER place ESC cancel', FC.BLK, BC.blk);
486
+ }
487
+
488
+ // ── grid ──
489
+ for (let row = 0; row < DISP_H; row++) {
490
+ const y0 = row * 2;
491
+ const y1 = y0 + 1;
492
+ for (let x = 0; x < GW; x++) {
493
+ const a = cells[idx(x, y0)];
494
+ const b = (y1 < GH) ? cells[idx(x, y1)] : 0;
495
+ const age = ageCells[idx(x, y0)];
496
+ const col = AGE_COLORS[Math.min(age, AGE_COLORS.length-1)];
497
+ const isCur = drawMode && x === curX && (y0 === curY || y1 === curY);
498
+ if (isCur) {
499
+ cv.set(x, row+2, '◎', FC.RED, BC.blk);
500
+ } else if (a && b) {
501
+ cv.set(x, row+2, '█', col, BC.blk);
502
+ } else if (a) {
503
+ cv.set(x, row+2, '▀', col, BC.blk);
504
+ } else if (b) {
505
+ cv.set(x, row+2, '▄', col, BC.blk);
506
+ } else {
507
+ cv.set(x, row+2, ' ', FC.BLK, BC.blk);
508
+ }
509
+ }
510
+ }
511
+
512
+ // ── side panel ──
513
+ const px = DISP_W + 1;
514
+ let py = 2;
515
+ const P = (s, f=FC.wht) => { cv.put(px, py++, s.slice(0,PANEL_W), f, BC.blk); };
516
+
517
+ P(`┌─ STATS ──────────┐`, FC.BLK);
518
+ P(` Gen: ${String(gen).padStart(9,' ')}`, FC.CYN);
519
+ P(` Pop: ${String(population).padStart(9,' ')}`, FC.GRN);
520
+ P(` Peak: ${String(peakPop).padStart(9,' ')}`, FC.YEL);
521
+ P(` Born: ${String(births).padStart(9,' ')}`, FC.GRN);
522
+ P(` Died: ${String(deaths).padStart(9,' ')}`, FC.RED);
523
+ P(` Speed: ${'▰'.repeat(speed)}${'▱'.repeat(10-speed)}`, FC.MAG);
524
+ P(` ${toroidal?'Toroidal wrap ':'Bounded grid '}`, FC.BLK);
525
+ P(`└──────────────────┘`, FC.BLK);
526
+ py++;
527
+ P(`┌─ CONTROLS ───────┐`, FC.BLK);
528
+ P(` SPACE pause/run `, FC.wht);
529
+ P(` ENTER step `, FC.wht);
530
+ P(` +/- speed `, FC.wht);
531
+ P(` D draw mode `, drawMode?FC.YEL:FC.wht);
532
+ P(` X toggle cell`, FC.wht);
533
+ P(` F/E fill/erase`, FC.wht);
534
+ P(` C clear `, FC.wht);
535
+ P(` R random `, FC.wht);
536
+ P(` P patterns `, FC.wht);
537
+ P(` T wrap `, FC.wht);
538
+ P(`└──────────────────┘`, FC.BLK);
539
+
540
+ // legend
541
+ cv.put(0, TOTAL_H-1,
542
+ ' New:' + `\x1b[${FC.WHT}m██\x1b[0m` +
543
+ ' Mid:' + `\x1b[${FC.GRN}m██\x1b[0m` +
544
+ ' Old:' + `\x1b[${FC.BLK}m██\x1b[0m` +
545
+ ` Cursor:${curX},${curY}`,
546
+ FC.BLK, BC.blk);
547
+
548
+ return cv.render();
549
+ }
550
+
551
+ return {
552
+ start, input, tick, frame,
553
+ on: em.on.bind(em),
554
+ off: em.off.bind(em),
555
+ get state() { return state; },
556
+ get score() { return score; },
557
+ get gen() { return gen; },
558
+ get pop() { return population; },
559
+ patterns: PATTERN_KEYS,
560
+ };
561
+ }
562
+
563
+ // ─── EXPORT ──────────────────────────────────────────────────────────────────
564
+
565
+ const kitdef = {
566
+ Life: (opts) => {
567
+ const g = makeLife(opts);
568
+ g.start(opts?.preset);
569
+ return g;
570
+ },
571
+ patterns: PATTERN_KEYS,
572
+ makeLife, PATTERNS
573
+ };
574
+
575
+ module.exports = { kitdef };