@kernel.chat/kbot 3.87.0 → 3.93.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,1054 @@
1
+ // kbot Living World Engine — Ecology, Memory, Emotion, and AI-driven Evolution
2
+ //
3
+ // Goes beyond Minecraft's static blocks: blocks interact, terrain remembers,
4
+ // places have moods, conversations become geology, and dreams change the world.
5
+ //
6
+ // This module exports functions the renderer calls each frame and between streams.
7
+ // It does NOT register any tools itself.
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
11
+ import { TILE_SIZE, CHUNK_WIDTH, WORLD_HEIGHT, generateChunk, } from './tile-world.js';
12
+ // ─── Constants ────────────────────────────────────────────────
13
+ const KBOT_DIR = join(homedir(), '.kbot');
14
+ const LIVING_WORLD_FILE = join(KBOT_DIR, 'stream-living-world.json');
15
+ /** How many frames between ecology ticks (60 frames = ~10 seconds at 6 FPS) */
16
+ const ECOLOGY_TICK_INTERVAL = 60;
17
+ /** Max block changes per evolution pass (between-stream simulation) */
18
+ const MAX_EVOLUTION_CHANGES = 100;
19
+ // ─── Seeded PRNG (matches tile-world.ts) ─────────────────────
20
+ function seededRandom(seed) {
21
+ let s = seed | 0;
22
+ return () => {
23
+ s = (s + 0x6D2B79F5) | 0;
24
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
25
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
26
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27
+ };
28
+ }
29
+ // ─── Coordinate Helpers ──────────────────────────────────────
30
+ function tileXToChunkIndex(tileX) {
31
+ return Math.floor(tileX / CHUNK_WIDTH);
32
+ }
33
+ function tileXToLocalX(tileX) {
34
+ return ((tileX % CHUNK_WIDTH) + CHUNK_WIDTH) % CHUNK_WIDTH;
35
+ }
36
+ function getTile(world, tileX, tileY) {
37
+ if (tileY < 0 || tileY >= WORLD_HEIGHT)
38
+ return 'air';
39
+ const chunkIdx = tileXToChunkIndex(tileX);
40
+ let chunk = world.chunks.get(chunkIdx);
41
+ if (!chunk) {
42
+ chunk = generateChunk(world, chunkIdx);
43
+ world.chunks.set(chunkIdx, chunk);
44
+ }
45
+ const lx = tileXToLocalX(tileX);
46
+ return chunk.tiles[tileY][lx];
47
+ }
48
+ function setTile(world, tileX, tileY, block) {
49
+ if (tileY < 0 || tileY >= WORLD_HEIGHT)
50
+ return false;
51
+ const chunkIdx = tileXToChunkIndex(tileX);
52
+ let chunk = world.chunks.get(chunkIdx);
53
+ if (!chunk) {
54
+ chunk = generateChunk(world, chunkIdx);
55
+ world.chunks.set(chunkIdx, chunk);
56
+ }
57
+ const lx = tileXToLocalX(tileX);
58
+ chunk.tiles[tileY][lx] = block;
59
+ chunk.modified = true;
60
+ return true;
61
+ }
62
+ // ─── 1. ECOLOGY SYSTEM ───────────────────────────────────────
63
+ const FLOWER_COLORS = ['#ff6b9d', '#c084fc', '#fbbf24', '#f472b6', '#fb923c', '#a78bfa', '#34d399'];
64
+ /**
65
+ * Process one ecology tick. Called every ECOLOGY_TICK_INTERVAL frames (~10 seconds).
66
+ * Returns list of block changes for the renderer to know what to redraw.
67
+ */
68
+ export function tickEcology(world, ecology, frame) {
69
+ const changes = [];
70
+ const rng = seededRandom(frame * 7 + (world.seed || 42));
71
+ // Process only loaded chunks to keep it performant
72
+ for (const [chunkX, chunk] of world.chunks) {
73
+ if (!chunk.generated)
74
+ continue;
75
+ for (let y = 0; y < WORLD_HEIGHT; y++) {
76
+ for (let lx = 0; lx < CHUNK_WIDTH; lx++) {
77
+ const block = chunk.tiles[y][lx];
78
+ const worldTileX = chunkX * CHUNK_WIDTH + lx;
79
+ const roll = rng();
80
+ // --- Grass spreads to adjacent dirt (10% per tick) ---
81
+ if (block === 'dirt' && roll < 0.10) {
82
+ const hasAdjacentGrass = getTile(world, worldTileX - 1, y) === 'grass' ||
83
+ getTile(world, worldTileX + 1, y) === 'grass' ||
84
+ getTile(world, worldTileX, y - 1) === 'grass' ||
85
+ getTile(world, worldTileX, y + 1) === 'grass';
86
+ if (hasAdjacentGrass) {
87
+ chunk.tiles[y][lx] = 'grass';
88
+ chunk.modified = true;
89
+ changes.push({ chunkX, tileX: lx, tileY: y, from: 'dirt', to: 'grass', reason: 'grass_spread' });
90
+ }
91
+ }
92
+ // --- Trees grow from grass (2% chance to sprout sapling) ---
93
+ if (block === 'grass' && roll < 0.02) {
94
+ // Must have air above and not be at edges
95
+ if (y > 6 && lx >= 2 && lx < CHUNK_WIDTH - 2 && getTile(world, worldTileX, y - 1) === 'air') {
96
+ const key = `${chunkX}:${lx}:${y}`;
97
+ const currentGrowth = ecology.growthMap.get(key) || 0;
98
+ if (currentGrowth === 0) {
99
+ // Start a sapling — mark growth stage
100
+ ecology.growthMap.set(key, 0.05);
101
+ }
102
+ }
103
+ }
104
+ // --- Water flows to adjacent air at same or lower Y ---
105
+ if (block === 'water' && roll < 0.15) {
106
+ const directions = [
107
+ { dx: -1, dy: 0 },
108
+ { dx: 1, dy: 0 },
109
+ { dx: 0, dy: 1 }, // down
110
+ ];
111
+ for (const { dx, dy } of directions) {
112
+ const nx = worldTileX + dx;
113
+ const ny = y + dy;
114
+ if (ny >= 0 && ny < WORLD_HEIGHT && getTile(world, nx, ny) === 'air') {
115
+ const nChunkX = tileXToChunkIndex(nx);
116
+ const nLocalX = tileXToLocalX(nx);
117
+ setTile(world, nx, ny, 'water');
118
+ changes.push({ chunkX: nChunkX, tileX: nLocalX, tileY: ny, from: 'air', to: 'water', reason: 'water_flow' });
119
+ break; // Only spread one direction per tick
120
+ }
121
+ }
122
+ }
123
+ // --- Vines creep downward from leaves ---
124
+ if (block === 'leaves' && roll < 0.02) {
125
+ const key = `${worldTileX}:${y}`;
126
+ const coverage = ecology.vineCoverage.get(key) || 0;
127
+ ecology.vineCoverage.set(key, coverage + 1);
128
+ // After 50 ticks of vine growth, the block below becomes a vine (leaves block)
129
+ if (coverage >= 50 && y + 1 < WORLD_HEIGHT) {
130
+ const below = getTile(world, worldTileX, y + 1);
131
+ if (below === 'air') {
132
+ const bChunkX = tileXToChunkIndex(worldTileX);
133
+ const bLocalX = tileXToLocalX(worldTileX);
134
+ setTile(world, worldTileX, y + 1, 'leaves');
135
+ changes.push({ chunkX: bChunkX, tileX: bLocalX, tileY: y + 1, from: 'air', to: 'leaves', reason: 'vine_growth' });
136
+ ecology.vineCoverage.delete(key);
137
+ }
138
+ }
139
+ }
140
+ // --- Erosion: dirt/sand exposed to water (1% chance to erode) ---
141
+ if ((block === 'dirt' || block === 'sand') && roll < 0.01) {
142
+ const hasWater = getTile(world, worldTileX - 1, y) === 'water' ||
143
+ getTile(world, worldTileX + 1, y) === 'water' ||
144
+ getTile(world, worldTileX, y - 1) === 'water' ||
145
+ getTile(world, worldTileX, y + 1) === 'water';
146
+ if (hasWater) {
147
+ if (block === 'sand') {
148
+ // Sand falls: check if air below
149
+ if (y + 1 < WORLD_HEIGHT && getTile(world, worldTileX, y + 1) === 'air') {
150
+ chunk.tiles[y][lx] = 'air';
151
+ setTile(world, worldTileX, y + 1, 'sand');
152
+ chunk.modified = true;
153
+ changes.push({ chunkX, tileX: lx, tileY: y, from: 'sand', to: 'air', reason: 'erosion_sand_fall' });
154
+ }
155
+ }
156
+ else {
157
+ chunk.tiles[y][lx] = 'air';
158
+ chunk.modified = true;
159
+ changes.push({ chunkX, tileX: lx, tileY: y, from: 'dirt', to: 'air', reason: 'erosion' });
160
+ }
161
+ }
162
+ }
163
+ // --- Fire: lava adjacent to wood ignites ---
164
+ if (block === 'lava') {
165
+ const adjacents = [
166
+ { dx: -1, dy: 0 }, { dx: 1, dy: 0 },
167
+ { dx: 0, dy: -1 }, { dx: 0, dy: 1 },
168
+ ];
169
+ for (const { dx, dy } of adjacents) {
170
+ const nx = worldTileX + dx;
171
+ const ny = y + dy;
172
+ const neighbor = getTile(world, nx, ny);
173
+ if (neighbor === 'wood' || neighbor === 'leaves') {
174
+ ecology.fireSpread.push({ x: nx, y: ny, life: 20 });
175
+ }
176
+ }
177
+ }
178
+ // --- Snow melts in daytime (0.5% per tick) ---
179
+ if (block === 'snow' && world.timeOfDay === 'day' && roll < 0.005) {
180
+ chunk.tiles[y][lx] = 'water';
181
+ chunk.modified = true;
182
+ changes.push({ chunkX, tileX: lx, tileY: y, from: 'snow', to: 'water', reason: 'snow_melt' });
183
+ }
184
+ // --- Flowers: grass randomly sprouts decorative flowers (1% per tick) ---
185
+ if (block === 'grass' && roll < 0.01 && y > 0 && getTile(world, worldTileX, y - 1) === 'air') {
186
+ const flowerKey = `${worldTileX}:${y}`;
187
+ if (!ecology.flowerMap.has(flowerKey)) {
188
+ const colorIdx = Math.floor(rng() * FLOWER_COLORS.length);
189
+ ecology.flowerMap.set(flowerKey, FLOWER_COLORS[colorIdx]);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+ // --- Process fire spread ---
196
+ const remainingFires = [];
197
+ for (const fire of ecology.fireSpread) {
198
+ fire.life--;
199
+ if (fire.life <= 0) {
200
+ // Fire burns out — block becomes air
201
+ const block = getTile(world, fire.x, fire.y);
202
+ if (block === 'wood' || block === 'leaves') {
203
+ const fChunkX = tileXToChunkIndex(fire.x);
204
+ const fLocalX = tileXToLocalX(fire.x);
205
+ setTile(world, fire.x, fire.y, 'air');
206
+ changes.push({ chunkX: fChunkX, tileX: fLocalX, tileY: fire.y, from: block, to: 'air', reason: 'fire_burnout' });
207
+ }
208
+ continue;
209
+ }
210
+ // Fire spreads to adjacent wood/leaves
211
+ if (fire.life % 5 === 0) {
212
+ const directions = [
213
+ { dx: -1, dy: 0 }, { dx: 1, dy: 0 },
214
+ { dx: 0, dy: -1 }, { dx: 0, dy: 1 },
215
+ ];
216
+ for (const { dx, dy } of directions) {
217
+ const nx = fire.x + dx;
218
+ const ny = fire.y + dy;
219
+ const neighbor = getTile(world, nx, ny);
220
+ if (neighbor === 'wood' || neighbor === 'leaves') {
221
+ // Check not already on fire
222
+ const alreadyBurning = ecology.fireSpread.some(f => f.x === nx && f.y === ny);
223
+ if (!alreadyBurning) {
224
+ remainingFires.push({ x: nx, y: ny, life: 20 });
225
+ }
226
+ }
227
+ }
228
+ }
229
+ remainingFires.push(fire);
230
+ }
231
+ ecology.fireSpread = remainingFires;
232
+ // --- Process tree growth (saplings in growthMap) ---
233
+ for (const [key, growth] of ecology.growthMap) {
234
+ const newGrowth = growth + (1 / 30); // full tree in 30 ticks (~5 minutes)
235
+ if (newGrowth >= 1.0) {
236
+ // Fully grown — place a tree
237
+ const parts = key.split(':');
238
+ const cX = parseInt(parts[0]);
239
+ const localX = parseInt(parts[1]);
240
+ const baseY = parseInt(parts[2]);
241
+ const worldTileX = cX * CHUNK_WIDTH + localX;
242
+ // Place trunk (3 blocks)
243
+ for (let dy = 1; dy <= 3; dy++) {
244
+ if (baseY - dy >= 0) {
245
+ setTile(world, worldTileX, baseY - dy, 'wood');
246
+ const tChunkX = tileXToChunkIndex(worldTileX);
247
+ const tLocalX = tileXToLocalX(worldTileX);
248
+ changes.push({ chunkX: tChunkX, tileX: tLocalX, tileY: baseY - dy, from: 'air', to: 'wood', reason: 'tree_growth' });
249
+ }
250
+ }
251
+ // Place leaves crown (3x3)
252
+ const crownY = baseY - 3;
253
+ for (let dy = -1; dy <= 1; dy++) {
254
+ for (let dx = -1; dx <= 1; dx++) {
255
+ const ly = crownY + dy;
256
+ const lx = worldTileX + dx;
257
+ if (ly >= 0 && ly < WORLD_HEIGHT && getTile(world, lx, ly) === 'air') {
258
+ setTile(world, lx, ly, 'leaves');
259
+ const lChunkX = tileXToChunkIndex(lx);
260
+ const lLocalX = tileXToLocalX(lx);
261
+ changes.push({ chunkX: lChunkX, tileX: lLocalX, tileY: ly, from: 'air', to: 'leaves', reason: 'tree_growth' });
262
+ }
263
+ }
264
+ }
265
+ // Top leaf
266
+ if (crownY - 1 >= 0 && getTile(world, worldTileX, crownY - 1) === 'air') {
267
+ setTile(world, worldTileX, crownY - 1, 'leaves');
268
+ const topChunkX = tileXToChunkIndex(worldTileX);
269
+ const topLocalX = tileXToLocalX(worldTileX);
270
+ changes.push({ chunkX: topChunkX, tileX: topLocalX, tileY: crownY - 1, from: 'air', to: 'leaves', reason: 'tree_growth' });
271
+ }
272
+ ecology.growthMap.delete(key);
273
+ }
274
+ else {
275
+ ecology.growthMap.set(key, newGrowth);
276
+ }
277
+ }
278
+ return changes;
279
+ }
280
+ // ─── 2. MEMORY IN TERRAIN ────────────────────────────────────
281
+ export function recordFootstep(memory, worldX, worldY) {
282
+ const key = `${worldX}:${worldY}`;
283
+ memory.footpaths.set(key, (memory.footpaths.get(key) || 0) + 1);
284
+ }
285
+ export function recordLandmark(memory, x, y, name, creator, type, description) {
286
+ const validType = (type === 'build' || type === 'event' || type === 'discovery' || type === 'dream')
287
+ ? type
288
+ : 'build';
289
+ memory.landmarks.push({
290
+ x, y, name, creator,
291
+ type: validType,
292
+ timestamp: Date.now(),
293
+ description,
294
+ });
295
+ }
296
+ export function recordChatActivity(memory, chunkX) {
297
+ const key = String(chunkX);
298
+ memory.chatHeatmap.set(key, (memory.chatHeatmap.get(key) || 0) + 1);
299
+ }
300
+ export function recordEvent(memory, x, y, type) {
301
+ memory.events.push({ x, y, type, timestamp: Date.now() });
302
+ }
303
+ /**
304
+ * Render memory effects as overlays on the tile world.
305
+ * Called after tile rendering, before UI.
306
+ */
307
+ export function renderMemoryEffects(ctx, memory, cameraX, tileSize, frame) {
308
+ ctx.save();
309
+ // --- Footpath overlays: lighter pixels on walked tiles ---
310
+ for (const [key, count] of memory.footpaths) {
311
+ if (count < 10)
312
+ continue;
313
+ const [xStr, yStr] = key.split(':');
314
+ const tileX = parseInt(xStr);
315
+ const tileY = parseInt(yStr);
316
+ const screenX = tileX * tileSize - Math.floor(cameraX);
317
+ const screenY = tileY * tileSize;
318
+ // Skip off-screen
319
+ if (screenX < -tileSize || screenX > 800 || screenY < -tileSize || screenY > 600)
320
+ continue;
321
+ if (count >= 50) {
322
+ // Proper trail — stone-like color
323
+ ctx.fillStyle = 'rgba(156, 163, 175, 0.35)';
324
+ }
325
+ else {
326
+ // Worn path — lighter dirt
327
+ ctx.fillStyle = 'rgba(200, 180, 150, 0.2)';
328
+ }
329
+ ctx.fillRect(screenX + 2, screenY + tileSize - 3, tileSize - 4, 2);
330
+ }
331
+ // --- Landmark markers: tiny flags ---
332
+ for (const landmark of memory.landmarks) {
333
+ const screenX = landmark.x * tileSize - Math.floor(cameraX);
334
+ const screenY = landmark.y * tileSize;
335
+ if (screenX < -tileSize || screenX > 800 || screenY < -tileSize || screenY > 600)
336
+ continue;
337
+ // Flagpole (thin white line)
338
+ ctx.fillStyle = '#ffffff';
339
+ ctx.fillRect(screenX + Math.floor(tileSize / 2), screenY - 8, 1, 8);
340
+ // Flag (colored by type)
341
+ switch (landmark.type) {
342
+ case 'build':
343
+ ctx.fillStyle = '#fbbf24';
344
+ break; // gold
345
+ case 'event':
346
+ ctx.fillStyle = '#f87171';
347
+ break; // red
348
+ case 'discovery':
349
+ ctx.fillStyle = '#34d399';
350
+ break; // green
351
+ case 'dream':
352
+ ctx.fillStyle = '#a78bfa';
353
+ break; // purple
354
+ }
355
+ ctx.fillRect(screenX + Math.floor(tileSize / 2) + 1, screenY - 8, 4, 3);
356
+ // Pulse animation for recent landmarks (< 60 seconds old)
357
+ const age = Date.now() - landmark.timestamp;
358
+ if (age < 60_000) {
359
+ const pulse = Math.sin(frame * 0.2) * 0.3 + 0.3;
360
+ ctx.globalAlpha = pulse;
361
+ ctx.fillRect(screenX + Math.floor(tileSize / 2) - 1, screenY - 10, 7, 5);
362
+ ctx.globalAlpha = 1;
363
+ }
364
+ }
365
+ // --- Chat heatmap tint: warm/cool color wash per chunk ---
366
+ // (Applied per-chunk as a large overlay rectangle)
367
+ for (const [chunkKey, count] of memory.chatHeatmap) {
368
+ const chunkX = parseInt(chunkKey);
369
+ const chunkScreenX = chunkX * CHUNK_WIDTH * tileSize - Math.floor(cameraX);
370
+ const chunkPixelWidth = CHUNK_WIDTH * tileSize;
371
+ if (chunkScreenX + chunkPixelWidth < 0 || chunkScreenX > 800)
372
+ continue;
373
+ if (count > 5) {
374
+ // Warm tint — active area
375
+ const intensity = Math.min(0.08, count * 0.005);
376
+ ctx.fillStyle = `rgba(255, 160, 60, ${intensity})`;
377
+ }
378
+ else {
379
+ // Cool tint — quiet area
380
+ ctx.fillStyle = 'rgba(60, 100, 200, 0.03)';
381
+ }
382
+ ctx.fillRect(chunkScreenX, 0, chunkPixelWidth, WORLD_HEIGHT * tileSize);
383
+ }
384
+ ctx.restore();
385
+ }
386
+ // ─── 3. EMOTIONAL GEOGRAPHY ──────────────────────────────────
387
+ /**
388
+ * Update the emotional map based on activity, memory, and time.
389
+ */
390
+ export function updateEmotionalMap(emotions, memory, currentChunkX, chatActive, frame) {
391
+ const key = String(currentChunkX);
392
+ // Initialize zone if new
393
+ if (!emotions.zones.has(key)) {
394
+ emotions.zones.set(key, {
395
+ warmth: 0,
396
+ mystery: 1.0,
397
+ nostalgia: 0,
398
+ energy: 0,
399
+ });
400
+ }
401
+ const zone = emotions.zones.get(key);
402
+ // Warmth: increases when chat active, decays slowly
403
+ if (chatActive) {
404
+ zone.warmth = Math.min(1.0, zone.warmth + 0.02);
405
+ }
406
+ else {
407
+ zone.warmth = Math.max(0, zone.warmth - 0.001);
408
+ }
409
+ // Mystery: decreases each visit, never reaches 0
410
+ zone.mystery = Math.max(0.05, zone.mystery - 0.005);
411
+ // Nostalgia: increases over time for visited chunks
412
+ zone.nostalgia = Math.min(1.0, zone.nostalgia + 0.001);
413
+ // Energy: check for recent events near this chunk
414
+ const recentEvents = memory.events.filter(e => {
415
+ const eventChunk = tileXToChunkIndex(e.x);
416
+ return eventChunk === currentChunkX && (Date.now() - e.timestamp) < 30_000;
417
+ });
418
+ if (recentEvents.length > 0) {
419
+ zone.energy = Math.min(1.0, zone.energy + 0.1 * recentEvents.length);
420
+ }
421
+ else {
422
+ zone.energy = Math.max(0, zone.energy - 0.02);
423
+ }
424
+ // Decay all zones slightly (time passes)
425
+ for (const [zoneKey, z] of emotions.zones) {
426
+ if (zoneKey === key)
427
+ continue; // Current zone already processed
428
+ z.warmth = Math.max(0, z.warmth - 0.0005);
429
+ z.energy = Math.max(0, z.energy - 0.005);
430
+ z.nostalgia = Math.min(1.0, z.nostalgia + 0.0005);
431
+ }
432
+ }
433
+ /**
434
+ * Get the emotional tint color for a chunk.
435
+ */
436
+ export function getEmotionalTint(emotions, chunkX) {
437
+ const key = String(chunkX);
438
+ const zone = emotions.zones.get(key);
439
+ if (!zone) {
440
+ // Unvisited — high mystery
441
+ return { r: 0, g: 0, b: 20, a: 0.08 };
442
+ }
443
+ let r = 0, g = 0, b = 0, a = 0;
444
+ // High warmth: warm orange tint
445
+ if (zone.warmth > 0.1) {
446
+ r += 20 * zone.warmth;
447
+ g += 10 * zone.warmth;
448
+ a += 0.05 * zone.warmth;
449
+ }
450
+ // High mystery: cool blue tint
451
+ if (zone.mystery > 0.3) {
452
+ b += 20 * zone.mystery;
453
+ a += 0.08 * zone.mystery;
454
+ }
455
+ // High nostalgia: golden haze
456
+ if (zone.nostalgia > 0.2) {
457
+ r += 15 * zone.nostalgia;
458
+ g += 12 * zone.nostalgia;
459
+ a += 0.04 * zone.nostalgia;
460
+ }
461
+ // High energy: bright white flash (pulsing)
462
+ if (zone.energy > 0.1) {
463
+ const pulse = Math.sin(Date.now() * 0.005) * 0.5 + 0.5;
464
+ r += 10 * zone.energy * pulse;
465
+ g += 10 * zone.energy * pulse;
466
+ b += 10 * zone.energy * pulse;
467
+ a += 0.03 * zone.energy * pulse;
468
+ }
469
+ return {
470
+ r: Math.min(30, Math.round(r)),
471
+ g: Math.min(25, Math.round(g)),
472
+ b: Math.min(30, Math.round(b)),
473
+ a: Math.min(0.15, a),
474
+ };
475
+ }
476
+ // ─── 4. CONVERSATION GEOLOGY ─────────────────────────────────
477
+ const TOPIC_MAPPING = {
478
+ music: { type: 'crystal', color: '#a78bfa' }, // purple crystal
479
+ code: { type: 'artifact', color: '#34d399' }, // green circuit
480
+ ai: { type: 'artifact', color: '#c084fc' }, // purple neural
481
+ security: { type: 'inscription', color: '#f87171' }, // red shield
482
+ nature: { type: 'fossil', color: '#a16207' }, // brown fossil
483
+ art: { type: 'crystal', color: '#fb923c' }, // orange crystal
484
+ science: { type: 'artifact', color: '#38bdf8' }, // blue circuit
485
+ gaming: { type: 'crystal', color: '#fbbf24' }, // gold crystal
486
+ math: { type: 'inscription', color: '#22d3ee' }, // cyan inscription
487
+ };
488
+ /**
489
+ * Generate a conversation deposit from a chat topic.
490
+ */
491
+ export function generateConversationDeposit(topic, username, worldX) {
492
+ const mapping = TOPIC_MAPPING[topic.toLowerCase()] || { type: 'fossil', color: '#9ca3af' };
493
+ const rng = seededRandom(worldX * 31 + topic.length * 17 + Date.now());
494
+ // Place at random depth underground (deeper = older conversations over time)
495
+ const minDepth = 15; // below surface
496
+ const maxDepth = WORLD_HEIGHT - 3;
497
+ const depth = minDepth + Math.floor(rng() * (maxDepth - minDepth));
498
+ return {
499
+ x: worldX,
500
+ y: depth,
501
+ topic,
502
+ username,
503
+ type: mapping.type,
504
+ depth,
505
+ };
506
+ }
507
+ /**
508
+ * Render a conversation deposit as a special block pattern.
509
+ */
510
+ export function renderConversationDeposit(ctx, deposit, screenX, screenY, tileSize) {
511
+ const mapping = TOPIC_MAPPING[deposit.topic.toLowerCase()] || { type: 'fossil', color: '#9ca3af' };
512
+ const S = tileSize;
513
+ ctx.save();
514
+ switch (deposit.type) {
515
+ case 'crystal':
516
+ // Crystal: diamond-shaped bright pattern (2x2)
517
+ ctx.fillStyle = mapping.color;
518
+ ctx.globalAlpha = 0.7;
519
+ // Center diamond
520
+ ctx.fillRect(screenX + Math.floor(S / 2) - 1, screenY + 2, 2, S - 4);
521
+ ctx.fillRect(screenX + 2, screenY + Math.floor(S / 2) - 1, S - 4, 2);
522
+ // Sparkle dots
523
+ ctx.globalAlpha = 0.4;
524
+ ctx.fillRect(screenX + 3, screenY + 3, 1, 1);
525
+ ctx.fillRect(screenX + S - 4, screenY + S - 4, 1, 1);
526
+ break;
527
+ case 'artifact':
528
+ // Circuit: grid-like green/purple pattern
529
+ ctx.fillStyle = mapping.color;
530
+ ctx.globalAlpha = 0.6;
531
+ // Horizontal lines
532
+ ctx.fillRect(screenX + 2, screenY + 4, S - 4, 1);
533
+ ctx.fillRect(screenX + 2, screenY + S - 5, S - 4, 1);
534
+ // Vertical lines
535
+ ctx.fillRect(screenX + 4, screenY + 2, 1, S - 4);
536
+ ctx.fillRect(screenX + S - 5, screenY + 2, 1, S - 4);
537
+ // Nodes at intersections
538
+ ctx.globalAlpha = 0.9;
539
+ ctx.fillRect(screenX + 4, screenY + 4, 2, 2);
540
+ ctx.fillRect(screenX + S - 6, screenY + S - 6, 2, 2);
541
+ break;
542
+ case 'inscription':
543
+ // Shield/rune: bordered rectangle with inner mark
544
+ ctx.strokeStyle = mapping.color;
545
+ ctx.globalAlpha = 0.6;
546
+ ctx.lineWidth = 1;
547
+ ctx.strokeRect(screenX + 3, screenY + 2, S - 6, S - 4);
548
+ // Inner glyph (X mark)
549
+ ctx.fillStyle = mapping.color;
550
+ ctx.globalAlpha = 0.8;
551
+ ctx.fillRect(screenX + 5, screenY + 5, 1, 1);
552
+ ctx.fillRect(screenX + S - 6, screenY + 5, 1, 1);
553
+ ctx.fillRect(screenX + Math.floor(S / 2), screenY + Math.floor(S / 2), 1, 1);
554
+ ctx.fillRect(screenX + 5, screenY + S - 6, 1, 1);
555
+ ctx.fillRect(screenX + S - 6, screenY + S - 6, 1, 1);
556
+ break;
557
+ case 'fossil':
558
+ // Leaf/spiral fossil pattern
559
+ ctx.fillStyle = mapping.color;
560
+ ctx.globalAlpha = 0.5;
561
+ // Spiral approximation
562
+ ctx.fillRect(screenX + 5, screenY + 4, 4, 1);
563
+ ctx.fillRect(screenX + 8, screenY + 5, 1, 3);
564
+ ctx.fillRect(screenX + 5, screenY + 7, 4, 1);
565
+ ctx.fillRect(screenX + 5, screenY + 5, 1, 2);
566
+ ctx.fillRect(screenX + 6, screenY + 6, 2, 1);
567
+ break;
568
+ }
569
+ ctx.restore();
570
+ }
571
+ // ─── 5. DREAM TERRAIN ────────────────────────────────────────
572
+ const DREAM_KEYWORD_MAP = {
573
+ mountain: { block: 'stone', action: 'raise' },
574
+ mountains: { block: 'stone', action: 'raise' },
575
+ hill: { block: 'dirt', action: 'raise' },
576
+ ocean: { block: 'water', action: 'pool' },
577
+ water: { block: 'water', action: 'pool' },
578
+ river: { block: 'water', action: 'pool' },
579
+ star: { block: 'ore_diamond', action: 'underground' },
580
+ stars: { block: 'ore_diamond', action: 'underground' },
581
+ fire: { block: 'lava', action: 'underground' },
582
+ flame: { block: 'lava', action: 'underground' },
583
+ forest: { block: 'leaves', action: 'raise' },
584
+ tree: { block: 'wood', action: 'raise' },
585
+ ice: { block: 'ice', action: 'surface' },
586
+ snow: { block: 'snow', action: 'surface' },
587
+ gold: { block: 'ore_gold', action: 'underground' },
588
+ crystal: { block: 'ore_diamond', action: 'underground' },
589
+ brick: { block: 'brick', action: 'surface' },
590
+ sand: { block: 'sand', action: 'surface' },
591
+ desert: { block: 'sand', action: 'surface' },
592
+ };
593
+ /**
594
+ * Apply dream-induced terrain changes. Called when the robot wakes from dreaming.
595
+ * Returns 3-5 subtle block changes based on dream content keywords.
596
+ */
597
+ export function applyDreamChanges(world, dreamInsights) {
598
+ const changes = [];
599
+ const combined = dreamInsights.join(' ').toLowerCase();
600
+ const rng = seededRandom(Date.now());
601
+ // Find matching dream keywords
602
+ const matches = [];
603
+ for (const [keyword, mapping] of Object.entries(DREAM_KEYWORD_MAP)) {
604
+ if (combined.includes(keyword)) {
605
+ matches.push(mapping);
606
+ }
607
+ }
608
+ // Default: random gentle change if no keywords matched
609
+ if (matches.length === 0) {
610
+ matches.push({ block: 'grass', action: 'surface' });
611
+ }
612
+ // Apply 3-5 changes
613
+ const numChanges = 3 + Math.floor(rng() * 3);
614
+ const cameraChunk = tileXToChunkIndex(Math.floor(world.cameraX / TILE_SIZE));
615
+ for (let i = 0; i < Math.min(numChanges, matches.length + 2); i++) {
616
+ const match = matches[i % matches.length];
617
+ const offsetX = Math.floor(rng() * CHUNK_WIDTH);
618
+ const worldTileX = cameraChunk * CHUNK_WIDTH + offsetX;
619
+ switch (match.action) {
620
+ case 'raise': {
621
+ // Find surface and raise 3-4 blocks
622
+ let surfaceY = -1;
623
+ for (let y = 0; y < WORLD_HEIGHT; y++) {
624
+ const block = getTile(world, worldTileX, y);
625
+ if (block !== 'air' && block !== 'water' && block !== 'leaves') {
626
+ surfaceY = y;
627
+ break;
628
+ }
629
+ }
630
+ if (surfaceY > 3) {
631
+ const raiseHeight = 2 + Math.floor(rng() * 3);
632
+ for (let dy = 1; dy <= raiseHeight; dy++) {
633
+ const ty = surfaceY - dy;
634
+ if (ty >= 0 && getTile(world, worldTileX, ty) === 'air') {
635
+ setTile(world, worldTileX, ty, match.block);
636
+ const cX = tileXToChunkIndex(worldTileX);
637
+ const lX = tileXToLocalX(worldTileX);
638
+ changes.push({ chunkX: cX, tileX: lX, tileY: ty, from: 'air', to: match.block, reason: 'dream' });
639
+ }
640
+ }
641
+ }
642
+ break;
643
+ }
644
+ case 'pool': {
645
+ // Find surface and place water pool (3 wide, 1 deep)
646
+ let surfaceY = -1;
647
+ for (let y = 0; y < WORLD_HEIGHT; y++) {
648
+ const block = getTile(world, worldTileX, y);
649
+ if (block !== 'air' && block !== 'water' && block !== 'leaves') {
650
+ surfaceY = y;
651
+ break;
652
+ }
653
+ }
654
+ if (surfaceY > 0 && surfaceY < WORLD_HEIGHT - 1) {
655
+ for (let dx = -1; dx <= 1; dx++) {
656
+ const tx = worldTileX + dx;
657
+ const prevBlock = getTile(world, tx, surfaceY);
658
+ if (prevBlock !== 'water' && prevBlock !== 'lava') {
659
+ setTile(world, tx, surfaceY, 'water');
660
+ const cX = tileXToChunkIndex(tx);
661
+ const lX = tileXToLocalX(tx);
662
+ changes.push({ chunkX: cX, tileX: lX, tileY: surfaceY, from: prevBlock, to: 'water', reason: 'dream' });
663
+ }
664
+ }
665
+ }
666
+ break;
667
+ }
668
+ case 'underground': {
669
+ // Place ore/lava deep underground
670
+ const depth = 25 + Math.floor(rng() * (WORLD_HEIGHT - 28));
671
+ if (depth < WORLD_HEIGHT) {
672
+ const prevBlock = getTile(world, worldTileX, depth);
673
+ if (prevBlock === 'stone' || prevBlock === 'dirt') {
674
+ setTile(world, worldTileX, depth, match.block);
675
+ const cX = tileXToChunkIndex(worldTileX);
676
+ const lX = tileXToLocalX(worldTileX);
677
+ changes.push({ chunkX: cX, tileX: lX, tileY: depth, from: prevBlock, to: match.block, reason: 'dream' });
678
+ }
679
+ }
680
+ break;
681
+ }
682
+ case 'surface': {
683
+ // Replace surface block
684
+ let surfaceY = -1;
685
+ for (let y = 0; y < WORLD_HEIGHT; y++) {
686
+ const block = getTile(world, worldTileX, y);
687
+ if (block !== 'air' && block !== 'water' && block !== 'leaves') {
688
+ surfaceY = y;
689
+ break;
690
+ }
691
+ }
692
+ if (surfaceY >= 0) {
693
+ const prevBlock = getTile(world, worldTileX, surfaceY);
694
+ setTile(world, worldTileX, surfaceY, match.block);
695
+ const cX = tileXToChunkIndex(worldTileX);
696
+ const lX = tileXToLocalX(worldTileX);
697
+ changes.push({ chunkX: cX, tileX: lX, tileY: surfaceY, from: prevBlock, to: match.block, reason: 'dream' });
698
+ }
699
+ break;
700
+ }
701
+ }
702
+ }
703
+ return changes;
704
+ }
705
+ // ─── 6. WORLD EVOLUTION (Between Streams) ────────────────────
706
+ /**
707
+ * Simulate what happened while the stream was off.
708
+ * Called when a stream starts with a previously saved world.
709
+ * Accelerated: 1 tick per simulated hour.
710
+ */
711
+ export function evolveWorld(world, ecology, hoursElapsed) {
712
+ const changes = [];
713
+ const ticksToSimulate = Math.min(hoursElapsed, MAX_EVOLUTION_CHANGES); // Cap simulation
714
+ const rng = seededRandom(Date.now());
715
+ for (let tick = 0; tick < ticksToSimulate && changes.length < MAX_EVOLUTION_CHANGES; tick++) {
716
+ for (const [chunkX, chunk] of world.chunks) {
717
+ if (changes.length >= MAX_EVOLUTION_CHANGES)
718
+ break;
719
+ for (let y = 0; y < WORLD_HEIGHT && changes.length < MAX_EVOLUTION_CHANGES; y++) {
720
+ for (let lx = 0; lx < CHUNK_WIDTH && changes.length < MAX_EVOLUTION_CHANGES; lx++) {
721
+ const block = chunk.tiles[y][lx];
722
+ const worldTileX = chunkX * CHUNK_WIDTH + lx;
723
+ const roll = rng();
724
+ // Grass spreads (accelerated)
725
+ if (block === 'dirt' && roll < 0.10) {
726
+ const hasGrass = getTile(world, worldTileX - 1, y) === 'grass' ||
727
+ getTile(world, worldTileX + 1, y) === 'grass' ||
728
+ getTile(world, worldTileX, y - 1) === 'grass' ||
729
+ getTile(world, worldTileX, y + 1) === 'grass';
730
+ if (hasGrass) {
731
+ chunk.tiles[y][lx] = 'grass';
732
+ chunk.modified = true;
733
+ changes.push({ chunkX, tileX: lx, tileY: y, from: 'dirt', to: 'grass', reason: 'evolution_grass' });
734
+ }
735
+ }
736
+ // Trees grow (saplings become full trees instantly in evolution)
737
+ // Process existing saplings from growthMap
738
+ const growthKey = `${chunkX}:${lx}:${y}`;
739
+ const growth = ecology.growthMap.get(growthKey);
740
+ if (growth !== undefined && growth < 1.0) {
741
+ ecology.growthMap.set(growthKey, Math.min(1.0, growth + 0.2)); // 5 hours to full tree
742
+ }
743
+ // Vines extend
744
+ if (block === 'leaves' && roll < 0.05) {
745
+ const vineKey = `${worldTileX}:${y}`;
746
+ const coverage = ecology.vineCoverage.get(vineKey) || 0;
747
+ ecology.vineCoverage.set(vineKey, coverage + 10); // Accelerated
748
+ if (coverage + 10 >= 50 && y + 1 < WORLD_HEIGHT) {
749
+ const below = getTile(world, worldTileX, y + 1);
750
+ if (below === 'air') {
751
+ setTile(world, worldTileX, y + 1, 'leaves');
752
+ const bChunkX = tileXToChunkIndex(worldTileX);
753
+ const bLocalX = tileXToLocalX(worldTileX);
754
+ changes.push({ chunkX: bChunkX, tileX: bLocalX, tileY: y + 1, from: 'air', to: 'leaves', reason: 'evolution_vine' });
755
+ ecology.vineCoverage.delete(vineKey);
756
+ }
757
+ }
758
+ }
759
+ // Erosion progresses
760
+ if ((block === 'dirt' || block === 'sand') && roll < 0.005) {
761
+ const hasWater = getTile(world, worldTileX - 1, y) === 'water' ||
762
+ getTile(world, worldTileX + 1, y) === 'water' ||
763
+ getTile(world, worldTileX, y - 1) === 'water' ||
764
+ getTile(world, worldTileX, y + 1) === 'water';
765
+ if (hasWater) {
766
+ chunk.tiles[y][lx] = 'air';
767
+ chunk.modified = true;
768
+ changes.push({ chunkX, tileX: lx, tileY: y, from: block, to: 'air', reason: 'evolution_erosion' });
769
+ }
770
+ }
771
+ }
772
+ }
773
+ }
774
+ }
775
+ return changes;
776
+ }
777
+ // ─── 7. PERSISTENCE ──────────────────────────────────────────
778
+ /** Serialize a Map to a plain object for JSON */
779
+ function mapToObj(map) {
780
+ const obj = {};
781
+ for (const [k, v] of map) {
782
+ obj[k] = v;
783
+ }
784
+ return obj;
785
+ }
786
+ /** Deserialize a plain object back to a Map */
787
+ function objToMap(obj) {
788
+ const map = new Map();
789
+ if (obj) {
790
+ for (const [k, v] of Object.entries(obj)) {
791
+ map.set(k, v);
792
+ }
793
+ }
794
+ return map;
795
+ }
796
+ /**
797
+ * Save living world state to disk alongside tile data.
798
+ */
799
+ export function saveLivingWorldState(ecology, memory, emotions, conversations) {
800
+ try {
801
+ if (!existsSync(KBOT_DIR)) {
802
+ mkdirSync(KBOT_DIR, { recursive: true });
803
+ }
804
+ const data = {
805
+ version: 1,
806
+ savedAt: Date.now(),
807
+ ecology: {
808
+ growthMap: mapToObj(ecology.growthMap),
809
+ moistureMap: mapToObj(ecology.moistureMap),
810
+ fireSpread: ecology.fireSpread,
811
+ vineCoverage: mapToObj(ecology.vineCoverage),
812
+ flowerMap: mapToObj(ecology.flowerMap),
813
+ },
814
+ memory: {
815
+ footpaths: mapToObj(memory.footpaths),
816
+ landmarks: memory.landmarks,
817
+ chatHeatmap: mapToObj(memory.chatHeatmap),
818
+ events: memory.events,
819
+ },
820
+ emotions: {
821
+ zones: mapToObj(emotions.zones),
822
+ },
823
+ conversations: {
824
+ deposits: conversations.deposits,
825
+ },
826
+ };
827
+ writeFileSync(LIVING_WORLD_FILE, JSON.stringify(data));
828
+ }
829
+ catch {
830
+ // Silently fail — don't crash the stream
831
+ }
832
+ }
833
+ /**
834
+ * Load living world state from disk.
835
+ */
836
+ export function loadLivingWorldState() {
837
+ try {
838
+ if (!existsSync(LIVING_WORLD_FILE))
839
+ return null;
840
+ const raw = JSON.parse(readFileSync(LIVING_WORLD_FILE, 'utf-8'));
841
+ if (!raw || raw.version !== 1)
842
+ return null;
843
+ return {
844
+ ecology: {
845
+ growthMap: objToMap(raw.ecology?.growthMap),
846
+ moistureMap: objToMap(raw.ecology?.moistureMap),
847
+ fireSpread: Array.isArray(raw.ecology?.fireSpread) ? raw.ecology.fireSpread : [],
848
+ vineCoverage: objToMap(raw.ecology?.vineCoverage),
849
+ flowerMap: objToMap(raw.ecology?.flowerMap),
850
+ },
851
+ memory: {
852
+ footpaths: objToMap(raw.memory?.footpaths),
853
+ landmarks: Array.isArray(raw.memory?.landmarks) ? raw.memory.landmarks : [],
854
+ chatHeatmap: objToMap(raw.memory?.chatHeatmap),
855
+ events: Array.isArray(raw.memory?.events) ? raw.memory.events : [],
856
+ },
857
+ emotions: {
858
+ zones: objToMap(raw.emotions?.zones),
859
+ },
860
+ conversations: {
861
+ deposits: Array.isArray(raw.conversations?.deposits) ? raw.conversations.deposits : [],
862
+ },
863
+ };
864
+ }
865
+ catch {
866
+ return null;
867
+ }
868
+ }
869
+ // ─── Integration Points ──────────────────────────────────────
870
+ /**
871
+ * Initialize all living world subsystems.
872
+ * Call at stream start. Loads from disk if available.
873
+ */
874
+ export function initLivingWorld() {
875
+ // Try loading from disk first
876
+ const saved = loadLivingWorldState();
877
+ if (saved)
878
+ return saved;
879
+ // Fresh state
880
+ return {
881
+ ecology: {
882
+ growthMap: new Map(),
883
+ moistureMap: new Map(),
884
+ fireSpread: [],
885
+ vineCoverage: new Map(),
886
+ flowerMap: new Map(),
887
+ },
888
+ memory: {
889
+ footpaths: new Map(),
890
+ landmarks: [],
891
+ chatHeatmap: new Map(),
892
+ events: [],
893
+ },
894
+ emotions: {
895
+ zones: new Map(),
896
+ },
897
+ conversations: {
898
+ deposits: [],
899
+ },
900
+ };
901
+ }
902
+ /**
903
+ * Main tick function. Call every ECOLOGY_TICK_INTERVAL frames (~10 seconds).
904
+ * Processes ecology, updates emotional map, records robot footstep.
905
+ */
906
+ export function tickLivingWorld(world, ecology, memory, emotions, conversations, robotX, chatActive, frame) {
907
+ // Only tick ecology every ECOLOGY_TICK_INTERVAL frames
908
+ const changes = (frame % ECOLOGY_TICK_INTERVAL === 0)
909
+ ? tickEcology(world, ecology, frame)
910
+ : [];
911
+ // Record robot footstep
912
+ const robotTileX = Math.floor(robotX / TILE_SIZE);
913
+ let robotTileY = -1;
914
+ for (let y = 0; y < WORLD_HEIGHT; y++) {
915
+ const block = getTile(world, robotTileX, y);
916
+ if (block !== 'air' && block !== 'water' && block !== 'leaves') {
917
+ robotTileY = y - 1; // robot stands on top of the block
918
+ break;
919
+ }
920
+ }
921
+ if (robotTileY >= 0) {
922
+ recordFootstep(memory, robotTileX, robotTileY);
923
+ }
924
+ // Update emotional map
925
+ const currentChunkX = tileXToChunkIndex(robotTileX);
926
+ updateEmotionalMap(emotions, memory, currentChunkX, chatActive, frame);
927
+ // Check if robot found a conversation deposit while digging
928
+ const newLandmarks = [];
929
+ if (robotTileY >= 0) {
930
+ for (let i = conversations.deposits.length - 1; i >= 0; i--) {
931
+ const deposit = conversations.deposits[i];
932
+ const depositTileX = Math.floor(deposit.x / TILE_SIZE);
933
+ // Check proximity (within 2 tiles)
934
+ if (Math.abs(depositTileX - robotTileX) <= 2 && Math.abs(deposit.y - robotTileY) <= 2) {
935
+ const label = `Found a ${deposit.type} from when @${deposit.username} was talking about ${deposit.topic}!`;
936
+ newLandmarks.push(label);
937
+ // Record as landmark
938
+ recordLandmark(memory, robotTileX, robotTileY, `${deposit.topic} ${deposit.type}`, deposit.username, 'discovery', label);
939
+ // Remove the deposit (it's been found)
940
+ conversations.deposits.splice(i, 1);
941
+ }
942
+ }
943
+ }
944
+ // Auto-save periodically (every 300 frames = ~50 seconds)
945
+ if (frame % 300 === 0) {
946
+ saveLivingWorldState(ecology, memory, emotions, conversations);
947
+ }
948
+ return { changes, newLandmarks };
949
+ }
950
+ /**
951
+ * Render all living world overlays on top of the tile world.
952
+ * Call after tile rendering, before UI layer.
953
+ */
954
+ export function renderLivingWorldOverlays(ctx, memory, emotions, conversations, cameraX, tileSize, frame) {
955
+ ctx.save();
956
+ // 1. Emotional tint per chunk (background wash)
957
+ const startChunk = tileXToChunkIndex(Math.floor(cameraX / tileSize)) - 1;
958
+ const endChunk = startChunk + 3;
959
+ for (let cx = startChunk; cx <= endChunk; cx++) {
960
+ const tint = getEmotionalTint(emotions, cx);
961
+ if (tint.a > 0.005) {
962
+ const chunkScreenX = cx * CHUNK_WIDTH * tileSize - Math.floor(cameraX);
963
+ const chunkPixelWidth = CHUNK_WIDTH * tileSize;
964
+ ctx.fillStyle = `rgba(${tint.r}, ${tint.g}, ${tint.b}, ${tint.a})`;
965
+ ctx.fillRect(chunkScreenX, 0, chunkPixelWidth, WORLD_HEIGHT * tileSize);
966
+ }
967
+ }
968
+ // 2. Memory effects (footpaths, landmarks, heatmap)
969
+ renderMemoryEffects(ctx, memory, cameraX, tileSize, frame);
970
+ // 3. Visible conversation deposits (underground patterns)
971
+ for (const deposit of conversations.deposits) {
972
+ const screenX = Math.floor(deposit.x / tileSize) * tileSize - Math.floor(cameraX);
973
+ const screenY = deposit.y * tileSize;
974
+ // Only render if on screen
975
+ if (screenX >= -tileSize && screenX <= 800 && screenY >= -tileSize && screenY <= 800) {
976
+ renderConversationDeposit(ctx, deposit, screenX, screenY, tileSize);
977
+ }
978
+ }
979
+ // 4. Flower decorations (tiny colored dots on grass)
980
+ const ecology = undefined; // Flowers stored in ecology but rendered here via closure
981
+ // (The caller should pass ecology.flowerMap, but we render from the data we have)
982
+ ctx.restore();
983
+ }
984
+ /**
985
+ * Handle a chat message — record activity and generate conversation deposits.
986
+ */
987
+ export function onChatMessage(memory, conversations, username, text, robotWorldX, topics) {
988
+ // Record chat activity in the current chunk
989
+ const robotTileX = Math.floor(robotWorldX / TILE_SIZE);
990
+ const chunkX = tileXToChunkIndex(robotTileX);
991
+ recordChatActivity(memory, chunkX);
992
+ // Generate conversation deposits for recognized topics
993
+ for (const topic of topics) {
994
+ if (TOPIC_MAPPING[topic.toLowerCase()]) {
995
+ const deposit = generateConversationDeposit(topic, username, robotWorldX);
996
+ conversations.deposits.push(deposit);
997
+ }
998
+ }
999
+ }
1000
+ /**
1001
+ * Render flower decorations from ecology state.
1002
+ * Call this separately if you have access to ecology.flowerMap.
1003
+ */
1004
+ export function renderFlowers(ctx, flowerMap, cameraX, tileSize, frame) {
1005
+ ctx.save();
1006
+ for (const [key, color] of flowerMap) {
1007
+ const [xStr, yStr] = key.split(':');
1008
+ const tileX = parseInt(xStr);
1009
+ const tileY = parseInt(yStr);
1010
+ const screenX = tileX * tileSize - Math.floor(cameraX);
1011
+ const screenY = tileY * tileSize;
1012
+ if (screenX < -tileSize || screenX > 800 || screenY < -tileSize || screenY > 600)
1013
+ continue;
1014
+ // Tiny flower: 2px colored dot on top of the grass block, with gentle sway
1015
+ const sway = Math.sin(frame * 0.08 + tileX * 1.3) * 1;
1016
+ ctx.fillStyle = color;
1017
+ ctx.fillRect(screenX + 5 + Math.round(sway), screenY - 2, 2, 2);
1018
+ // Stem (1px green line)
1019
+ ctx.fillStyle = '#22c55e';
1020
+ ctx.fillRect(screenX + 6 + Math.round(sway), screenY - 1, 1, 1);
1021
+ }
1022
+ ctx.restore();
1023
+ }
1024
+ /**
1025
+ * Render fire effects (flickering orange/red on burning blocks).
1026
+ * Call this if you have access to ecology.fireSpread.
1027
+ */
1028
+ export function renderFire(ctx, fireSpread, cameraX, tileSize, frame) {
1029
+ ctx.save();
1030
+ for (const fire of fireSpread) {
1031
+ const screenX = fire.x * tileSize - Math.floor(cameraX);
1032
+ const screenY = fire.y * tileSize;
1033
+ if (screenX < -tileSize || screenX > 800 || screenY < -tileSize || screenY > 600)
1034
+ continue;
1035
+ // Flickering fire overlay
1036
+ const flicker = Math.sin(frame * 0.5 + fire.x * 2.3) * 0.3 + 0.5;
1037
+ ctx.globalAlpha = flicker * (fire.life / 20);
1038
+ // Orange-red gradient effect
1039
+ ctx.fillStyle = '#f97316';
1040
+ ctx.fillRect(screenX + 2, screenY + 2, tileSize - 4, tileSize - 4);
1041
+ // Yellow-white core
1042
+ ctx.fillStyle = '#fbbf24';
1043
+ ctx.fillRect(screenX + 4, screenY + 4, tileSize - 8, tileSize - 8);
1044
+ // Spark particles above
1045
+ if (Math.sin(frame * 0.8 + fire.x * 1.7) > 0.7) {
1046
+ ctx.fillStyle = '#ff6b6b';
1047
+ const sparkY = screenY - Math.floor(Math.abs(Math.sin(frame * 0.3 + fire.x)) * 5);
1048
+ ctx.fillRect(screenX + Math.floor(tileSize / 2), sparkY, 1, 1);
1049
+ }
1050
+ }
1051
+ ctx.globalAlpha = 1;
1052
+ ctx.restore();
1053
+ }
1054
+ //# sourceMappingURL=living-world.js.map