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,266 @@
1
+ // TUI rendering logic
2
+ // Handles updating the display with agent outputs and system status
3
+ import { toRoman } from '../state.js';
4
+ /**
5
+ * Box drawing characters for borders
6
+ */
7
+ const BOX_CHARS = {
8
+ topLeft: '\u2554', // ╔
9
+ topRight: '\u2557', // ╗
10
+ bottomLeft: '\u255A', // ╚
11
+ bottomRight: '\u255D', // ╝
12
+ horizontal: '\u2550', // ═
13
+ vertical: '\u2551', // ║
14
+ leftT: '\u2560', // ╠
15
+ rightT: '\u2563', // ╣
16
+ };
17
+ /**
18
+ * Progress bar characters
19
+ */
20
+ const PROGRESS_CHARS = {
21
+ filled: '\u2588', // █
22
+ empty: '\u2591', // ░
23
+ };
24
+ /**
25
+ * Tool indicator character
26
+ */
27
+ const TOOL_INDICATOR = '\u25C8'; // ◈
28
+ /**
29
+ * Animation state for loading dots
30
+ */
31
+ let animationFrame = 0;
32
+ /**
33
+ * Generates animated dots text based on current animation frame
34
+ * Cycles through: "Working." -> "Working.." -> "Working..."
35
+ * @param baseText - The base text to animate (e.g., "Working" or "Waiting for Arbiter")
36
+ * @returns The text with animated dots appended
37
+ */
38
+ export function getAnimatedDots(baseText) {
39
+ const dotCount = (animationFrame % 3) + 1;
40
+ return baseText + '.'.repeat(dotCount);
41
+ }
42
+ /**
43
+ * Advances the animation frame for the loading dots
44
+ * Should be called by an interval timer
45
+ */
46
+ export function advanceAnimation() {
47
+ animationFrame = (animationFrame + 1) % 3;
48
+ }
49
+ /**
50
+ * Resets the animation frame to 0
51
+ */
52
+ export function resetAnimation() {
53
+ animationFrame = 0;
54
+ }
55
+ /**
56
+ * Renders the conversation log to the conversation box
57
+ * Formats messages with speakers (You:, Arbiter:, Orchestrator I:, etc.)
58
+ */
59
+ export function renderConversation(elements, state) {
60
+ const { conversationBox, screen } = elements;
61
+ const lines = [];
62
+ // Get effective width for text wrapping
63
+ const effectiveWidth = Math.max(screen.width - 6, 74);
64
+ for (const message of state.conversationLog) {
65
+ // Format speaker label
66
+ const speakerLabel = formatSpeakerLabel(message.speaker);
67
+ // Format and wrap the message text
68
+ const wrappedText = wrapText(message.text, effectiveWidth - speakerLabel.length - 2);
69
+ const textLines = wrappedText.split('\n');
70
+ // First line with speaker label
71
+ lines.push(` ${speakerLabel} ${textLines[0]}`);
72
+ // Subsequent lines indented to align with first line
73
+ const indent = ' '.repeat(speakerLabel.length + 3);
74
+ for (let i = 1; i < textLines.length; i++) {
75
+ lines.push(`${indent}${textLines[i]}`);
76
+ }
77
+ // Add empty line between messages
78
+ lines.push('');
79
+ }
80
+ // Join lines and add vertical borders
81
+ const formattedContent = lines
82
+ .map((line) => {
83
+ const paddedLine = line.padEnd(effectiveWidth, ' ');
84
+ return `${BOX_CHARS.vertical}${paddedLine}${BOX_CHARS.vertical}`;
85
+ })
86
+ .join('\n');
87
+ conversationBox.setContent(formattedContent);
88
+ // Auto-scroll to bottom
89
+ conversationBox.setScrollPerc(100);
90
+ screen.render();
91
+ }
92
+ /**
93
+ * Renders the status bar with context percentages and current tool
94
+ *
95
+ * When orchestrator is active:
96
+ * ║ Arbiter ─────────────────────────────────────────────────── ██░░░░░░░░ 18% ║
97
+ * ║ Orchestrator I ──────────────────────────────────────────── ████████░░ 74% ║
98
+ * ║ ◈ Edit (12) ║
99
+ *
100
+ * When no orchestrator (Arbiter speaks to human):
101
+ * ║ Arbiter ─────────────────────────────────────────────────── ██░░░░░░░░ 18% ║
102
+ * ║ Awaiting your command. ║
103
+ *
104
+ * @param waitingState - Optional waiting state to show animated dots
105
+ */
106
+ export function renderStatus(elements, state, waitingState = 'none') {
107
+ const { statusBox, screen } = elements;
108
+ const effectiveWidth = Math.max(screen.width - 2, 78);
109
+ // Status separator at top
110
+ const separator = BOX_CHARS.leftT + BOX_CHARS.horizontal.repeat(effectiveWidth) + BOX_CHARS.rightT;
111
+ // Progress bar width (10 characters for the bar)
112
+ const barWidth = 10;
113
+ // Build Arbiter status line
114
+ const arbiterLabel = 'Arbiter';
115
+ const arbiterBar = renderProgressBar(state.arbiterContextPercent, barWidth);
116
+ const arbiterPercent = `${Math.round(state.arbiterContextPercent)}%`.padStart(4);
117
+ const arbiterDashes = createDashLine(effectiveWidth - arbiterLabel.length - barWidth - arbiterPercent.length - 8);
118
+ const arbiterLine = `${BOX_CHARS.vertical} ${arbiterLabel} ${arbiterDashes} ${arbiterBar} ${arbiterPercent} ${BOX_CHARS.vertical}`;
119
+ let orchestratorLine;
120
+ let toolLine;
121
+ if (state.currentOrchestrator) {
122
+ // Orchestrator status line
123
+ const orchLabel = `Orchestrator ${toRoman(state.currentOrchestrator.number)}`;
124
+ const orchBar = renderProgressBar(state.currentOrchestrator.contextPercent, barWidth);
125
+ const orchPercent = `${Math.round(state.currentOrchestrator.contextPercent)}%`.padStart(4);
126
+ const orchDashes = createDashLine(effectiveWidth - orchLabel.length - barWidth - orchPercent.length - 8);
127
+ orchestratorLine = `${BOX_CHARS.vertical} ${orchLabel} ${orchDashes} ${orchBar} ${orchPercent} ${BOX_CHARS.vertical}`;
128
+ // Tool indicator line
129
+ if (state.currentOrchestrator.currentTool) {
130
+ const toolText = `${TOOL_INDICATOR} ${state.currentOrchestrator.currentTool} (${state.currentOrchestrator.toolCallCount})`;
131
+ const toolPadding = ' '.repeat(effectiveWidth - toolText.length - 2);
132
+ toolLine = `${BOX_CHARS.vertical} ${toolText}${toolPadding}${BOX_CHARS.vertical}`;
133
+ }
134
+ else if (waitingState === 'orchestrator') {
135
+ // Show animated dots when waiting for orchestrator response
136
+ const waitingText = getAnimatedDots('Working');
137
+ const waitingPadding = ' '.repeat(effectiveWidth - waitingText.length - 2);
138
+ toolLine = `${BOX_CHARS.vertical} ${waitingText}${waitingPadding}${BOX_CHARS.vertical}`;
139
+ }
140
+ else {
141
+ const waitingText = 'Working...';
142
+ const waitingPadding = ' '.repeat(effectiveWidth - waitingText.length - 2);
143
+ toolLine = `${BOX_CHARS.vertical} ${waitingText}${waitingPadding}${BOX_CHARS.vertical}`;
144
+ }
145
+ }
146
+ else if (waitingState === 'arbiter') {
147
+ // Waiting for Arbiter response - show animated dots
148
+ const waitingText = getAnimatedDots('Waiting for Arbiter');
149
+ const waitingPadding = ' '.repeat(effectiveWidth - waitingText.length - 2);
150
+ orchestratorLine = `${BOX_CHARS.vertical} ${waitingText}${waitingPadding}${BOX_CHARS.vertical}`;
151
+ toolLine = `${BOX_CHARS.vertical}${' '.repeat(effectiveWidth)}${BOX_CHARS.vertical}`;
152
+ }
153
+ else {
154
+ // No orchestrator - show awaiting message
155
+ const awaitingText = 'Awaiting your command.';
156
+ const awaitingPadding = ' '.repeat(effectiveWidth - awaitingText.length - 2);
157
+ orchestratorLine = `${BOX_CHARS.vertical} ${awaitingText}${awaitingPadding}${BOX_CHARS.vertical}`;
158
+ toolLine = `${BOX_CHARS.vertical}${' '.repeat(effectiveWidth)}${BOX_CHARS.vertical}`;
159
+ }
160
+ statusBox.setContent(`${separator}\n${arbiterLine}\n${orchestratorLine}\n${toolLine}`);
161
+ screen.render();
162
+ }
163
+ /**
164
+ * Creates an ASCII progress bar
165
+ * @param percent - Current percentage (0-100)
166
+ * @param width - Total width of the progress bar
167
+ * @returns Progress bar string like "████████░░"
168
+ */
169
+ export function renderProgressBar(percent, width) {
170
+ // Clamp percent between 0 and 100
171
+ const clampedPercent = Math.max(0, Math.min(100, percent));
172
+ // Calculate filled width
173
+ const filledWidth = Math.round((clampedPercent / 100) * width);
174
+ const emptyWidth = width - filledWidth;
175
+ // Build progress bar
176
+ return PROGRESS_CHARS.filled.repeat(filledWidth) + PROGRESS_CHARS.empty.repeat(emptyWidth);
177
+ }
178
+ /**
179
+ * Formats a speaker label with appropriate styling
180
+ */
181
+ function formatSpeakerLabel(speaker) {
182
+ switch (speaker) {
183
+ case 'human':
184
+ return '{bold}You:{/bold}';
185
+ case 'arbiter':
186
+ return '{bold}{yellow-fg}Arbiter:{/yellow-fg}{/bold}';
187
+ default:
188
+ // Orchestrator labels come through as-is (e.g., "Orchestrator I")
189
+ if (speaker.startsWith('Orchestrator')) {
190
+ return `{bold}{cyan-fg}${speaker}:{/cyan-fg}{/bold}`;
191
+ }
192
+ return `{bold}${speaker}:{/bold}`;
193
+ }
194
+ }
195
+ /**
196
+ * Wraps text to fit within a specified width
197
+ */
198
+ function wrapText(text, maxWidth) {
199
+ if (maxWidth <= 0) {
200
+ return text;
201
+ }
202
+ const words = text.split(' ');
203
+ const lines = [];
204
+ let currentLine = '';
205
+ for (const word of words) {
206
+ // Handle words that are longer than maxWidth
207
+ if (word.length > maxWidth) {
208
+ // If there's content in the current line, push it first
209
+ if (currentLine) {
210
+ lines.push(currentLine.trim());
211
+ currentLine = '';
212
+ }
213
+ // Break the long word
214
+ let remaining = word;
215
+ while (remaining.length > maxWidth) {
216
+ lines.push(remaining.substring(0, maxWidth));
217
+ remaining = remaining.substring(maxWidth);
218
+ }
219
+ currentLine = `${remaining} `;
220
+ continue;
221
+ }
222
+ // Check if adding this word would exceed the limit
223
+ if (currentLine.length + word.length + 1 > maxWidth) {
224
+ lines.push(currentLine.trim());
225
+ currentLine = `${word} `;
226
+ }
227
+ else {
228
+ currentLine += `${word} `;
229
+ }
230
+ }
231
+ // Add remaining content
232
+ if (currentLine.trim()) {
233
+ lines.push(currentLine.trim());
234
+ }
235
+ return lines.join('\n');
236
+ }
237
+ /**
238
+ * Creates a dash line for status bar alignment
239
+ * Uses em-dash (─) for cleaner appearance
240
+ */
241
+ function createDashLine(length) {
242
+ const dashChar = '\u2500'; // ─
243
+ return dashChar.repeat(Math.max(0, length));
244
+ }
245
+ /**
246
+ * Renders the input area with prompt
247
+ */
248
+ export function renderInputArea(elements) {
249
+ const { screen } = elements;
250
+ const effectiveWidth = Math.max(screen.width - 2, 78);
251
+ // Input separator and prompt are handled by the layout
252
+ // This function can be used for additional input area styling if needed
253
+ // Create input separator
254
+ const _inputSeparator = BOX_CHARS.leftT + BOX_CHARS.horizontal.repeat(effectiveWidth) + BOX_CHARS.rightT;
255
+ // The inputBox already has its own styling from layout
256
+ // We can prepend a separator to the status box if needed
257
+ screen.render();
258
+ }
259
+ /**
260
+ * Updates the entire display
261
+ */
262
+ export function renderAll(elements, state) {
263
+ renderConversation(elements, state);
264
+ renderStatus(elements, state);
265
+ elements.screen.render();
266
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Scene state and composition module for the Arbiter TUI
3
+ *
4
+ * Manages scene state and creates tile grids for rendering the wizard council scene.
5
+ * The scene shows a human, the arbiter, a spellbook, a campfire, and demons.
6
+ */
7
+ import { type Tileset } from './tileset.js';
8
+ /**
9
+ * State for a single hop animation at a position
10
+ * Each hop = 500ms (250ms up + 250ms down)
11
+ */
12
+ export interface HopState {
13
+ remaining: number;
14
+ frameInHop: 0 | 1;
15
+ }
16
+ /**
17
+ * Scene state describing positions and counts of all scene elements
18
+ */
19
+ export interface SceneState {
20
+ arbiterPos: -1 | 0 | 1 | 2;
21
+ demonCount: number;
22
+ focusTarget: 'human' | 'arbiter' | 'demon' | null;
23
+ selectedCharacter: number;
24
+ humanCol: number;
25
+ activeHops: Map<string, HopState>;
26
+ bubbleVisible: boolean;
27
+ showSpellbook: boolean;
28
+ chatBubbleTarget: 'human' | 'arbiter' | 'conjuring' | null;
29
+ alertTarget: 'human' | 'arbiter' | 'conjuring' | null;
30
+ scrollVisible: boolean;
31
+ }
32
+ /**
33
+ * Tile specification - either a simple tile index or an object with mirroring
34
+ */
35
+ export type TileSpec = number | {
36
+ tile: number;
37
+ mirrored: boolean;
38
+ };
39
+ /**
40
+ * Create initial scene state with defaults
41
+ */
42
+ export declare function createInitialSceneState(): SceneState;
43
+ /**
44
+ * Create a 7x6 grid of tile specifications based on scene state
45
+ *
46
+ * Scene Layout:
47
+ * - Row 0-1: Trees on edges (col 0 and 6), grass elsewhere
48
+ * - Row 2: Human at col 1, Arbiter at col 2-4 (based on arbiterPos), Cauldron at col 5, tree at col 6
49
+ * - Row 3: Tree at col 0, grass, campfire at col 5, tree at col 6
50
+ * - Row 4-5: Trees at col 0 and 6, grass elsewhere
51
+ * - Demons spawn around campfire at col 5-6, rows 1-3
52
+ * - Smoke bubbles appear above cauldron when working
53
+ * - Spellbook appears to the left of arbiter when at position 2
54
+ */
55
+ export declare function createScene(state: SceneState): TileSpec[][];
56
+ /**
57
+ * Render the scene to an ANSI string
58
+ *
59
+ * @param tileset - The loaded tileset
60
+ * @param scene - The tile grid from createScene
61
+ * @param activeHops - Map of "row,col" -> HopState for positions that should hop
62
+ * @param sceneState - Optional scene state for chat bubble rendering
63
+ */
64
+ export declare function renderScene(tileset: Tileset, scene: TileSpec[][], activeHops?: Map<string, HopState>, sceneState?: SceneState): string;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Scene state and composition module for the Arbiter TUI
3
+ *
4
+ * Manages scene state and creates tile grids for rendering the wizard council scene.
5
+ * The scene shows a human, the arbiter, a spellbook, a campfire, and demons.
6
+ */
7
+ import { CHAR_HEIGHT, compositeQuarterTile, compositeTiles, extractQuarterTile, extractTile, mirrorTile, renderTile, TILE, } from './tileset.js';
8
+ // ============================================================================
9
+ // Constants
10
+ // ============================================================================
11
+ const SCENE_WIDTH = 7;
12
+ const SCENE_HEIGHT = 6;
13
+ // Demon spawn positions around the campfire (order matters for spawning)
14
+ const DEMON_POSITIONS = [
15
+ { row: 2, col: 6, tile: TILE.DEMON_1 }, // right of fire (first)
16
+ { row: 1, col: 6, tile: TILE.DEMON_2 }, // above-right
17
+ { row: 3, col: 6, tile: TILE.DEMON_3 }, // below-right
18
+ { row: 1, col: 5, tile: TILE.DEMON_4 }, // above fire
19
+ { row: 3, col: 5, tile: TILE.DEMON_5 }, // below fire
20
+ ];
21
+ /**
22
+ * Get a varied grass tile based on position for visual variety
23
+ * Returns ~30% sparse grass, ~70% regular grass using a deterministic pattern
24
+ */
25
+ function getGrassTile(row, col) {
26
+ // Use a simple pattern based on position for variety
27
+ // This creates a natural-looking distribution of grass types
28
+ const pattern = (row * 3 + col * 7) % 10;
29
+ if (pattern < 3)
30
+ return TILE.GRASS_SPARSE;
31
+ return TILE.GRASS;
32
+ }
33
+ // ============================================================================
34
+ // Tile Render Cache
35
+ // ============================================================================
36
+ const tileCache = new Map();
37
+ /**
38
+ * Generate cache key for a tile render
39
+ */
40
+ function getCacheKey(tileIndex, mirrored) {
41
+ return `${tileIndex}:${mirrored ? 'M' : 'N'}`;
42
+ }
43
+ /**
44
+ * Get or create a cached tile render
45
+ */
46
+ function getTileRender(tileset, grassPixels, tileIndex, mirrored = false) {
47
+ const key = getCacheKey(tileIndex, mirrored);
48
+ if (tileCache.has(key)) {
49
+ return tileCache.get(key);
50
+ }
51
+ let pixels = extractTile(tileset, tileIndex);
52
+ // Composite tiles >= 80 on grass (characters, objects, etc.)
53
+ if (tileIndex >= 80) {
54
+ pixels = compositeTiles(pixels, grassPixels, 1);
55
+ }
56
+ if (mirrored) {
57
+ pixels = mirrorTile(pixels);
58
+ }
59
+ const rendered = renderTile(pixels);
60
+ tileCache.set(key, rendered);
61
+ return rendered;
62
+ }
63
+ // ============================================================================
64
+ // Factory Function
65
+ // ============================================================================
66
+ /**
67
+ * Create initial scene state with defaults
68
+ */
69
+ export function createInitialSceneState() {
70
+ return {
71
+ arbiterPos: 2, // Start by fire (row 3), walks to scroll when first message ready
72
+ demonCount: 0,
73
+ focusTarget: null,
74
+ selectedCharacter: TILE.HUMAN_1,
75
+ humanCol: 1, // Default position (after entry animation)
76
+ // Animation state
77
+ activeHops: new Map(),
78
+ bubbleVisible: false,
79
+ showSpellbook: false,
80
+ // Chat bubble
81
+ chatBubbleTarget: null,
82
+ // Alert/exclamation indicator
83
+ alertTarget: null,
84
+ // Scroll of requirements
85
+ scrollVisible: false,
86
+ };
87
+ }
88
+ // ============================================================================
89
+ // Scene Creation
90
+ // ============================================================================
91
+ /**
92
+ * Create a 7x6 grid of tile specifications based on scene state
93
+ *
94
+ * Scene Layout:
95
+ * - Row 0-1: Trees on edges (col 0 and 6), grass elsewhere
96
+ * - Row 2: Human at col 1, Arbiter at col 2-4 (based on arbiterPos), Cauldron at col 5, tree at col 6
97
+ * - Row 3: Tree at col 0, grass, campfire at col 5, tree at col 6
98
+ * - Row 4-5: Trees at col 0 and 6, grass elsewhere
99
+ * - Demons spawn around campfire at col 5-6, rows 1-3
100
+ * - Smoke bubbles appear above cauldron when working
101
+ * - Spellbook appears to the left of arbiter when at position 2
102
+ */
103
+ export function createScene(state) {
104
+ const { arbiterPos, demonCount, selectedCharacter, humanCol, bubbleVisible, showSpellbook, scrollVisible, } = state;
105
+ // arbiterPos -1 means off-screen (not visible)
106
+ const arbiterVisible = arbiterPos >= 0;
107
+ // Position mapping:
108
+ // Pos 2: row 3, col 4 (by fire - starting position)
109
+ // Pos 1: row 2, col 4 (by cauldron)
110
+ // Pos 0: row 2, col 3 (by scroll - final position)
111
+ let arbiterCol = -1;
112
+ let arbiterRow = 2;
113
+ if (arbiterVisible) {
114
+ switch (arbiterPos) {
115
+ case 0:
116
+ arbiterCol = 3;
117
+ arbiterRow = 2;
118
+ break;
119
+ case 1:
120
+ arbiterCol = 4;
121
+ arbiterRow = 2;
122
+ break;
123
+ case 2:
124
+ arbiterCol = 4;
125
+ arbiterRow = 3;
126
+ break;
127
+ }
128
+ }
129
+ const scene = [];
130
+ for (let row = 0; row < SCENE_HEIGHT; row++) {
131
+ const sceneRow = [];
132
+ for (let col = 0; col < SCENE_WIDTH; col++) {
133
+ let tile = getGrassTile(row, col);
134
+ // Trees on left edge (col 0), except row 2 which is the path entrance
135
+ if (col === 0) {
136
+ tile = row === 2 ? TILE.GRASS_SPARSE : TILE.PINE_TREE;
137
+ }
138
+ // Bare trees at specific positions on col 1
139
+ if (col === 1 && (row === 0 || row === 4)) {
140
+ tile = TILE.BARE_TREE;
141
+ }
142
+ // Trees on right edge (col 6)
143
+ if (col === 6 && (row === 0 || row === 4 || row === 5)) {
144
+ tile = TILE.PINE_TREE;
145
+ }
146
+ // Human character on row 2 at humanCol position
147
+ if (row === 2 && col === humanCol && humanCol >= 0 && humanCol < SCENE_WIDTH) {
148
+ tile = selectedCharacter;
149
+ }
150
+ // Scroll of requirements (row 2, col 2) - between human and arbiter
151
+ if (scrollVisible && row === 2 && col === 2) {
152
+ tile = TILE.SCROLL;
153
+ }
154
+ // Spellbook at row 4, col 4 (one down and left from campfire)
155
+ // Shows when explicitly set (during arbiter's working position)
156
+ if (showSpellbook && row === 4 && col === 4) {
157
+ tile = TILE.SPELLBOOK;
158
+ }
159
+ // Cauldron (row 2, col 5)
160
+ if (row === 2 && col === 5) {
161
+ tile = TILE.CAULDRON;
162
+ }
163
+ // Smoke/bubbles above cauldron when bubbleVisible is true
164
+ if (bubbleVisible && row === 1 && col === 5) {
165
+ tile = TILE.SMOKE;
166
+ }
167
+ // Campfire (row 3, col 5)
168
+ if (row === 3 && col === 5) {
169
+ tile = TILE.CAMPFIRE;
170
+ }
171
+ // Arbiter - only draw if visible (arbiterPos >= 0)
172
+ if (arbiterVisible && row === arbiterRow && col === arbiterCol) {
173
+ tile = TILE.ARBITER;
174
+ }
175
+ // Demons based on count (spawn in order)
176
+ for (let i = 0; i < Math.min(demonCount, DEMON_POSITIONS.length); i++) {
177
+ const dp = DEMON_POSITIONS[i];
178
+ if (row === dp.row && col === dp.col) {
179
+ tile = dp.tile;
180
+ }
181
+ }
182
+ sceneRow.push(tile);
183
+ }
184
+ scene.push(sceneRow);
185
+ }
186
+ return scene;
187
+ }
188
+ // ============================================================================
189
+ // Scene Rendering
190
+ // ============================================================================
191
+ /**
192
+ * Get the row,col position for a chat bubble target
193
+ */
194
+ function getChatBubblePosition(state, target) {
195
+ switch (target) {
196
+ case 'human':
197
+ return { row: 2, col: state.humanCol };
198
+ case 'arbiter': {
199
+ const pos = state.arbiterPos;
200
+ switch (pos) {
201
+ case 0:
202
+ return { row: 2, col: 3 }; // By scroll
203
+ case 1:
204
+ return { row: 2, col: 4 }; // By cauldron
205
+ case 2:
206
+ return { row: 3, col: 4 }; // By fire
207
+ default:
208
+ return { row: 2, col: 3 };
209
+ }
210
+ }
211
+ case 'conjuring':
212
+ // First demon position
213
+ return { row: 2, col: 6 };
214
+ }
215
+ }
216
+ // Cache for the chat bubble quarter tile (extracted once, reused)
217
+ let chatBubbleQuarterCache = null;
218
+ /**
219
+ * Get or create the cached chat bubble quarter tile
220
+ */
221
+ function getChatBubbleQuarter(tileset) {
222
+ if (!chatBubbleQuarterCache) {
223
+ const quartersTile = extractTile(tileset, TILE.CHAT_BUBBLE_QUARTERS);
224
+ chatBubbleQuarterCache = extractQuarterTile(quartersTile, 'top-right');
225
+ }
226
+ return chatBubbleQuarterCache;
227
+ }
228
+ // Cache for the alert/exclamation quarter tile (extracted once, reused)
229
+ let alertQuarterCache = null;
230
+ /**
231
+ * Get or create the cached alert/exclamation quarter tile
232
+ */
233
+ function getAlertQuarter(tileset) {
234
+ if (!alertQuarterCache) {
235
+ const quartersTile = extractTile(tileset, TILE.ALERT_QUARTERS);
236
+ alertQuarterCache = extractQuarterTile(quartersTile, 'top-left');
237
+ }
238
+ return alertQuarterCache;
239
+ }
240
+ /**
241
+ * Render the scene to an ANSI string
242
+ *
243
+ * @param tileset - The loaded tileset
244
+ * @param scene - The tile grid from createScene
245
+ * @param activeHops - Map of "row,col" -> HopState for positions that should hop
246
+ * @param sceneState - Optional scene state for chat bubble rendering
247
+ */
248
+ export function renderScene(tileset, scene, activeHops = new Map(), sceneState) {
249
+ // Get grass pixels for compositing
250
+ const grassPixels = extractTile(tileset, TILE.GRASS);
251
+ // Determine chat bubble position if target is set
252
+ let bubbleRow = -1;
253
+ let bubbleCol = -1;
254
+ if (sceneState?.chatBubbleTarget) {
255
+ const pos = getChatBubblePosition(sceneState, sceneState.chatBubbleTarget);
256
+ bubbleRow = pos.row;
257
+ bubbleCol = pos.col;
258
+ }
259
+ // Determine alert/exclamation position if target is set
260
+ let alertRow = -1;
261
+ let alertCol = -1;
262
+ if (sceneState?.alertTarget) {
263
+ const pos = getChatBubblePosition(sceneState, sceneState.alertTarget);
264
+ alertRow = pos.row;
265
+ alertCol = pos.col;
266
+ }
267
+ const renderedTiles = [];
268
+ for (let row = 0; row < scene.length; row++) {
269
+ const renderedRow = [];
270
+ for (let col = 0; col < scene[row].length; col++) {
271
+ const tileSpec = scene[row][col];
272
+ let tileIndex;
273
+ let mirrored = false;
274
+ if (typeof tileSpec === 'number') {
275
+ tileIndex = tileSpec;
276
+ }
277
+ else {
278
+ tileIndex = tileSpec.tile;
279
+ mirrored = tileSpec.mirrored;
280
+ }
281
+ // Check if this position needs an overlay (chat bubble or alert)
282
+ const needsBubble = row === bubbleRow && col === bubbleCol;
283
+ const needsAlert = row === alertRow && col === alertCol;
284
+ if (needsBubble || needsAlert) {
285
+ // Render without cache, adding the overlay
286
+ let pixels = extractTile(tileset, tileIndex);
287
+ // Composite on grass if needed
288
+ if (tileIndex >= 80) {
289
+ pixels = compositeTiles(pixels, grassPixels, 1);
290
+ }
291
+ if (mirrored) {
292
+ pixels = mirrorTile(pixels);
293
+ }
294
+ // Add chat bubble to top-right corner
295
+ if (needsBubble) {
296
+ const bubbleQuarter = getChatBubbleQuarter(tileset);
297
+ pixels = compositeQuarterTile(pixels, bubbleQuarter, 'top-right', 1);
298
+ }
299
+ // Add alert/exclamation to top-left corner
300
+ if (needsAlert) {
301
+ const alertQuarter = getAlertQuarter(tileset);
302
+ pixels = compositeQuarterTile(pixels, alertQuarter, 'top-left', 1);
303
+ }
304
+ renderedRow.push(renderTile(pixels));
305
+ }
306
+ else {
307
+ // Use cached render
308
+ renderedRow.push(getTileRender(tileset, grassPixels, tileIndex, mirrored));
309
+ }
310
+ }
311
+ renderedTiles.push(renderedRow);
312
+ }
313
+ // Build output string
314
+ // Hop detection now uses activeHops map - check if position is hopping and in "up" frame
315
+ // When hopping, we need to shift the hopping tile up by 1 row
316
+ // This means: at the row above, we show the bottom row of the hopping tile
317
+ // and at the tile's normal position, we show rows shifted up
318
+ const lines = [];
319
+ for (let tileRow = 0; tileRow < scene.length; tileRow++) {
320
+ for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
321
+ let line = '';
322
+ for (let tileCol = 0; tileCol < scene[tileRow].length; tileCol++) {
323
+ // Check if this position is hopping (and in "up" frame)
324
+ const hopKey = `${tileRow},${tileCol}`;
325
+ const hopState = activeHops.get(hopKey);
326
+ const isHoppingTile = hopState && hopState.frameInHop === 0;
327
+ // Check if tile above is hopping (for the overflow effect)
328
+ const hopKeyBelow = `${tileRow + 1},${tileCol}`;
329
+ const hopStateBelow = activeHops.get(hopKeyBelow);
330
+ const isTileAboveHopping = hopStateBelow && hopStateBelow.frameInHop === 0;
331
+ if (isHoppingTile) {
332
+ // For the hopping tile, show the row below (shifted up)
333
+ // If charRow is 0, we show nothing special (already handled by tile above)
334
+ // Otherwise show charRow - 1 if we're hopping, but we need to handle the overlap
335
+ if (charRow === 0) {
336
+ // First char row of hopping tile shows second row of the tile
337
+ line += renderedTiles[tileRow][tileCol][1];
338
+ }
339
+ else if (charRow === CHAR_HEIGHT - 1) {
340
+ // Last char row shows grass (the tile has moved up)
341
+ line += renderedTiles[tileRow][tileCol][charRow]; // Actually show grass from tile
342
+ }
343
+ else {
344
+ // Show the next row down (shifted up by 1)
345
+ line += renderedTiles[tileRow][tileCol][charRow + 1];
346
+ }
347
+ }
348
+ else if (isTileAboveHopping) {
349
+ // For the tile above the hopping tile, the last row shows the first row of the hopping tile
350
+ if (charRow === CHAR_HEIGHT - 1) {
351
+ line += renderedTiles[tileRow + 1][tileCol][0];
352
+ }
353
+ else {
354
+ line += renderedTiles[tileRow][tileCol][charRow];
355
+ }
356
+ }
357
+ else {
358
+ line += renderedTiles[tileRow][tileCol][charRow];
359
+ }
360
+ }
361
+ lines.push(line);
362
+ }
363
+ }
364
+ // Join with newlines (no trailing newline to prevent extra line causing flicker)
365
+ return lines.join('\n');
366
+ }