arbiter-ai 1.0.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.
Files changed (51) hide show
  1. package/README.md +41 -0
  2. package/assets/jerom_16x16.png +0 -0
  3. package/dist/arbiter.d.ts +43 -0
  4. package/dist/arbiter.js +486 -0
  5. package/dist/context-analyzer.d.ts +15 -0
  6. package/dist/context-analyzer.js +603 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +165 -0
  9. package/dist/orchestrator.d.ts +31 -0
  10. package/dist/orchestrator.js +227 -0
  11. package/dist/router.d.ts +187 -0
  12. package/dist/router.js +1135 -0
  13. package/dist/router.test.d.ts +15 -0
  14. package/dist/router.test.js +95 -0
  15. package/dist/session-persistence.d.ts +9 -0
  16. package/dist/session-persistence.js +63 -0
  17. package/dist/session-persistence.test.d.ts +1 -0
  18. package/dist/session-persistence.test.js +165 -0
  19. package/dist/sound.d.ts +31 -0
  20. package/dist/sound.js +50 -0
  21. package/dist/state.d.ts +72 -0
  22. package/dist/state.js +107 -0
  23. package/dist/state.test.d.ts +1 -0
  24. package/dist/state.test.js +194 -0
  25. package/dist/test-headless.d.ts +1 -0
  26. package/dist/test-headless.js +155 -0
  27. package/dist/tui/index.d.ts +14 -0
  28. package/dist/tui/index.js +17 -0
  29. package/dist/tui/layout.d.ts +30 -0
  30. package/dist/tui/layout.js +200 -0
  31. package/dist/tui/render.d.ts +57 -0
  32. package/dist/tui/render.js +266 -0
  33. package/dist/tui/scene.d.ts +64 -0
  34. package/dist/tui/scene.js +366 -0
  35. package/dist/tui/screens/CharacterSelect-termkit.d.ts +18 -0
  36. package/dist/tui/screens/CharacterSelect-termkit.js +216 -0
  37. package/dist/tui/screens/ForestIntro-termkit.d.ts +15 -0
  38. package/dist/tui/screens/ForestIntro-termkit.js +856 -0
  39. package/dist/tui/screens/GitignoreCheck-termkit.d.ts +14 -0
  40. package/dist/tui/screens/GitignoreCheck-termkit.js +185 -0
  41. package/dist/tui/screens/TitleScreen-termkit.d.ts +14 -0
  42. package/dist/tui/screens/TitleScreen-termkit.js +132 -0
  43. package/dist/tui/screens/index.d.ts +9 -0
  44. package/dist/tui/screens/index.js +10 -0
  45. package/dist/tui/tileset.d.ts +97 -0
  46. package/dist/tui/tileset.js +237 -0
  47. package/dist/tui/tui-termkit.d.ts +34 -0
  48. package/dist/tui/tui-termkit.js +2602 -0
  49. package/dist/tui/types.d.ts +41 -0
  50. package/dist/tui/types.js +4 -0
  51. package/package.json +71 -0
@@ -0,0 +1,856 @@
1
+ /**
2
+ * Forest Intro Screen (terminal-kit version)
3
+ *
4
+ * A narrative intro screen between character select and main TUI.
5
+ * Player controls their character with arrow keys to walk through the forest.
6
+ * Uses terminal-kit with Strategy 5 (minimal redraws) for flicker-free rendering.
7
+ */
8
+ import termKit from 'terminal-kit';
9
+ import { playSfx } from '../../sound.js';
10
+ import { CHAR_HEIGHT, compositeTiles, extractTile, loadTileset, RESET, renderTile, TILE, TILE_SIZE, } from '../tileset.js';
11
+ const term = termKit.terminal;
12
+ // ============================================================================
13
+ // Constants
14
+ // ============================================================================
15
+ // Scene dimensions: 7 tiles wide x 5 tiles tall
16
+ const SCENE_WIDTH_TILES = 7;
17
+ const SCENE_HEIGHT_TILES = 5;
18
+ const SCENE_WIDTH_CHARS = SCENE_WIDTH_TILES * TILE_SIZE; // 112 chars
19
+ const SCENE_HEIGHT_CHARS = SCENE_HEIGHT_TILES * CHAR_HEIGHT; // 40 rows
20
+ // ANSI codes
21
+ const BOLD = '\x1b[1m';
22
+ const WHITE = '\x1b[97m';
23
+ const DIM = '\x1b[2m';
24
+ // True color theme colors (RGB)
25
+ const COLOR_ARBITER = '\x1b[38;2;100;255;100m'; // Green for THE ARBITER
26
+ const COLOR_WAS = '\x1b[38;2;100;200;255m'; // Blue-cyan for "WAS"
27
+ const COLOR_IS = '\x1b[38;2;200;100;255m'; // Purple for "IS"
28
+ const COLOR_DEATH = '\x1b[38;2;180;50;50m'; // Red for death messages
29
+ const COLOR_RETREAT = '\x1b[38;2;100;180;255m'; // Blue for retreat messages
30
+ // Dialogue box tile indices (2x2 tile message window)
31
+ const DIALOGUE_TILES = {
32
+ TOP_LEFT: 38,
33
+ TOP_RIGHT: 39,
34
+ BOTTOM_LEFT: 48,
35
+ BOTTOM_RIGHT: 49,
36
+ };
37
+ // Death screen tile indices
38
+ const DEATH_TILES = {
39
+ GRAVESTONE: 60,
40
+ SKELETON: 61,
41
+ };
42
+ // Starting position - left side of path
43
+ const START_X = 0;
44
+ const START_Y = 2; // Path row
45
+ // Sign position
46
+ const SIGN_X = 5;
47
+ const SIGN_Y = 1;
48
+ // Rat position (warning NPC)
49
+ const RAT_X = 2;
50
+ const RAT_Y = 1;
51
+ const RAT_TILE = 210;
52
+ // ============================================================================
53
+ // Collision System
54
+ // ============================================================================
55
+ /**
56
+ * Tile type constants for collision detection
57
+ */
58
+ const TILE_TYPE = {
59
+ WALKABLE: 0,
60
+ BLOCKED: 1,
61
+ SIGN: 2,
62
+ EXIT: 3,
63
+ };
64
+ /**
65
+ * Create a collision map for the forest scene
66
+ */
67
+ function createCollisionMap() {
68
+ const map = [];
69
+ for (let row = 0; row < SCENE_HEIGHT_TILES; row++) {
70
+ const mapRow = [];
71
+ for (let col = 0; col < SCENE_WIDTH_TILES; col++) {
72
+ let tileType = TILE_TYPE.WALKABLE;
73
+ // Left edge trees (except path row) - blocked
74
+ if (col === 0 && row !== 2) {
75
+ tileType = TILE_TYPE.BLOCKED;
76
+ }
77
+ // Right edge trees (except path row) - blocked
78
+ if (col === 6 && row !== 2) {
79
+ tileType = TILE_TYPE.BLOCKED;
80
+ }
81
+ // Top row: trees are blocked
82
+ if (row === 0) {
83
+ if (col === 0 || col === 1 || col === 5 || col === 6)
84
+ tileType = TILE_TYPE.BLOCKED;
85
+ if (col === 2 || col === 4)
86
+ tileType = TILE_TYPE.BLOCKED;
87
+ }
88
+ // Bottom row: trees are blocked
89
+ if (row === 4) {
90
+ if (col === 0 || col === 6)
91
+ tileType = TILE_TYPE.BLOCKED;
92
+ if (col === 1 || col === 5)
93
+ tileType = TILE_TYPE.BLOCKED;
94
+ }
95
+ // Row 1: trees on edges, sign at col 5
96
+ if (row === 1) {
97
+ if (col === 0 || col === 6)
98
+ tileType = TILE_TYPE.BLOCKED;
99
+ if (col === 5)
100
+ tileType = TILE_TYPE.SIGN;
101
+ }
102
+ // Row 3: trees on edges
103
+ if (row === 3) {
104
+ if (col === 0 || col === 6)
105
+ tileType = TILE_TYPE.BLOCKED;
106
+ }
107
+ // No exit tiles - player must walk off screen to right
108
+ // (formerly: Exit point at right edge of path (row 2, col 6))
109
+ mapRow.push(tileType);
110
+ }
111
+ map.push(mapRow);
112
+ }
113
+ return map;
114
+ }
115
+ // Pre-compute the collision map
116
+ const COLLISION_MAP = createCollisionMap();
117
+ /**
118
+ * Check if a tile position is walkable
119
+ */
120
+ function isWalkable(x, y) {
121
+ if (x < 0 || y < 0 || x >= SCENE_WIDTH_TILES || y >= SCENE_HEIGHT_TILES) {
122
+ return false;
123
+ }
124
+ const tileType = COLLISION_MAP[y][x];
125
+ return tileType !== TILE_TYPE.BLOCKED;
126
+ }
127
+ /**
128
+ * Check if position is off the screen (death zone)
129
+ */
130
+ function isOffScreen(x, y) {
131
+ return x < 0 || y < 0 || x >= SCENE_WIDTH_TILES || y >= SCENE_HEIGHT_TILES;
132
+ }
133
+ /**
134
+ * Check if position is the exit (no longer used - exit is now off-screen)
135
+ */
136
+ function _isExit(_x, _y) {
137
+ // Exit is now when player walks off-screen to the right on path row
138
+ // This function kept for compatibility but always returns false
139
+ return false;
140
+ }
141
+ /**
142
+ * Check if player is next to the sign (below or left of it)
143
+ */
144
+ function isNextToSign(x, y) {
145
+ // Below sign: (5, 2)
146
+ if (x === SIGN_X && y === SIGN_Y + 1)
147
+ return true;
148
+ // Left of sign: (4, 1)
149
+ if (x === SIGN_X - 1 && y === SIGN_Y)
150
+ return true;
151
+ return false;
152
+ }
153
+ /**
154
+ * Check if player is next to the rat (directly below it)
155
+ */
156
+ function isNextToRat(x, y) {
157
+ // Below rat: (3, 2)
158
+ return x === RAT_X && y === RAT_Y + 1;
159
+ }
160
+ // ============================================================================
161
+ // Rendering Functions
162
+ // ============================================================================
163
+ /**
164
+ * Apply rainbow colors to text
165
+ */
166
+ function rainbow(text) {
167
+ const colors = [
168
+ [255, 100, 100], // red
169
+ [255, 200, 100], // orange
170
+ [255, 255, 100], // yellow
171
+ [100, 255, 100], // green
172
+ [100, 255, 255], // cyan
173
+ [100, 100, 255], // blue
174
+ [200, 100, 255], // purple
175
+ [255, 100, 255], // magenta
176
+ ];
177
+ let result = '';
178
+ let colorIndex = 0;
179
+ for (const char of text) {
180
+ if (char === ' ') {
181
+ result += char;
182
+ }
183
+ else {
184
+ const [r, g, b] = colors[colorIndex % colors.length];
185
+ result += `\x1b[38;2;${r};${g};${b}m${char}`;
186
+ colorIndex++;
187
+ }
188
+ }
189
+ return result + RESET;
190
+ }
191
+ /**
192
+ * Strip ANSI escape codes from a string to get visible length
193
+ */
194
+ function stripAnsi(str) {
195
+ // eslint-disable-next-line no-control-regex
196
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
197
+ }
198
+ /**
199
+ * Create the forest scene layout
200
+ */
201
+ function createForestScene(characterTile, playerX, playerY) {
202
+ const scene = [];
203
+ for (let row = 0; row < SCENE_HEIGHT_TILES; row++) {
204
+ const sceneRow = [];
205
+ for (let col = 0; col < SCENE_WIDTH_TILES; col++) {
206
+ let tile = TILE.GRASS;
207
+ // Left edge trees (except path row)
208
+ if (col === 0 && row !== 2) {
209
+ tile = TILE.PINE_TREE;
210
+ }
211
+ // Right edge trees (except path row)
212
+ if (col === 6 && row !== 2) {
213
+ tile = TILE.PINE_TREE;
214
+ }
215
+ // Top row: mix of trees
216
+ if (row === 0) {
217
+ if (col === 0 || col === 1 || col === 5 || col === 6)
218
+ tile = TILE.PINE_TREE;
219
+ if (col === 2 || col === 4)
220
+ tile = TILE.BARE_TREE;
221
+ }
222
+ // Bottom row: mix of trees
223
+ if (row === 4) {
224
+ if (col === 0 || col === 6)
225
+ tile = TILE.PINE_TREE;
226
+ if (col === 1 || col === 5)
227
+ tile = TILE.BARE_TREE;
228
+ }
229
+ // Add signpost near right edge, above path
230
+ if (row === 1 && col === 5) {
231
+ tile = 63; // Signpost tile
232
+ }
233
+ // Add rat NPC above path
234
+ if (row === RAT_Y && col === RAT_X) {
235
+ tile = RAT_TILE;
236
+ }
237
+ // Middle path row (row 2) - sparse grass for path ALL THE WAY THROUGH
238
+ if (row === 2) {
239
+ tile = TILE.GRASS_SPARSE;
240
+ }
241
+ // Place character at player position
242
+ if (row === playerY &&
243
+ col === playerX &&
244
+ playerX >= 0 &&
245
+ playerX < SCENE_WIDTH_TILES &&
246
+ playerY >= 0 &&
247
+ playerY < SCENE_HEIGHT_TILES) {
248
+ tile = characterTile;
249
+ }
250
+ sceneRow.push(tile);
251
+ }
252
+ scene.push(sceneRow);
253
+ }
254
+ return scene;
255
+ }
256
+ /**
257
+ * Get the background tile index at a position (what the scene would have without the player)
258
+ */
259
+ function getBackgroundTileAt(row, col) {
260
+ let tile = TILE.GRASS;
261
+ // Left edge trees (except path row)
262
+ if (col === 0 && row !== 2) {
263
+ tile = TILE.PINE_TREE;
264
+ }
265
+ // Right edge trees (except path row)
266
+ if (col === 6 && row !== 2) {
267
+ tile = TILE.PINE_TREE;
268
+ }
269
+ // Top row: mix of trees
270
+ if (row === 0) {
271
+ if (col === 0 || col === 1 || col === 5 || col === 6)
272
+ tile = TILE.PINE_TREE;
273
+ if (col === 2 || col === 4)
274
+ tile = TILE.BARE_TREE;
275
+ }
276
+ // Bottom row: mix of trees
277
+ if (row === 4) {
278
+ if (col === 0 || col === 6)
279
+ tile = TILE.PINE_TREE;
280
+ if (col === 1 || col === 5)
281
+ tile = TILE.BARE_TREE;
282
+ }
283
+ // Add signpost near right edge, above path
284
+ if (row === 1 && col === 5) {
285
+ tile = 63; // Signpost tile
286
+ }
287
+ // Add rat NPC above path (on bare grass)
288
+ if (row === RAT_Y && col === RAT_X) {
289
+ tile = TILE.GRASS; // Rat sits on bare grass, not sparse
290
+ }
291
+ // Middle path row (row 2) - sparse grass for path ALL THE WAY THROUGH
292
+ if (row === 2) {
293
+ tile = TILE.GRASS_SPARSE;
294
+ }
295
+ return tile;
296
+ }
297
+ /**
298
+ * Render the forest scene to an array of ANSI strings (one per row)
299
+ */
300
+ function renderForestScene(tileset, characterTile, playerX, playerY) {
301
+ const trailTile = extractTile(tileset, TILE.GRASS_SPARSE);
302
+ // Create and render forest scene
303
+ const scene = createForestScene(characterTile, playerX, playerY);
304
+ // Pre-render all tiles
305
+ const renderedTiles = [];
306
+ for (let row = 0; row < scene.length; row++) {
307
+ const renderedRow = [];
308
+ for (let col = 0; col < scene[row].length; col++) {
309
+ const tileIndex = scene[row][col];
310
+ let pixels = extractTile(tileset, tileIndex);
311
+ // Composite characters/objects on appropriate background
312
+ if (tileIndex >= 80) {
313
+ // Check if this is the player character position
314
+ if (row === playerY && col === playerX) {
315
+ // Get the actual background tile at this position
316
+ const bgTileIndex = getBackgroundTileAt(row, col);
317
+ const bgTile = extractTile(tileset, bgTileIndex);
318
+ pixels = compositeTiles(pixels, bgTile, 1);
319
+ }
320
+ else if (row === RAT_Y && col === RAT_X) {
321
+ // Rat uses bare grass
322
+ const grassTile = extractTile(tileset, TILE.GRASS);
323
+ pixels = compositeTiles(pixels, grassTile, 1);
324
+ }
325
+ else {
326
+ // Other objects (like signpost) use sparse grass
327
+ pixels = compositeTiles(pixels, trailTile, 1);
328
+ }
329
+ }
330
+ renderedRow.push(renderTile(pixels));
331
+ }
332
+ renderedTiles.push(renderedRow);
333
+ }
334
+ // Build output lines
335
+ const lines = [];
336
+ for (let tileRow = 0; tileRow < scene.length; tileRow++) {
337
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
338
+ let line = '';
339
+ for (let tileCol = 0; tileCol < scene[tileRow].length; tileCol++) {
340
+ line += renderedTiles[tileRow][tileCol][charRow];
341
+ }
342
+ lines.push(line);
343
+ }
344
+ }
345
+ return lines;
346
+ }
347
+ /**
348
+ * Create middle fill row for dialogue box
349
+ */
350
+ function createMiddleFill(leftTile, charRow) {
351
+ const pixelRowTop = charRow * 2;
352
+ const pixelRowBot = pixelRowTop + 1;
353
+ let result = '';
354
+ const sampleX = 8; // Middle column
355
+ for (let x = 0; x < 16; x++) {
356
+ const topPixel = leftTile[pixelRowTop][sampleX];
357
+ const botPixel = leftTile[pixelRowBot]?.[sampleX] || topPixel;
358
+ result += `\x1b[48;2;${topPixel.r};${topPixel.g};${topPixel.b}m`;
359
+ result += `\x1b[38;2;${botPixel.r};${botPixel.g};${botPixel.b}m`;
360
+ result += '\u2584'; // Lower half block
361
+ }
362
+ result += RESET;
363
+ return result;
364
+ }
365
+ /**
366
+ * Wrap text with consistent background color
367
+ */
368
+ function wrapTextWithBg(text, bgColor) {
369
+ const bgMaintained = text.replace(/\x1b\[0m/g, `\x1b[0m${bgColor}`);
370
+ return bgColor + bgMaintained + RESET;
371
+ }
372
+ /**
373
+ * Render the dialogue box overlay as an array of strings
374
+ */
375
+ function renderDialogueBox(tileset) {
376
+ const dialogueBoxWidthTiles = 5;
377
+ // Extract dialogue tiles
378
+ const topLeft = extractTile(tileset, DIALOGUE_TILES.TOP_LEFT);
379
+ const topRight = extractTile(tileset, DIALOGUE_TILES.TOP_RIGHT);
380
+ const bottomLeft = extractTile(tileset, DIALOGUE_TILES.BOTTOM_LEFT);
381
+ const bottomRight = extractTile(tileset, DIALOGUE_TILES.BOTTOM_RIGHT);
382
+ const tlRendered = renderTile(topLeft);
383
+ const trRendered = renderTile(topRight);
384
+ const blRendered = renderTile(bottomLeft);
385
+ const brRendered = renderTile(bottomRight);
386
+ // Create middle fill rows
387
+ const middleTopRendered = [];
388
+ const middleBottomRendered = [];
389
+ for (let row = 0; row < CHAR_HEIGHT; row++) {
390
+ middleTopRendered.push(createMiddleFill(topLeft, row));
391
+ middleBottomRendered.push(createMiddleFill(bottomLeft, row));
392
+ }
393
+ const middleTiles = Math.max(0, dialogueBoxWidthTiles - 2);
394
+ const interiorWidth = middleTiles * 16; // 3 tiles * 16 = 48 chars
395
+ // Build dialogue box lines
396
+ const boxLines = [];
397
+ // Top row of dialogue box tiles
398
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
399
+ let line = tlRendered[charRow];
400
+ for (let m = 0; m < middleTiles; m++) {
401
+ line += middleTopRendered[charRow];
402
+ }
403
+ line += trRendered[charRow];
404
+ boxLines.push(line);
405
+ }
406
+ // Bottom row of dialogue box tiles
407
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
408
+ let line = blRendered[charRow];
409
+ for (let m = 0; m < middleTiles; m++) {
410
+ line += middleBottomRendered[charRow];
411
+ }
412
+ line += brRendered[charRow];
413
+ boxLines.push(line);
414
+ }
415
+ // Sample background color from dialogue tile center
416
+ const bgSamplePixel = topLeft[8][8];
417
+ const textBgColor = `\x1b[48;2;${bgSamplePixel.r};${bgSamplePixel.g};${bgSamplePixel.b}m`;
418
+ // The Arbiter wisdom text
419
+ const textLines = [
420
+ `${WHITE}You approach the lair of`,
421
+ '',
422
+ `${BOLD}${COLOR_ARBITER}THE ARBITER`,
423
+ `${WHITE}OF THAT WHICH ${COLOR_WAS}WAS${WHITE},`,
424
+ `${WHITE}THAT WHICH ${COLOR_IS}IS${WHITE},`,
425
+ `${WHITE}AND THAT WHICH ${rainbow('SHALL COME TO BE')}`,
426
+ ];
427
+ // Center text in the dialogue box
428
+ const boxHeight = CHAR_HEIGHT * 2;
429
+ const textStartOffset = Math.floor((boxHeight - textLines.length) / 2);
430
+ // Overlay text onto the box
431
+ for (let i = 0; i < textLines.length; i++) {
432
+ const boxLineIndex = textStartOffset + i;
433
+ if (boxLineIndex >= 0 && boxLineIndex < boxLines.length) {
434
+ const line = textLines[i];
435
+ const visibleLength = stripAnsi(line).length;
436
+ const padding = Math.max(0, Math.floor((interiorWidth - visibleLength) / 2));
437
+ const rightPadding = Math.max(0, interiorWidth - padding - visibleLength);
438
+ const textContent = ' '.repeat(padding) + line + ' '.repeat(rightPadding);
439
+ const textWithBg = wrapTextWithBg(textContent, textBgColor);
440
+ const isTopHalf = boxLineIndex < CHAR_HEIGHT;
441
+ const charRow = isTopHalf ? boxLineIndex : boxLineIndex - CHAR_HEIGHT;
442
+ const leftBorder = isTopHalf ? tlRendered[charRow] : blRendered[charRow];
443
+ const rightBorder = isTopHalf ? trRendered[charRow] : brRendered[charRow];
444
+ boxLines[boxLineIndex] = leftBorder + textWithBg + rightBorder;
445
+ }
446
+ }
447
+ return boxLines;
448
+ }
449
+ // Rat dialogue colors
450
+ const _COLOR_RAT = '\x1b[38;2;180;140;100m'; // Brown for rat
451
+ const COLOR_RAT_EEK = '\x1b[38;2;255;200;100m'; // Yellow for eek
452
+ /**
453
+ * Render the rat dialogue box overlay as an array of strings
454
+ */
455
+ function renderRatDialogueBox(tileset) {
456
+ const dialogueBoxWidthTiles = 6; // Wider for more text
457
+ // Extract dialogue tiles
458
+ const topLeft = extractTile(tileset, DIALOGUE_TILES.TOP_LEFT);
459
+ const topRight = extractTile(tileset, DIALOGUE_TILES.TOP_RIGHT);
460
+ const bottomLeft = extractTile(tileset, DIALOGUE_TILES.BOTTOM_LEFT);
461
+ const bottomRight = extractTile(tileset, DIALOGUE_TILES.BOTTOM_RIGHT);
462
+ const tlRendered = renderTile(topLeft);
463
+ const trRendered = renderTile(topRight);
464
+ const blRendered = renderTile(bottomLeft);
465
+ const brRendered = renderTile(bottomRight);
466
+ // Create middle fill rows
467
+ const middleTopRendered = [];
468
+ const middleBottomRendered = [];
469
+ for (let row = 0; row < CHAR_HEIGHT; row++) {
470
+ middleTopRendered.push(createMiddleFill(topLeft, row));
471
+ middleBottomRendered.push(createMiddleFill(bottomLeft, row));
472
+ }
473
+ const middleTiles = Math.max(0, dialogueBoxWidthTiles - 2);
474
+ const interiorWidth = middleTiles * 16; // 4 tiles * 16 = 64 chars
475
+ // Build dialogue box lines (2 rows of tiles = 16 char rows)
476
+ const boxLines = [];
477
+ // Top row of dialogue box tiles
478
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
479
+ let line = tlRendered[charRow];
480
+ for (let m = 0; m < middleTiles; m++) {
481
+ line += middleTopRendered[charRow];
482
+ }
483
+ line += trRendered[charRow];
484
+ boxLines.push(line);
485
+ }
486
+ // Bottom row of dialogue box tiles
487
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
488
+ let line = blRendered[charRow];
489
+ for (let m = 0; m < middleTiles; m++) {
490
+ line += middleBottomRendered[charRow];
491
+ }
492
+ line += brRendered[charRow];
493
+ boxLines.push(line);
494
+ }
495
+ // Sample background color from dialogue tile center
496
+ const bgSamplePixel = topLeft[8][8];
497
+ const textBgColor = `\x1b[48;2;${bgSamplePixel.r};${bgSamplePixel.g};${bgSamplePixel.b}m`;
498
+ // Rat dialogue text (compressed)
499
+ const textLines = [
500
+ `${COLOR_RAT_EEK}*eek!* ${WHITE}Heed my example!`,
501
+ '',
502
+ `${WHITE}The Arbiter rewards those who sacrifice first -`,
503
+ `${WHITE}who toil over their requirements until every detail is known.`,
504
+ '',
505
+ `${WHITE}If you offer only scattered thoughts, turn ye back!`,
506
+ `${WHITE}He might ruin your code, or turn you into a rat!`,
507
+ ];
508
+ // Center text in the dialogue box
509
+ const boxHeight = CHAR_HEIGHT * 2; // 2 tile rows
510
+ const textStartOffset = Math.floor((boxHeight - textLines.length) / 2);
511
+ // Overlay text onto the box
512
+ for (let i = 0; i < textLines.length; i++) {
513
+ const boxLineIndex = textStartOffset + i;
514
+ if (boxLineIndex >= 0 && boxLineIndex < boxLines.length) {
515
+ const line = textLines[i];
516
+ const visibleLength = stripAnsi(line).length;
517
+ const padding = Math.max(0, Math.floor((interiorWidth - visibleLength) / 2));
518
+ const rightPadding = Math.max(0, interiorWidth - padding - visibleLength);
519
+ const textContent = ' '.repeat(padding) + line + ' '.repeat(rightPadding);
520
+ const textWithBg = wrapTextWithBg(textContent, textBgColor);
521
+ // Determine which row we're in (0 or 1)
522
+ const tileRow = Math.floor(boxLineIndex / CHAR_HEIGHT);
523
+ const charRow = boxLineIndex % CHAR_HEIGHT;
524
+ const leftBorder = tileRow === 0 ? tlRendered[charRow] : blRendered[charRow];
525
+ const rightBorder = tileRow === 0 ? trRendered[charRow] : brRendered[charRow];
526
+ boxLines[boxLineIndex] = leftBorder + textWithBg + rightBorder;
527
+ }
528
+ }
529
+ return boxLines;
530
+ }
531
+ /**
532
+ * Render the death scene
533
+ */
534
+ function renderDeathScene(tileset) {
535
+ const DEATH_WIDTH = 3;
536
+ const DEATH_HEIGHT = 2;
537
+ const grassTile = extractTile(tileset, TILE.GRASS_SPARSE);
538
+ const gravestoneTile = extractTile(tileset, DEATH_TILES.GRAVESTONE);
539
+ const skeletonTile = extractTile(tileset, DEATH_TILES.SKELETON);
540
+ const gravestoneComposite = compositeTiles(gravestoneTile, grassTile, 1);
541
+ const skeletonComposite = compositeTiles(skeletonTile, grassTile, 1);
542
+ const scene = [
543
+ [grassTile, gravestoneComposite, grassTile],
544
+ [grassTile, skeletonComposite, grassTile],
545
+ ];
546
+ const renderedTiles = [];
547
+ for (let row = 0; row < DEATH_HEIGHT; row++) {
548
+ const renderedRow = [];
549
+ for (let col = 0; col < DEATH_WIDTH; col++) {
550
+ renderedRow.push(renderTile(scene[row][col]));
551
+ }
552
+ renderedTiles.push(renderedRow);
553
+ }
554
+ const lines = [];
555
+ for (let tileRow = 0; tileRow < DEATH_HEIGHT; tileRow++) {
556
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
557
+ let line = '';
558
+ for (let tileCol = 0; tileCol < DEATH_WIDTH; tileCol++) {
559
+ line += renderedTiles[tileRow][tileCol][charRow];
560
+ }
561
+ lines.push(line);
562
+ }
563
+ }
564
+ return lines;
565
+ }
566
+ // ============================================================================
567
+ // Main Export
568
+ // ============================================================================
569
+ /**
570
+ * Shows the forest intro screen using terminal-kit with Strategy 5 (minimal redraws)
571
+ *
572
+ * @param selectedCharacter - The tile index of the selected character (190-197)
573
+ * @returns Promise<'success' | 'death'> - 'success' when player exits right after seeing sign, 'death' if they die
574
+ */
575
+ export async function showForestIntro(selectedCharacter) {
576
+ // Load tileset before entering the Promise
577
+ const tileset = await loadTileset();
578
+ return new Promise((resolve) => {
579
+ // Initialize terminal
580
+ term.fullscreen(true);
581
+ term.hideCursor();
582
+ term.grabInput(true);
583
+ // State
584
+ const state = {
585
+ playerX: START_X,
586
+ playerY: START_Y,
587
+ phase: 'walking',
588
+ hasSeenSign: false,
589
+ hasSeenRat: false,
590
+ };
591
+ // Change tracker for minimal redraws
592
+ const tracker = {
593
+ lastPlayerX: -1,
594
+ lastPlayerY: -1,
595
+ lastPhase: 'walking',
596
+ lastShowMessage: false,
597
+ lastShowRatMessage: false,
598
+ };
599
+ // Calculate centering offsets
600
+ let width = 180;
601
+ let height = 50;
602
+ if (typeof term.width === 'number' && Number.isFinite(term.width) && term.width > 0) {
603
+ width = term.width;
604
+ }
605
+ if (typeof term.height === 'number' && Number.isFinite(term.height) && term.height > 0) {
606
+ height = term.height;
607
+ }
608
+ const sceneOffsetX = Math.max(1, Math.floor((width - SCENE_WIDTH_CHARS) / 2));
609
+ const sceneOffsetY = Math.max(1, Math.floor((height - SCENE_HEIGHT_CHARS - 4) / 2));
610
+ /**
611
+ * Draw the forest scene (only if changed)
612
+ */
613
+ function drawScene() {
614
+ const showMessage = isNextToSign(state.playerX, state.playerY);
615
+ const showRatMessage = isNextToRat(state.playerX, state.playerY);
616
+ // Track if player has seen the sign or rat
617
+ if (showMessage) {
618
+ state.hasSeenSign = true;
619
+ }
620
+ if (showRatMessage) {
621
+ state.hasSeenRat = true;
622
+ }
623
+ // Check if anything changed
624
+ if (state.playerX === tracker.lastPlayerX &&
625
+ state.playerY === tracker.lastPlayerY &&
626
+ state.phase === tracker.lastPhase &&
627
+ showMessage === tracker.lastShowMessage &&
628
+ showRatMessage === tracker.lastShowRatMessage) {
629
+ return;
630
+ }
631
+ // Detect if dialogue just appeared (for sound effects)
632
+ const signDialogueJustAppeared = showMessage && !tracker.lastShowMessage;
633
+ const ratDialogueJustAppeared = showRatMessage && !tracker.lastShowRatMessage;
634
+ tracker.lastPlayerX = state.playerX;
635
+ tracker.lastPlayerY = state.playerY;
636
+ tracker.lastPhase = state.phase;
637
+ tracker.lastShowMessage = showMessage;
638
+ tracker.lastShowRatMessage = showRatMessage;
639
+ // Play dialogue appearance sounds
640
+ if (signDialogueJustAppeared) {
641
+ playSfx('quickNotice');
642
+ }
643
+ else if (ratDialogueJustAppeared) {
644
+ playSfx('quickNotice');
645
+ }
646
+ const sceneLines = renderForestScene(tileset, selectedCharacter, state.playerX, state.playerY);
647
+ // Write scene lines
648
+ for (let i = 0; i < sceneLines.length; i++) {
649
+ term.moveTo(sceneOffsetX, sceneOffsetY + i);
650
+ process.stdout.write(sceneLines[i] + RESET);
651
+ }
652
+ // Show dialogue box at bottom of scene if next to sign
653
+ if (showMessage) {
654
+ const dialogueLines = renderDialogueBox(tileset);
655
+ // Center dialogue box horizontally: 5 tiles = 80 chars, scene = 112 chars
656
+ // (112 - 80) / 2 = 16 chars offset from scene start
657
+ const dialogueOffsetX = sceneOffsetX + Math.floor((SCENE_WIDTH_CHARS - 80) / 2);
658
+ // Position dialogue to cover bottom 3 tile rows of scene
659
+ // 3 tiles * 8 rows/tile = 24 rows, so start at: sceneHeight - 24
660
+ const dialogueOffsetY = sceneOffsetY + (SCENE_HEIGHT_CHARS - 16);
661
+ for (let i = 0; i < dialogueLines.length; i++) {
662
+ term.moveTo(dialogueOffsetX, dialogueOffsetY + i);
663
+ process.stdout.write(dialogueLines[i] + RESET);
664
+ }
665
+ }
666
+ else if (showRatMessage) {
667
+ // Show rat dialogue box
668
+ const dialogueLines = renderRatDialogueBox(tileset);
669
+ // Center dialogue box horizontally: 6 tiles = 96 chars, scene = 112 chars
670
+ const dialogueOffsetX = sceneOffsetX + Math.floor((SCENE_WIDTH_CHARS - 96) / 2);
671
+ // Position dialogue to cover bottom portion of scene (2 tile rows = 16 char rows)
672
+ const dialogueOffsetY = sceneOffsetY + (SCENE_HEIGHT_CHARS - 16);
673
+ for (let i = 0; i < dialogueLines.length; i++) {
674
+ term.moveTo(dialogueOffsetX, dialogueOffsetY + i);
675
+ process.stdout.write(dialogueLines[i] + RESET);
676
+ }
677
+ }
678
+ else {
679
+ // Show hint text at bottom
680
+ const hintY = sceneOffsetY + SCENE_HEIGHT_CHARS + 1;
681
+ term.moveTo(sceneOffsetX, hintY);
682
+ // Clear the line first
683
+ process.stdout.write(' '.repeat(SCENE_WIDTH_CHARS));
684
+ term.moveTo(sceneOffsetX, hintY);
685
+ let hintText = `${DIM}Use arrow keys to move. Walk to the right to find the Arbiter.${RESET}`;
686
+ if (state.hasSeenSign) {
687
+ hintText = `${DIM}Continue to the right to enter the Arbiter's lair.${RESET}`;
688
+ }
689
+ process.stdout.write(hintText);
690
+ }
691
+ }
692
+ /**
693
+ * Draw the death screen
694
+ */
695
+ function drawDeathScreen() {
696
+ // Clear screen first
697
+ term.clear();
698
+ const deathLines = renderDeathScene(tileset);
699
+ // Center the death scene
700
+ const deathWidth = 3 * TILE_SIZE; // 48 chars
701
+ const deathHeight = deathLines.length;
702
+ const deathOffsetX = Math.max(1, Math.floor((width - deathWidth) / 2));
703
+ const deathOffsetY = Math.max(1, Math.floor((height - deathHeight - 6) / 2));
704
+ // Draw death scene tiles
705
+ for (let i = 0; i < deathLines.length; i++) {
706
+ term.moveTo(deathOffsetX, deathOffsetY + i);
707
+ process.stdout.write(deathLines[i] + RESET);
708
+ }
709
+ // Death messages
710
+ const msgY = deathOffsetY + deathHeight + 2;
711
+ const msg1 = `${COLOR_DEATH}${BOLD}You strayed from the path.${RESET}`;
712
+ const msg2 = `${COLOR_DEATH}The forest claims another soul.${RESET}`;
713
+ const msg3 = `${DIM}Press y to try again...${RESET}`;
714
+ term.moveTo(Math.max(1, Math.floor((width - 26) / 2)), msgY);
715
+ process.stdout.write(msg1);
716
+ term.moveTo(Math.max(1, Math.floor((width - 32) / 2)), msgY + 1);
717
+ process.stdout.write(msg2);
718
+ term.moveTo(Math.max(1, Math.floor((width - 22) / 2)), msgY + 3);
719
+ process.stdout.write(msg3);
720
+ }
721
+ /**
722
+ * Draw the retreat screen (turned back)
723
+ */
724
+ function drawRetreatScreen() {
725
+ // Clear screen first
726
+ term.clear();
727
+ // Render the player character on grass
728
+ const grassTile = extractTile(tileset, TILE.GRASS_SPARSE);
729
+ const characterTile = extractTile(tileset, selectedCharacter);
730
+ const characterComposite = compositeTiles(characterTile, grassTile, 1);
731
+ const scene = [[grassTile, characterComposite, grassTile]];
732
+ const renderedTiles = [];
733
+ for (let col = 0; col < 3; col++) {
734
+ renderedTiles.push([renderTile(scene[0][col])]);
735
+ }
736
+ const lines = [];
737
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
738
+ let line = '';
739
+ for (let col = 0; col < 3; col++) {
740
+ line += renderedTiles[col][0][charRow];
741
+ }
742
+ lines.push(line);
743
+ }
744
+ // Center the retreat scene
745
+ const retreatWidth = 3 * TILE_SIZE; // 48 chars
746
+ const retreatHeight = lines.length;
747
+ const retreatOffsetX = Math.max(1, Math.floor((width - retreatWidth) / 2));
748
+ const retreatOffsetY = Math.max(1, Math.floor((height - retreatHeight - 6) / 2));
749
+ // Draw retreat scene tiles
750
+ for (let i = 0; i < lines.length; i++) {
751
+ term.moveTo(retreatOffsetX, retreatOffsetY + i);
752
+ process.stdout.write(lines[i] + RESET);
753
+ }
754
+ // Retreat messages
755
+ const msgY = retreatOffsetY + retreatHeight + 2;
756
+ const msg1 = `${COLOR_RETREAT}${BOLD}You turned back.${RESET}`;
757
+ const msg2 = `${COLOR_RETREAT}Probably a wise decision.${RESET}`;
758
+ const msg3 = `${DIM}Press y to restart, or q to quit${RESET}`;
759
+ term.moveTo(Math.max(1, Math.floor((width - 16) / 2)), msgY);
760
+ process.stdout.write(msg1);
761
+ term.moveTo(Math.max(1, Math.floor((width - 26) / 2)), msgY + 1);
762
+ process.stdout.write(msg2);
763
+ term.moveTo(Math.max(1, Math.floor((width - 32) / 2)), msgY + 3);
764
+ process.stdout.write(msg3);
765
+ }
766
+ /**
767
+ * Cleanup and restore terminal
768
+ */
769
+ function cleanup() {
770
+ term.removeAllListeners('key');
771
+ term.grabInput(false);
772
+ term.fullscreen(false);
773
+ term.hideCursor(false);
774
+ }
775
+ // Initial draw
776
+ term.clear();
777
+ drawScene();
778
+ // Handle keyboard input
779
+ term.on('key', (key) => {
780
+ // Quit handling
781
+ if (key === 'q' || key === 'CTRL_C' || key === 'CTRL_Z') {
782
+ cleanup();
783
+ process.exit(0);
784
+ return;
785
+ }
786
+ // Death screen - only 'y' to retry
787
+ if (state.phase === 'dead') {
788
+ if (key === 'y' || key === 'Y') {
789
+ cleanup();
790
+ resolve('death');
791
+ }
792
+ return;
793
+ }
794
+ // Retreat screen - 'y' to restart (same as death)
795
+ if (state.phase === 'retreat') {
796
+ if (key === 'y' || key === 'Y') {
797
+ cleanup();
798
+ resolve('death'); // Returning 'death' triggers restart
799
+ }
800
+ return;
801
+ }
802
+ // Walking phase - handle arrow key movement
803
+ if (state.phase === 'walking') {
804
+ let newX = state.playerX;
805
+ let newY = state.playerY;
806
+ if (key === 'UP' || key === 'k')
807
+ newY--;
808
+ if (key === 'DOWN' || key === 'j')
809
+ newY++;
810
+ if (key === 'LEFT' || key === 'h')
811
+ newX--;
812
+ if (key === 'RIGHT' || key === 'l')
813
+ newX++;
814
+ // No movement key pressed
815
+ if (newX === state.playerX && newY === state.playerY) {
816
+ return;
817
+ }
818
+ // Check if trying to move off screen
819
+ if (isOffScreen(newX, newY)) {
820
+ // Check if this is the valid exit: moving right off screen on path row after seeing sign
821
+ if (newX >= SCENE_WIDTH_TILES && newY === START_Y && state.hasSeenSign) {
822
+ // Successfully exited by walking off the right edge on the path!
823
+ playSfx('quickNotice');
824
+ cleanup();
825
+ resolve('success');
826
+ return;
827
+ }
828
+ // Check if retreating off the left edge on the path
829
+ if (newX < 0 && newY === START_Y) {
830
+ // Turned back - retreat screen
831
+ state.phase = 'retreat';
832
+ playSfx('quickNotice');
833
+ drawRetreatScreen();
834
+ return;
835
+ }
836
+ // Wandered off in wrong direction or skipped sign - death!
837
+ state.phase = 'dead';
838
+ playSfx('death');
839
+ drawDeathScreen();
840
+ return;
841
+ }
842
+ // Check collision with blocked tiles
843
+ if (!isWalkable(newX, newY)) {
844
+ // Can't walk there, don't move
845
+ return;
846
+ }
847
+ // Valid move - update position
848
+ state.playerX = newX;
849
+ state.playerY = newY;
850
+ playSfx('footstep');
851
+ drawScene();
852
+ }
853
+ });
854
+ });
855
+ }
856
+ export default showForestIntro;