@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.
- package/dist/tools/audio-engine.d.ts +72 -0
- package/dist/tools/audio-engine.js +426 -0
- package/dist/tools/evolution-engine.d.ts +102 -0
- package/dist/tools/evolution-engine.js +746 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/living-world.d.ts +161 -0
- package/dist/tools/living-world.js +1054 -0
- package/dist/tools/narrative-engine.d.ts +58 -0
- package/dist/tools/narrative-engine.js +681 -0
- package/dist/tools/render-engine.js +5 -5
- package/dist/tools/rom-engine.d.ts +130 -0
- package/dist/tools/rom-engine.js +1208 -0
- package/dist/tools/social-engine.d.ts +100 -0
- package/dist/tools/social-engine.js +540 -0
- package/dist/tools/sprite-engine.js +40 -26
- package/dist/tools/stream-brain.js +4 -4
- package/dist/tools/stream-control.d.ts +2 -0
- package/dist/tools/stream-control.js +789 -0
- package/dist/tools/stream-intelligence.d.ts +6 -0
- package/dist/tools/stream-intelligence.js +239 -49
- package/dist/tools/stream-renderer.js +540 -519
- package/dist/tools/stream-self-eval.d.ts +96 -0
- package/dist/tools/stream-self-eval.js +764 -0
- package/dist/tools/tile-world.d.ts +40 -0
- package/dist/tools/tile-world.js +1070 -0
- package/package.json +1 -1
|
@@ -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
|