@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,1070 @@
1
+ // kbot Tile World Engine — Minecraft-inspired 2D tile world for the stream
2
+ //
3
+ // Procedurally generated, explorable, buildable, persistent tile world.
4
+ // Replaces flat painted backgrounds with an actual tile grid.
5
+ // Renders 16x16px blocks with 3D-style shading, animated water/lava, ore flecks.
6
+ import { registerTool } from './index.js';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ // ─── Constants ────────────────────────────────────────────────
11
+ export const TILE_SIZE = 16; // pixels per tile
12
+ export const CHUNK_WIDTH = 36; // tiles visible horizontally (576px in the 580px robot panel)
13
+ export const CHUNK_HEIGHT = 26; // tiles visible vertically
14
+ export const WORLD_HEIGHT = 40; // total height including underground (26 visible + 14 below)
15
+ const BLOCK_COLORS = {
16
+ air: { top: 'transparent', face: 'transparent', dark: 'transparent' },
17
+ grass: { top: '#4ade80', face: '#65a30d', dark: '#3f6212' },
18
+ dirt: { top: '#92400e', face: '#78350f', dark: '#451a03' },
19
+ stone: { top: '#9ca3af', face: '#6b7280', dark: '#4b5563' },
20
+ sand: { top: '#fde68a', face: '#fbbf24', dark: '#d97706' },
21
+ water: { top: '#38bdf8', face: '#0284c7', dark: '#0369a1' },
22
+ wood: { top: '#a16207', face: '#854d0e', dark: '#713f12' },
23
+ leaves: { top: '#22c55e', face: '#16a34a', dark: '#15803d' },
24
+ ore_iron: { top: '#9ca3af', face: '#78716c', dark: '#57534e' },
25
+ ore_gold: { top: '#9ca3af', face: '#6b7280', dark: '#4b5563' },
26
+ ore_diamond: { top: '#9ca3af', face: '#6b7280', dark: '#4b5563' },
27
+ lava: { top: '#f97316', face: '#ea580c', dark: '#c2410c' },
28
+ snow: { top: '#f0f9ff', face: '#e0f2fe', dark: '#bae6fd' },
29
+ ice: { top: '#a5f3fc', face: '#67e8f9', dark: '#22d3ee' },
30
+ brick: { top: '#dc2626', face: '#b91c1c', dark: '#991b1b' },
31
+ glass: { top: '#dbeafe', face: '#bfdbfe', dark: '#93c5fd' },
32
+ };
33
+ // Ore fleck colors
34
+ const ORE_FLECK_COLORS = {
35
+ ore_iron: '#a0826d',
36
+ ore_gold: '#fbbf24',
37
+ ore_diamond: '#22d3ee',
38
+ };
39
+ // Valid placeable block types (water/lava only by natural generation)
40
+ const PLACEABLE_BLOCKS = new Set([
41
+ 'grass', 'dirt', 'stone', 'sand', 'wood', 'leaves',
42
+ 'snow', 'ice', 'brick', 'glass',
43
+ ]);
44
+ // ─── Seeded PRNG ──────────────────────────────────────────────
45
+ /** Simple seeded pseudo-random number generator (mulberry32) */
46
+ function seededRandom(seed) {
47
+ let s = seed | 0;
48
+ return () => {
49
+ s = (s + 0x6D2B79F5) | 0;
50
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
51
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
52
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
53
+ };
54
+ }
55
+ // ─── Terrain Generation ───────────────────────────────────────
56
+ // Water level: water fills enclosed basins up to this Y (absolute).
57
+ // Set during chunk generation relative to surfaceLevel.
58
+ const WATER_LEVEL_OFFSET = 2; // water surface sits 2 rows below surfaceLevel
59
+ /** Hash-based deterministic value for tile variation (returns 0-2) */
60
+ function tileHash(x, y, seed) {
61
+ let h = (x * 374761393 + y * 668265263 + seed * 1274126177) | 0;
62
+ h = (h ^ (h >> 13)) * 1103515245;
63
+ h = h ^ (h >> 16);
64
+ return ((h >>> 0) % 3);
65
+ }
66
+ /**
67
+ * 1D value noise: hash-based pseudo-random values at integer points,
68
+ * smoothly interpolated between them using cosine interpolation.
69
+ */
70
+ function valueNoise1D(x, seed) {
71
+ const xi = Math.floor(x);
72
+ const frac = x - xi;
73
+ // Hash integer positions to pseudo-random values in [0, 1]
74
+ const hash = (n) => {
75
+ let h = ((n + seed * 7919) * 374761393) | 0;
76
+ h = (h ^ (h >> 13)) * 1103515245;
77
+ h = h ^ (h >> 16);
78
+ return ((h >>> 0) % 10000) / 10000;
79
+ };
80
+ const a = hash(xi);
81
+ const b = hash(xi + 1);
82
+ // Cosine interpolation for smooth curves
83
+ const t = (1 - Math.cos(frac * Math.PI)) * 0.5;
84
+ return a + (b - a) * t;
85
+ }
86
+ /**
87
+ * Multi-octave noise using layered value noise.
88
+ * 6 octaves, each half the amplitude and double the frequency.
89
+ * Returns a value in [0, 1].
90
+ */
91
+ function multiOctaveNoise(x, seed, octaves = 6) {
92
+ let value = 0;
93
+ let amplitude = 1;
94
+ let frequency = 0.02; // base frequency — gentle hills
95
+ let maxValue = 0;
96
+ for (let i = 0; i < octaves; i++) {
97
+ value += amplitude * valueNoise1D(x * frequency, seed + i * 31337);
98
+ maxValue += amplitude;
99
+ amplitude *= 0.5;
100
+ frequency *= 2;
101
+ }
102
+ return value / maxValue; // normalized to [0, 1]
103
+ }
104
+ /** Compute terrain surface height for a given world X coordinate.
105
+ * Uses multi-octave noise with power redistribution for flatter lowlands
106
+ * and sharper peaks. Surface level sits at 40% of WORLD_HEIGHT.
107
+ */
108
+ function terrainHeight(worldX, seed, _surfaceLevel) {
109
+ const baseLevel = Math.floor(WORLD_HEIGHT * 0.40); // surface at 40%
110
+ // Get noise in [0, 1], then center it to [-0.5, 0.5]
111
+ const raw = multiOctaveNoise(worldX, seed, 6) - 0.5;
112
+ // Power redistribution: preserve sign, flatten near zero, amplify extremes
113
+ const sign = raw >= 0 ? 1 : -1;
114
+ const shaped = sign * Math.pow(Math.abs(raw) * 2, 1.8) / 2;
115
+ // Scale to tile offset: gentle undulation of +/- 8 tiles from base
116
+ const maxSwing = 8;
117
+ const offset = Math.round(shaped * maxSwing * 2);
118
+ return Math.max(2, Math.min(WORLD_HEIGHT - 3, baseLevel + offset));
119
+ }
120
+ /** Check if a position should be a cave. Uses cellular-automata-style
121
+ * density from layered noise. Caves form coherent tunnels instead of
122
+ * random holes.
123
+ */
124
+ function isCave(worldX, y, seed) {
125
+ // Two noise fields at different scales
126
+ const n1 = Math.sin(worldX * 0.06 + y * 0.09 + seed * 1.3) *
127
+ Math.cos(worldX * 0.04 + y * 0.07 + seed * 0.7);
128
+ const n2 = Math.sin(worldX * 0.12 + y * 0.05 + seed * 2.1) *
129
+ Math.cos(worldX * 0.09 + y * 0.13 + seed * 3.3);
130
+ // Combine: the product of two sine waves creates natural-looking voids
131
+ const density = (n1 + n2) / 2;
132
+ // Threshold: only open caves where both noise fields agree (narrow band)
133
+ return density > 0.28 && density < 0.5;
134
+ }
135
+ /** Generate a chunk at the given chunk X index */
136
+ export function generateChunk(world, chunkX) {
137
+ const tiles = [];
138
+ // Initialize all tiles to air
139
+ for (let y = 0; y < WORLD_HEIGHT; y++) {
140
+ tiles.push(new Array(CHUNK_WIDTH).fill('air'));
141
+ }
142
+ const rng = seededRandom(world.seed * 7919 + chunkX * 104729);
143
+ // Water level: absolute Y coordinate for water surface
144
+ const waterLevel = Math.floor(WORLD_HEIGHT * 0.40) + WATER_LEVEL_OFFSET;
145
+ // Pre-compute surface heights for all columns (needed for basin detection + slope)
146
+ const surfaces = [];
147
+ for (let lx = 0; lx < CHUNK_WIDTH; lx++) {
148
+ const worldX = chunkX * CHUNK_WIDTH + lx;
149
+ const surface = terrainHeight(worldX, world.seed, world.surfaceLevel);
150
+ surfaces[lx] = Math.max(2, Math.min(WORLD_HEIGHT - 3, surface));
151
+ }
152
+ // Compute neighbor surfaces outside chunk bounds for edge-basin detection.
153
+ // Look up to 8 columns beyond each edge to find the nearest bank.
154
+ const BANK_SEARCH = 8;
155
+ let leftNeighborSurface = WORLD_HEIGHT; // default: no bank found (deep)
156
+ for (let d = 1; d <= BANK_SEARCH; d++) {
157
+ const s = Math.max(2, Math.min(WORLD_HEIGHT - 3, terrainHeight(chunkX * CHUNK_WIDTH - d, world.seed, world.surfaceLevel)));
158
+ if (s <= waterLevel) {
159
+ leftNeighborSurface = s;
160
+ break;
161
+ }
162
+ }
163
+ let rightNeighborSurface = WORLD_HEIGHT;
164
+ for (let d = 0; d < BANK_SEARCH; d++) {
165
+ const s = Math.max(2, Math.min(WORLD_HEIGHT - 3, terrainHeight((chunkX + 1) * CHUNK_WIDTH + d, world.seed, world.surfaceLevel)));
166
+ if (s <= waterLevel) {
167
+ rightNeighborSurface = s;
168
+ break;
169
+ }
170
+ }
171
+ // ── Pass 1: Fill terrain (surface + underground) ──
172
+ for (let lx = 0; lx < CHUNK_WIDTH; lx++) {
173
+ const worldX = chunkX * CHUNK_WIDTH + lx;
174
+ const clampedSurface = surfaces[lx];
175
+ // Biome tinting: use a slow noise to pick between grass/sand/snow at surface
176
+ const biomeNoise = multiOctaveNoise(worldX, world.seed + 50000, 3);
177
+ // Default to grass; sand near water level, snow at high elevations
178
+ let surfaceBlock = 'grass';
179
+ if (clampedSurface >= waterLevel - 1 && clampedSurface <= waterLevel + 1) {
180
+ surfaceBlock = 'sand'; // beaches near water level
181
+ }
182
+ else if (clampedSurface <= Math.floor(WORLD_HEIGHT * 0.15)) {
183
+ surfaceBlock = biomeNoise > 0.5 ? 'snow' : 'grass';
184
+ }
185
+ // Fill from surface down
186
+ for (let y = clampedSurface; y < WORLD_HEIGHT; y++) {
187
+ const depth = y - clampedSurface;
188
+ if (depth === 0) {
189
+ tiles[y][lx] = surfaceBlock;
190
+ }
191
+ else if (depth <= 3 + Math.floor(rng() * 2)) {
192
+ // Dirt layer (3-4 blocks); sand under sand surfaces
193
+ tiles[y][lx] = surfaceBlock === 'sand' ? 'sand' : 'dirt';
194
+ }
195
+ else {
196
+ // Stone layer — check for caves
197
+ if (isCave(worldX, y, world.seed)) {
198
+ if (y >= WORLD_HEIGHT - 4 && rng() < 0.3) {
199
+ tiles[y][lx] = 'lava';
200
+ }
201
+ else {
202
+ tiles[y][lx] = 'air';
203
+ }
204
+ }
205
+ else {
206
+ // Place stone with possible ores
207
+ const oreRoll = rng();
208
+ if (oreRoll < 0.005 && depth > 15) {
209
+ tiles[y][lx] = 'ore_diamond';
210
+ }
211
+ else if (oreRoll < 0.025 && depth > 8) {
212
+ tiles[y][lx] = 'ore_gold';
213
+ }
214
+ else if (oreRoll < 0.075) {
215
+ tiles[y][lx] = 'ore_iron';
216
+ }
217
+ else {
218
+ tiles[y][lx] = 'stone';
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ // ── Pass 2: Fill ENCLOSED basins with water (not every air gap) ──
225
+ // A basin is a contiguous run of columns where the surface dips below
226
+ // waterLevel AND is enclosed on both sides by terrain at or above waterLevel.
227
+ // We scan left-to-right, finding basins, then fill only those.
228
+ {
229
+ let lx = 0;
230
+ let waterColumns = 0;
231
+ const maxWaterColumns = Math.floor(CHUNK_WIDTH * 0.30); // max 30% water
232
+ while (lx < CHUNK_WIDTH && waterColumns < maxWaterColumns) {
233
+ // Skip columns at or above water level
234
+ if (surfaces[lx] <= waterLevel) {
235
+ lx++;
236
+ continue;
237
+ }
238
+ // Found a column below water level — scan for basin extent
239
+ const basinStart = lx;
240
+ while (lx < CHUNK_WIDTH && surfaces[lx] > waterLevel) {
241
+ lx++;
242
+ }
243
+ const basinEnd = lx; // exclusive
244
+ // Check enclosure: left bank and right bank must be at or above waterLevel
245
+ const leftBank = basinStart > 0 ? surfaces[basinStart - 1] : leftNeighborSurface;
246
+ const rightBank = basinEnd < CHUNK_WIDTH ? surfaces[basinEnd] : rightNeighborSurface;
247
+ const leftEnclosed = leftBank <= waterLevel;
248
+ const rightEnclosed = rightBank <= waterLevel;
249
+ if (leftEnclosed && rightEnclosed) {
250
+ // Enclosed basin — fill air between waterLevel and surface with water.
251
+ // In our coordinate system, higher Y = deeper. The surface Y is
252
+ // greater than waterLevel for basin columns. Fill from waterLevel
253
+ // downward to the surface.
254
+ for (let bx = basinStart; bx < basinEnd && waterColumns < maxWaterColumns; bx++) {
255
+ for (let y = waterLevel; y < surfaces[bx]; y++) {
256
+ if (tiles[y][bx] === 'air') {
257
+ tiles[y][bx] = 'water';
258
+ }
259
+ }
260
+ // Convert the surface block and adjacent blocks to sand (beach effect)
261
+ if (tiles[surfaces[bx]][bx] === 'grass' || tiles[surfaces[bx]][bx] === 'dirt') {
262
+ tiles[surfaces[bx]][bx] = 'sand';
263
+ }
264
+ waterColumns++;
265
+ }
266
+ }
267
+ }
268
+ }
269
+ // ── Pass 3: Trees — only on flat areas (slope < 2 between adjacent columns) ──
270
+ // Track last tree position to avoid clustering
271
+ let lastTreeX = -5;
272
+ for (let lx = 2; lx < CHUNK_WIDTH - 2; lx++) {
273
+ if (lx - lastTreeX < 4)
274
+ continue; // minimum spacing between trees
275
+ const clampedSurface = surfaces[lx];
276
+ // Slope check: compare with left and right neighbor heights
277
+ const slopeLeft = Math.abs(surfaces[lx] - surfaces[lx - 1]);
278
+ const slopeRight = Math.abs(surfaces[lx] - surfaces[lx + 1]);
279
+ if (slopeLeft >= 2 || slopeRight >= 2)
280
+ continue; // too steep
281
+ // Must be on grass (not sand, water, snow)
282
+ if (tiles[clampedSurface]?.[lx] !== 'grass')
283
+ continue;
284
+ // 12% base chance
285
+ if (rng() > 0.12)
286
+ continue;
287
+ if (clampedSurface < 6)
288
+ continue; // need headroom
289
+ lastTreeX = lx;
290
+ const trunkHeight = 3 + Math.floor(rng() * 3); // 3-5 blocks tall
291
+ // Place trunk
292
+ for (let th = 1; th <= trunkHeight; th++) {
293
+ const ty = clampedSurface - th;
294
+ if (ty >= 0)
295
+ tiles[ty][lx] = 'wood';
296
+ }
297
+ // Place leaves crown (3x3 centered on trunk top, plus 1 block above)
298
+ const crownY = clampedSurface - trunkHeight;
299
+ for (let dy = -1; dy <= 1; dy++) {
300
+ for (let dx = -1; dx <= 1; dx++) {
301
+ const ly = crownY + dy;
302
+ const llx = lx + dx;
303
+ if (ly >= 0 && ly < WORLD_HEIGHT && llx >= 0 && llx < CHUNK_WIDTH) {
304
+ if (tiles[ly][llx] === 'air')
305
+ tiles[ly][llx] = 'leaves';
306
+ }
307
+ }
308
+ }
309
+ // Extra leaf on top
310
+ if (crownY - 1 >= 0 && tiles[crownY - 1][lx] === 'air') {
311
+ tiles[crownY - 1][lx] = 'leaves';
312
+ }
313
+ }
314
+ return {
315
+ x: chunkX,
316
+ tiles,
317
+ generated: true,
318
+ modified: false,
319
+ };
320
+ }
321
+ // ─── World Init / Load / Save ─────────────────────────────────
322
+ const KBOT_DIR = join(homedir(), '.kbot');
323
+ const WORLD_FILE = join(KBOT_DIR, 'stream-world.json');
324
+ /** Initialize a fresh tile world */
325
+ export function initTileWorld(seed) {
326
+ const world = {
327
+ chunks: new Map(),
328
+ cameraX: 0,
329
+ surfaceLevel: Math.floor(WORLD_HEIGHT * 0.40), // 40% of world height
330
+ seed: seed ?? Math.floor(Math.random() * 999999),
331
+ timeOfDay: 'day',
332
+ weather: 'clear',
333
+ };
334
+ // Pre-generate the starting chunk (chunk 0)
335
+ const startChunk = generateChunk(world, 0);
336
+ world.chunks.set(0, startChunk);
337
+ // Also generate chunk -1 so the camera can look left
338
+ const leftChunk = generateChunk(world, -1);
339
+ world.chunks.set(-1, leftChunk);
340
+ return world;
341
+ }
342
+ /** Save world to disk (only modified chunks to keep file small) */
343
+ export function saveWorld(world) {
344
+ try {
345
+ if (!existsSync(KBOT_DIR)) {
346
+ mkdirSync(KBOT_DIR, { recursive: true });
347
+ }
348
+ const data = {
349
+ seed: world.seed,
350
+ cameraX: world.cameraX,
351
+ surfaceLevel: world.surfaceLevel,
352
+ timeOfDay: world.timeOfDay,
353
+ weather: world.weather,
354
+ chunks: Array.from(world.chunks.entries())
355
+ .filter(([_, c]) => c.modified)
356
+ .map(([x, c]) => ({ x, tiles: c.tiles })),
357
+ };
358
+ writeFileSync(WORLD_FILE, JSON.stringify(data));
359
+ }
360
+ catch {
361
+ // Silently fail — don't crash the stream
362
+ }
363
+ }
364
+ /** Load world from disk, returns null if no save exists */
365
+ export function loadWorld() {
366
+ try {
367
+ if (!existsSync(WORLD_FILE))
368
+ return null;
369
+ const raw = JSON.parse(readFileSync(WORLD_FILE, 'utf-8'));
370
+ const world = {
371
+ chunks: new Map(),
372
+ cameraX: raw.cameraX ?? 0,
373
+ surfaceLevel: raw.surfaceLevel ?? 12,
374
+ seed: raw.seed ?? 42,
375
+ timeOfDay: raw.timeOfDay ?? 'day',
376
+ weather: raw.weather ?? 'clear',
377
+ };
378
+ // Restore modified chunks
379
+ if (Array.isArray(raw.chunks)) {
380
+ for (const saved of raw.chunks) {
381
+ world.chunks.set(saved.x, {
382
+ x: saved.x,
383
+ tiles: saved.tiles,
384
+ generated: true,
385
+ modified: true,
386
+ });
387
+ }
388
+ }
389
+ return world;
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ }
395
+ // ─── Camera / Scrolling ───────────────────────────────────────
396
+ /** Update camera to follow the robot with smooth lerp */
397
+ export function updateCamera(world, robotWorldX, panelWidth = 576) {
398
+ const targetX = robotWorldX - panelWidth / 2;
399
+ world.cameraX += (targetX - world.cameraX) * 0.1;
400
+ }
401
+ // ─── Coordinate Conversion ────────────────────────────────────
402
+ /** Convert world pixel X to tile X (absolute) */
403
+ export function worldXToTile(worldPixelX) {
404
+ return Math.floor(worldPixelX / TILE_SIZE);
405
+ }
406
+ /** Convert tile X to world pixel X */
407
+ export function tileToWorldX(tileX) {
408
+ return tileX * TILE_SIZE;
409
+ }
410
+ /** Get which chunk a tile X belongs to */
411
+ function tileXToChunkIndex(tileX) {
412
+ return Math.floor(tileX / CHUNK_WIDTH);
413
+ }
414
+ /** Get local X within a chunk */
415
+ function tileXToLocalX(tileX) {
416
+ return ((tileX % CHUNK_WIDTH) + CHUNK_WIDTH) % CHUNK_WIDTH;
417
+ }
418
+ // ─── Tile Access Helpers ──────────────────────────────────────
419
+ /** Get a tile at absolute tile coordinates, generating chunk if needed */
420
+ function getTile(world, tileX, tileY) {
421
+ if (tileY < 0 || tileY >= WORLD_HEIGHT)
422
+ return 'air';
423
+ const chunkIdx = tileXToChunkIndex(tileX);
424
+ let chunk = world.chunks.get(chunkIdx);
425
+ if (!chunk) {
426
+ chunk = generateChunk(world, chunkIdx);
427
+ world.chunks.set(chunkIdx, chunk);
428
+ }
429
+ const lx = tileXToLocalX(tileX);
430
+ return chunk.tiles[tileY][lx];
431
+ }
432
+ /** Set a tile at absolute tile coordinates */
433
+ function setTile(world, tileX, tileY, block) {
434
+ if (tileY < 0 || tileY >= WORLD_HEIGHT)
435
+ return false;
436
+ const chunkIdx = tileXToChunkIndex(tileX);
437
+ let chunk = world.chunks.get(chunkIdx);
438
+ if (!chunk) {
439
+ chunk = generateChunk(world, chunkIdx);
440
+ world.chunks.set(chunkIdx, chunk);
441
+ }
442
+ const lx = tileXToLocalX(tileX);
443
+ chunk.tiles[tileY][lx] = block;
444
+ chunk.modified = true;
445
+ return true;
446
+ }
447
+ // ─── Hex color utilities ──────────────────────────────────────
448
+ function hexToRgb(hex) {
449
+ const h = hex.startsWith('#') ? hex.slice(1) : hex;
450
+ return [
451
+ parseInt(h.slice(0, 2), 16),
452
+ parseInt(h.slice(2, 4), 16),
453
+ parseInt(h.slice(4, 6), 16),
454
+ ];
455
+ }
456
+ function adjustBrightness(hex, factor) {
457
+ const [r, g, b] = hexToRgb(hex);
458
+ const clamp = (v) => Math.max(0, Math.min(255, Math.round(v * factor)));
459
+ return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`;
460
+ }
461
+ // ─── Autotile Types ──────────────────────────────────────────
462
+ /** Block types that receive autotiling (soft edges based on neighbors) */
463
+ const AUTOTILE_BLOCKS = new Set(['grass', 'sand', 'snow', 'water']);
464
+ // ─── Rendering ────────────────────────────────────────────────
465
+ /**
466
+ * Compute 4-bit autotile mask for a block.
467
+ * Bits: North=1, West=2, East=4, South=8.
468
+ * A bit is set if the neighbor in that direction is the SAME block type.
469
+ */
470
+ function autotileMask(world, tileX, tileY, blockType) {
471
+ let mask = 0;
472
+ if (getTile(world, tileX, tileY - 1) === blockType)
473
+ mask |= 1; // North
474
+ if (getTile(world, tileX - 1, tileY) === blockType)
475
+ mask |= 2; // West
476
+ if (getTile(world, tileX + 1, tileY) === blockType)
477
+ mask |= 4; // East
478
+ if (getTile(world, tileX, tileY + 1) === blockType)
479
+ mask |= 8; // South
480
+ return mask;
481
+ }
482
+ /**
483
+ * Draw tile variation details. Uses tileHash to pick 1 of 3 visual variants.
484
+ * Each variant adds minor pixel-level details (cracks, grass blades, grain).
485
+ */
486
+ function drawTileVariation(ctx, screenX, screenY, block, seed, tileWorldX, tileWorldY) {
487
+ const S = TILE_SIZE;
488
+ const variant = tileHash(tileWorldX, tileWorldY, seed);
489
+ const colors = BLOCK_COLORS[block];
490
+ switch (block) {
491
+ case 'grass': {
492
+ // Variant grass blade positions (tiny 1px darker marks)
493
+ ctx.fillStyle = colors.dark;
494
+ if (variant === 0) {
495
+ ctx.fillRect(screenX + 3, screenY + 2, 1, 2);
496
+ ctx.fillRect(screenX + 10, screenY + 1, 1, 3);
497
+ }
498
+ else if (variant === 1) {
499
+ ctx.fillRect(screenX + 6, screenY + 2, 1, 2);
500
+ ctx.fillRect(screenX + 13, screenY + 3, 1, 2);
501
+ ctx.fillRect(screenX + 1, screenY + 1, 1, 2);
502
+ }
503
+ else {
504
+ ctx.fillRect(screenX + 4, screenY + 3, 1, 2);
505
+ ctx.fillRect(screenX + 11, screenY + 2, 1, 2);
506
+ }
507
+ break;
508
+ }
509
+ case 'dirt': {
510
+ // Small rock/crack details
511
+ ctx.fillStyle = adjustBrightness(colors.face, variant === 0 ? 0.8 : variant === 1 ? 1.15 : 0.9);
512
+ if (variant === 0) {
513
+ ctx.fillRect(screenX + 4, screenY + 5, 2, 1);
514
+ ctx.fillRect(screenX + 10, screenY + 10, 2, 1);
515
+ }
516
+ else if (variant === 1) {
517
+ ctx.fillRect(screenX + 7, screenY + 3, 1, 2);
518
+ ctx.fillRect(screenX + 2, screenY + 9, 2, 2);
519
+ }
520
+ else {
521
+ ctx.fillRect(screenX + 5, screenY + 7, 3, 1);
522
+ ctx.fillRect(screenX + 12, screenY + 4, 1, 1);
523
+ }
524
+ break;
525
+ }
526
+ case 'stone': {
527
+ // Crack lines and shade variation
528
+ const shade = variant === 0 ? 0.85 : variant === 1 ? 0.92 : 1.08;
529
+ ctx.fillStyle = adjustBrightness(colors.face, shade);
530
+ if (variant === 0) {
531
+ ctx.fillRect(screenX + 2, screenY + 6, 4, 1);
532
+ ctx.fillRect(screenX + 5, screenY + 6, 1, 3);
533
+ }
534
+ else if (variant === 1) {
535
+ ctx.fillRect(screenX + 8, screenY + 4, 1, 5);
536
+ ctx.fillRect(screenX + 3, screenY + 11, 3, 1);
537
+ }
538
+ else {
539
+ ctx.fillRect(screenX + 6, screenY + 2, 3, 1);
540
+ ctx.fillRect(screenX + 10, screenY + 8, 2, 2);
541
+ }
542
+ break;
543
+ }
544
+ case 'sand': {
545
+ // Subtle grain dots
546
+ ctx.fillStyle = adjustBrightness(colors.face, variant === 0 ? 1.1 : 0.9);
547
+ const offsets = variant === 0
548
+ ? [[3, 4], [9, 7], [13, 11]]
549
+ : variant === 1
550
+ ? [[5, 3], [7, 10], [12, 5]]
551
+ : [[2, 8], [8, 3], [11, 12]];
552
+ for (const [ox, oy] of offsets) {
553
+ ctx.fillRect(screenX + ox, screenY + oy, 1, 1);
554
+ }
555
+ break;
556
+ }
557
+ case 'snow': {
558
+ // Sparkle dots
559
+ ctx.fillStyle = '#ffffff';
560
+ ctx.globalAlpha = 0.4;
561
+ const sparkles = variant === 0
562
+ ? [[4, 3], [11, 8]]
563
+ : variant === 1
564
+ ? [[7, 5], [2, 11], [13, 3]]
565
+ : [[5, 9], [10, 4]];
566
+ for (const [ox, oy] of sparkles) {
567
+ ctx.fillRect(screenX + ox, screenY + oy, 1, 1);
568
+ }
569
+ ctx.globalAlpha = 1;
570
+ break;
571
+ }
572
+ default:
573
+ break;
574
+ }
575
+ }
576
+ /**
577
+ * Draw autotile softened edges. For each edge where the neighbor is NOT
578
+ * the same type, we draw a rounded/softened transition (2px inset with the
579
+ * face color of whatever is adjacent — typically air → sky gradient fakes).
580
+ * This makes grass-to-air edges look curved rather than blocky.
581
+ */
582
+ function drawAutotileEdges(ctx, screenX, screenY, mask, colors) {
583
+ const S = TILE_SIZE;
584
+ const INSET = 2; // how many pixels to soften
585
+ // For missing neighbors, draw a subtle rounded corner effect
586
+ // by drawing the dark shade in the corner pixels
587
+ ctx.fillStyle = colors.dark;
588
+ // If no north neighbor → soften top-left and top-right corners
589
+ if (!(mask & 1)) {
590
+ ctx.fillRect(screenX, screenY, INSET, 1);
591
+ ctx.fillRect(screenX + S - INSET, screenY, INSET, 1);
592
+ ctx.fillRect(screenX, screenY + 1, 1, 1);
593
+ ctx.fillRect(screenX + S - 1, screenY + 1, 1, 1);
594
+ }
595
+ // If no south neighbor → soften bottom corners
596
+ if (!(mask & 8)) {
597
+ ctx.fillRect(screenX, screenY + S - 1, INSET, 1);
598
+ ctx.fillRect(screenX + S - INSET, screenY + S - 1, INSET, 1);
599
+ ctx.fillRect(screenX, screenY + S - 2, 1, 1);
600
+ ctx.fillRect(screenX + S - 1, screenY + S - 2, 1, 1);
601
+ }
602
+ // If no west neighbor → soften left edge
603
+ if (!(mask & 2)) {
604
+ ctx.fillRect(screenX, screenY, 1, INSET);
605
+ ctx.fillRect(screenX, screenY + S - INSET, 1, INSET);
606
+ ctx.fillRect(screenX + 1, screenY, 1, 1);
607
+ ctx.fillRect(screenX + 1, screenY + S - 1, 1, 1);
608
+ }
609
+ // If no east neighbor → soften right edge
610
+ if (!(mask & 4)) {
611
+ ctx.fillRect(screenX + S - 1, screenY, 1, INSET);
612
+ ctx.fillRect(screenX + S - 1, screenY + S - INSET, 1, INSET);
613
+ ctx.fillRect(screenX + S - 2, screenY, 1, 1);
614
+ ctx.fillRect(screenX + S - 2, screenY + S - 1, 1, 1);
615
+ }
616
+ }
617
+ /** Draw a single tile block with 3D-style shading, autotiling, and variation */
618
+ function drawBlock(ctx, screenX, screenY, block, frame, tileWorldX, tileWorldY, world) {
619
+ if (block === 'air')
620
+ return;
621
+ const colors = BLOCK_COLORS[block];
622
+ const S = TILE_SIZE;
623
+ // Special: water at 60% opacity with autotile
624
+ if (block === 'water') {
625
+ ctx.save();
626
+ ctx.globalAlpha = 0.6;
627
+ const waveOffset = Math.sin(frame * 0.15 + tileWorldX * 0.5) * 2;
628
+ // Main body
629
+ ctx.fillStyle = colors.face;
630
+ ctx.fillRect(screenX, screenY + 1, S, S - 1);
631
+ // Top row with wave
632
+ ctx.fillStyle = colors.top;
633
+ ctx.fillRect(screenX + Math.round(waveOffset), screenY, S, 1);
634
+ // Dark edge (right + bottom)
635
+ ctx.fillStyle = colors.dark;
636
+ ctx.fillRect(screenX + S - 1, screenY, 1, S);
637
+ ctx.fillRect(screenX, screenY + S - 1, S, 1);
638
+ // Autotile softened edges
639
+ const mask = autotileMask(world, tileWorldX, tileWorldY, block);
640
+ drawAutotileEdges(ctx, screenX, screenY, mask, colors);
641
+ // Tile variation: subtle wave lines
642
+ drawTileVariation(ctx, screenX, screenY, block, world.seed, tileWorldX, tileWorldY);
643
+ ctx.restore();
644
+ return;
645
+ }
646
+ // Special: glass at 30% opacity
647
+ if (block === 'glass') {
648
+ ctx.save();
649
+ ctx.globalAlpha = 0.3;
650
+ ctx.fillStyle = colors.face;
651
+ ctx.fillRect(screenX, screenY, S, S);
652
+ ctx.fillStyle = colors.top;
653
+ ctx.fillRect(screenX, screenY, S, 1);
654
+ ctx.fillStyle = colors.dark;
655
+ ctx.fillRect(screenX + S - 1, screenY, 1, S);
656
+ ctx.fillRect(screenX, screenY + S - 1, S, 1);
657
+ ctx.restore();
658
+ return;
659
+ }
660
+ // Special: lava with brightness pulse
661
+ if (block === 'lava') {
662
+ const pulse = 0.85 + Math.sin(frame * 0.3 + tileWorldX * 0.2) * 0.15;
663
+ ctx.fillStyle = adjustBrightness(colors.face, pulse);
664
+ ctx.fillRect(screenX, screenY, S, S);
665
+ ctx.fillStyle = adjustBrightness(colors.top, pulse * 1.1);
666
+ ctx.fillRect(screenX, screenY, S, 1);
667
+ ctx.fillStyle = adjustBrightness(colors.dark, pulse * 0.9);
668
+ ctx.fillRect(screenX + S - 1, screenY, 1, S);
669
+ ctx.fillRect(screenX, screenY + S - 1, S, 1);
670
+ // Occasional bubble
671
+ if (Math.sin(frame * 0.7 + tileWorldX * 3.1) > 0.9) {
672
+ const bubbleY = screenY - 1 - Math.floor(Math.abs(Math.sin(frame * 0.5 + tileWorldX)) * 3);
673
+ ctx.fillStyle = '#ff9f43';
674
+ ctx.fillRect(screenX + Math.floor(S / 2), bubbleY, 1, 1);
675
+ }
676
+ return;
677
+ }
678
+ // Special: leaves with slight sway
679
+ if (block === 'leaves') {
680
+ const sway = Math.sin(frame * 0.1 + tileWorldX * 0.8) * 0.5;
681
+ const sx = screenX + Math.round(sway);
682
+ ctx.fillStyle = colors.face;
683
+ ctx.fillRect(sx, screenY, S, S);
684
+ ctx.fillStyle = colors.top;
685
+ ctx.fillRect(sx, screenY, S, 1);
686
+ ctx.fillStyle = colors.dark;
687
+ ctx.fillRect(sx + S - 1, screenY, 1, S);
688
+ ctx.fillRect(sx, screenY + S - 1, S, 1);
689
+ return;
690
+ }
691
+ // ── Default block rendering with autotile + variation ──
692
+ // Main face
693
+ ctx.fillStyle = colors.face;
694
+ ctx.fillRect(screenX, screenY, S, S);
695
+ // Top highlight row
696
+ ctx.fillStyle = colors.top;
697
+ ctx.fillRect(screenX, screenY, S, 1);
698
+ // Right shadow edge
699
+ ctx.fillStyle = colors.dark;
700
+ ctx.fillRect(screenX + S - 1, screenY, 1, S);
701
+ // Bottom shadow edge
702
+ ctx.fillRect(screenX, screenY + S - 1, S, 1);
703
+ // Autotile: soften edges for grass, sand, snow
704
+ if (AUTOTILE_BLOCKS.has(block)) {
705
+ const mask = autotileMask(world, tileWorldX, tileWorldY, block);
706
+ drawAutotileEdges(ctx, screenX, screenY, mask, colors);
707
+ }
708
+ // Tile variation: minor pixel details per block type
709
+ drawTileVariation(ctx, screenX, screenY, block, world.seed, tileWorldX, tileWorldY);
710
+ // Ore fleck rendering
711
+ if (block === 'ore_iron' || block === 'ore_gold' || block === 'ore_diamond') {
712
+ const fleckColor = ORE_FLECK_COLORS[block];
713
+ ctx.fillStyle = fleckColor;
714
+ const fleckSeed = tileWorldX * 31 + tileWorldY * 17;
715
+ const numFlecks = 2 + (fleckSeed % 2);
716
+ for (let i = 0; i < numFlecks; i++) {
717
+ const fx = 3 + ((fleckSeed * (i + 1) * 7) % (S - 6));
718
+ const fy = 3 + ((fleckSeed * (i + 1) * 13) % (S - 6));
719
+ ctx.fillRect(screenX + fx, screenY + fy, 2, 2);
720
+ }
721
+ }
722
+ }
723
+ /** Draw the sky gradient behind the tile world */
724
+ function drawSky(ctx, panelX, panelY, panelWidth, panelHeight, timeOfDay) {
725
+ const grad = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
726
+ switch (timeOfDay) {
727
+ case 'night':
728
+ grad.addColorStop(0, '#0a0e1a');
729
+ grad.addColorStop(1, '#1a1f3a');
730
+ break;
731
+ case 'sunset':
732
+ grad.addColorStop(0, '#1a1040');
733
+ grad.addColorStop(0.4, '#4a2060');
734
+ grad.addColorStop(0.7, '#c05030');
735
+ grad.addColorStop(1, '#e08040');
736
+ break;
737
+ case 'dawn':
738
+ grad.addColorStop(0, '#1a1a3a');
739
+ grad.addColorStop(0.5, '#4a3060');
740
+ grad.addColorStop(0.8, '#c07050');
741
+ grad.addColorStop(1, '#e0a060');
742
+ break;
743
+ default: // day
744
+ grad.addColorStop(0, '#1a3a5a');
745
+ grad.addColorStop(0.5, '#2a5a8a');
746
+ grad.addColorStop(1, '#4a8aba');
747
+ break;
748
+ }
749
+ ctx.fillStyle = grad;
750
+ ctx.fillRect(panelX, panelY, panelWidth, panelHeight);
751
+ }
752
+ /** Render the visible tile world */
753
+ export function renderTileWorld(ctx, world, panelX, panelY, panelWidth, panelHeight, robotWorldX, frame) {
754
+ // Draw sky background first
755
+ drawSky(ctx, panelX, panelY, panelWidth, panelHeight, world.timeOfDay);
756
+ // Calculate visible tile range from camera position
757
+ const startTileX = Math.floor(world.cameraX / TILE_SIZE);
758
+ const tilesVisibleX = Math.ceil(panelWidth / TILE_SIZE) + 1;
759
+ const tilesVisibleY = Math.ceil(panelHeight / TILE_SIZE) + 1;
760
+ // The Y offset determines which row of the world appears at the top of the panel.
761
+ // We want the surface (around surfaceLevel) to appear roughly in the upper third.
762
+ const viewStartY = Math.max(0, world.surfaceLevel - Math.floor(tilesVisibleY * 0.35));
763
+ // Sub-tile pixel offset for smooth scrolling
764
+ const pixelOffsetX = Math.floor(world.cameraX) % TILE_SIZE;
765
+ // Ensure chunks exist for all visible columns
766
+ for (let dx = -1; dx <= Math.ceil(tilesVisibleX / CHUNK_WIDTH) + 1; dx++) {
767
+ const chunkIdx = tileXToChunkIndex(startTileX) + dx;
768
+ if (!world.chunks.has(chunkIdx)) {
769
+ world.chunks.set(chunkIdx, generateChunk(world, chunkIdx));
770
+ }
771
+ }
772
+ // Render visible tiles
773
+ for (let screenTileY = 0; screenTileY < tilesVisibleY; screenTileY++) {
774
+ const tileY = viewStartY + screenTileY;
775
+ if (tileY < 0 || tileY >= WORLD_HEIGHT)
776
+ continue;
777
+ for (let screenTileX = 0; screenTileX <= tilesVisibleX; screenTileX++) {
778
+ const tileX = startTileX + screenTileX;
779
+ const block = getTile(world, tileX, tileY);
780
+ if (block === 'air')
781
+ continue;
782
+ const screenX = panelX + screenTileX * TILE_SIZE - pixelOffsetX;
783
+ const screenY = panelY + screenTileY * TILE_SIZE;
784
+ // Clip to panel bounds
785
+ if (screenX + TILE_SIZE < panelX || screenX > panelX + panelWidth)
786
+ continue;
787
+ if (screenY + TILE_SIZE < panelY || screenY > panelY + panelHeight)
788
+ continue;
789
+ drawBlock(ctx, screenX, screenY, block, frame, tileX, tileY, world);
790
+ }
791
+ }
792
+ // Draw some stars in the sky if night
793
+ if (world.timeOfDay === 'night') {
794
+ ctx.fillStyle = '#ffffff';
795
+ const starSeed = world.seed * 13;
796
+ for (let i = 0; i < 30; i++) {
797
+ const sx = panelX + ((starSeed * (i + 1) * 37) % panelWidth);
798
+ const sy = panelY + ((starSeed * (i + 1) * 53) % Math.floor(panelHeight * 0.3));
799
+ const twinkle = Math.sin(frame * 0.2 + i) > 0.3 ? 1 : 0.3;
800
+ ctx.globalAlpha = twinkle;
801
+ ctx.fillRect(sx, sy, 1, 1);
802
+ }
803
+ ctx.globalAlpha = 1;
804
+ }
805
+ }
806
+ // ─── Pre-built Structures ─────────────────────────────────────
807
+ /** Build a house (5 wide, 4 tall brick box with door) */
808
+ function buildHouse(world, baseTileX, baseTileY) {
809
+ // Walls
810
+ for (let dy = 0; dy < 4; dy++) {
811
+ for (let dx = 0; dx < 5; dx++) {
812
+ const isWall = dy === 0 || dy === 3 || dx === 0 || dx === 4;
813
+ const isDoor = dx === 2 && (dy === 2 || dy === 3);
814
+ const isWindow = (dx === 1 || dx === 3) && dy === 1;
815
+ if (isDoor) {
816
+ setTile(world, baseTileX + dx, baseTileY - dy, 'air');
817
+ }
818
+ else if (isWindow) {
819
+ setTile(world, baseTileX + dx, baseTileY - dy, 'glass');
820
+ }
821
+ else if (isWall) {
822
+ setTile(world, baseTileX + dx, baseTileY - dy, 'brick');
823
+ }
824
+ else {
825
+ setTile(world, baseTileX + dx, baseTileY - dy, 'air');
826
+ }
827
+ }
828
+ }
829
+ // Roof
830
+ for (let dx = -1; dx <= 5; dx++) {
831
+ setTile(world, baseTileX + dx, baseTileY - 4, 'wood');
832
+ }
833
+ return 'Built a house!';
834
+ }
835
+ /** Build a tower (3 wide, 8 tall brick column) */
836
+ function buildTower(world, baseTileX, baseTileY) {
837
+ for (let dy = 0; dy < 8; dy++) {
838
+ for (let dx = 0; dx < 3; dx++) {
839
+ const isWall = dx === 0 || dx === 2;
840
+ const isTop = dy === 7;
841
+ const isDoor = dx === 1 && dy <= 1;
842
+ if (isDoor) {
843
+ setTile(world, baseTileX + dx, baseTileY - dy, 'air');
844
+ }
845
+ else if (isWall || isTop) {
846
+ setTile(world, baseTileX + dx, baseTileY - dy, 'brick');
847
+ }
848
+ else {
849
+ setTile(world, baseTileX + dx, baseTileY - dy, 'air');
850
+ }
851
+ }
852
+ }
853
+ // Parapet
854
+ setTile(world, baseTileX - 1, baseTileY - 8, 'stone');
855
+ setTile(world, baseTileX + 3, baseTileY - 8, 'stone');
856
+ return 'Built a tower!';
857
+ }
858
+ /** Build a tree */
859
+ function buildTree(world, baseTileX, baseTileY) {
860
+ // Trunk (4 high)
861
+ for (let dy = 1; dy <= 4; dy++) {
862
+ setTile(world, baseTileX, baseTileY - dy, 'wood');
863
+ }
864
+ // Leaves crown (3x3 at top + 1 above)
865
+ for (let dx = -1; dx <= 1; dx++) {
866
+ for (let dy = 4; dy <= 6; dy++) {
867
+ setTile(world, baseTileX + dx, baseTileY - dy, 'leaves');
868
+ }
869
+ }
870
+ setTile(world, baseTileX, baseTileY - 7, 'leaves');
871
+ return 'Planted a tree!';
872
+ }
873
+ /** Build a bridge (wood planks spanning a gap, 8 tiles wide) */
874
+ function buildBridge(world, baseTileX, baseTileY) {
875
+ for (let dx = 0; dx < 8; dx++) {
876
+ setTile(world, baseTileX + dx, baseTileY, 'wood');
877
+ // Railing
878
+ setTile(world, baseTileX + dx, baseTileY - 1, 'air');
879
+ if (dx === 0 || dx === 7) {
880
+ setTile(world, baseTileX + dx, baseTileY - 1, 'wood');
881
+ setTile(world, baseTileX + dx, baseTileY - 2, 'wood');
882
+ }
883
+ }
884
+ return 'Built a bridge!';
885
+ }
886
+ // ─── Chat Commands ────────────────────────────────────────────
887
+ /** Parse and handle tile world chat commands. Returns response string or null if not a tile command. */
888
+ export function handleTileCommand(text, username, world, robotWorldPixelX) {
889
+ const t = text.trim().toLowerCase();
890
+ const robotTileX = worldXToTile(robotWorldPixelX);
891
+ // Robot stands on the surface — find ground level at robot position
892
+ const robotTileY = findSurfaceY(world, robotTileX);
893
+ // !place <block> <x> <y>
894
+ const placeMatch = t.match(/^!place\s+(\w+)\s+(-?\d+)\s+(-?\d+)$/);
895
+ if (placeMatch) {
896
+ const blockName = placeMatch[1];
897
+ const rx = parseInt(placeMatch[2]);
898
+ const ry = parseInt(placeMatch[3]);
899
+ if (!PLACEABLE_BLOCKS.has(blockName)) {
900
+ return `Can't place "${blockName}". Use: ${[...PLACEABLE_BLOCKS].join(', ')}`;
901
+ }
902
+ if (Math.abs(rx) > 5 || Math.abs(ry) > 5) {
903
+ return 'Too far! Max distance is 5 tiles from the robot.';
904
+ }
905
+ const targetX = robotTileX + rx;
906
+ const targetY = robotTileY - ry; // negative Y = up in world coords
907
+ // Don't place in robot position
908
+ if (rx === 0 && ry === 0) {
909
+ return "Can't place a block on the robot!";
910
+ }
911
+ setTile(world, targetX, targetY, blockName);
912
+ return `${username} placed ${blockName} at (${rx},${ry})`;
913
+ }
914
+ // !break <x> <y>
915
+ const breakMatch = t.match(/^!break\s+(-?\d+)\s+(-?\d+)$/);
916
+ if (breakMatch) {
917
+ const rx = parseInt(breakMatch[1]);
918
+ const ry = parseInt(breakMatch[2]);
919
+ if (Math.abs(rx) > 5 || Math.abs(ry) > 5) {
920
+ return 'Too far! Max distance is 5 tiles.';
921
+ }
922
+ const targetX = robotTileX + rx;
923
+ const targetY = robotTileY - ry;
924
+ const existing = getTile(world, targetX, targetY);
925
+ if (existing === 'air') {
926
+ return 'Nothing to break there!';
927
+ }
928
+ setTile(world, targetX, targetY, 'air');
929
+ return `${username} broke ${existing} at (${rx},${ry})`;
930
+ }
931
+ // !dig
932
+ if (t === '!dig') {
933
+ const below = getTile(world, robotTileX, robotTileY + 1);
934
+ if (below === 'air' || below === 'water' || below === 'lava') {
935
+ return "Nothing solid to dig!";
936
+ }
937
+ setTile(world, robotTileX, robotTileY + 1, 'air');
938
+ return `${username} dug through ${below}!`;
939
+ }
940
+ // !build <structure>
941
+ const buildMatch = t.match(/^!build\s+(\w+)$/);
942
+ if (buildMatch) {
943
+ const structure = buildMatch[1];
944
+ // Place structures relative to robot, on the surface
945
+ const buildX = robotTileX + 2; // offset slightly to the right
946
+ const buildY = robotTileY;
947
+ switch (structure) {
948
+ case 'house': return buildHouse(world, buildX, buildY);
949
+ case 'tower': return buildTower(world, buildX, buildY);
950
+ case 'tree': return buildTree(world, buildX, buildY);
951
+ case 'bridge': return buildBridge(world, buildX, buildY);
952
+ default:
953
+ return `Unknown structure "${structure}". Available: house, tower, tree, bridge`;
954
+ }
955
+ }
956
+ // !fill <block> <x1> <y1> <x2> <y2>
957
+ const fillMatch = t.match(/^!fill\s+(\w+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$/);
958
+ if (fillMatch) {
959
+ const blockName = fillMatch[1];
960
+ const x1 = parseInt(fillMatch[2]);
961
+ const y1 = parseInt(fillMatch[3]);
962
+ const x2 = parseInt(fillMatch[4]);
963
+ const y2 = parseInt(fillMatch[5]);
964
+ if (!PLACEABLE_BLOCKS.has(blockName)) {
965
+ return `Can't fill with "${blockName}". Use: ${[...PLACEABLE_BLOCKS].join(', ')}`;
966
+ }
967
+ const minX = Math.min(x1, x2);
968
+ const maxX = Math.max(x1, x2);
969
+ const minY = Math.min(y1, y2);
970
+ const maxY = Math.max(y1, y2);
971
+ if (maxX - minX > 9 || maxY - minY > 9) {
972
+ return 'Max fill area is 10x10!';
973
+ }
974
+ if (Math.abs(maxX) > 10 || Math.abs(maxY) > 10 || Math.abs(minX) > 10 || Math.abs(minY) > 10) {
975
+ return 'Too far from robot! Keep coordinates within 10.';
976
+ }
977
+ let count = 0;
978
+ for (let ry = minY; ry <= maxY; ry++) {
979
+ for (let rx = minX; rx <= maxX; rx++) {
980
+ const tx = robotTileX + rx;
981
+ const ty = robotTileY - ry;
982
+ setTile(world, tx, ty, blockName);
983
+ count++;
984
+ }
985
+ }
986
+ return `${username} filled ${count} blocks with ${blockName}!`;
987
+ }
988
+ // !biome
989
+ if (t === '!biome') {
990
+ const surface = findSurfaceY(world, robotTileX);
991
+ const depth = WORLD_HEIGHT - surface;
992
+ let biomeDesc = 'Overworld (grass terrain)';
993
+ if (surface > world.surfaceLevel + 4)
994
+ biomeDesc = 'Valley (low terrain, possible lake)';
995
+ if (surface < world.surfaceLevel - 4)
996
+ biomeDesc = 'Hills (elevated terrain)';
997
+ return `Biome: ${biomeDesc} | Surface: y=${surface} | Depth to bedrock: ${depth} tiles | Seed: ${world.seed}`;
998
+ }
999
+ // !seed
1000
+ if (t === '!seed') {
1001
+ return `World seed: ${world.seed}`;
1002
+ }
1003
+ // !save
1004
+ if (t === '!save') {
1005
+ saveWorld(world);
1006
+ const modifiedCount = Array.from(world.chunks.values()).filter(c => c.modified).length;
1007
+ return `World saved! (${modifiedCount} modified chunks, ${world.chunks.size} total loaded)`;
1008
+ }
1009
+ return null;
1010
+ }
1011
+ /** Find the surface Y (first non-air tile from top) at a given tile X */
1012
+ function findSurfaceY(world, tileX) {
1013
+ for (let y = 0; y < WORLD_HEIGHT; y++) {
1014
+ const block = getTile(world, tileX, y);
1015
+ if (block !== 'air' && block !== 'leaves' && block !== 'water') {
1016
+ return y;
1017
+ }
1018
+ }
1019
+ return world.surfaceLevel;
1020
+ }
1021
+ // ─── Tool Registration ────────────────────────────────────────
1022
+ export function registerTileWorldTools() {
1023
+ registerTool({
1024
+ name: 'tile_world_info',
1025
+ description: 'Show current tile world state — seed, camera position, loaded chunks, modified chunks',
1026
+ parameters: {},
1027
+ tier: 'free',
1028
+ execute: async () => {
1029
+ // Try to load world from disk to report on it
1030
+ const world = loadWorld();
1031
+ if (!world) {
1032
+ return 'No tile world exists yet. Start the stream to generate one, or call tile_world_reset.';
1033
+ }
1034
+ const totalChunks = world.chunks.size;
1035
+ const modifiedChunks = Array.from(world.chunks.values()).filter(c => c.modified).length;
1036
+ return [
1037
+ `Tile World Info`,
1038
+ ` Seed: ${world.seed}`,
1039
+ ` Camera X: ${Math.round(world.cameraX)}px`,
1040
+ ` Surface level: ${world.surfaceLevel}`,
1041
+ ` Time of day: ${world.timeOfDay}`,
1042
+ ` Weather: ${world.weather}`,
1043
+ ` Loaded chunks: ${totalChunks}`,
1044
+ ` Modified chunks: ${modifiedChunks}`,
1045
+ ` Tile size: ${TILE_SIZE}px`,
1046
+ ` Chunk size: ${CHUNK_WIDTH}x${WORLD_HEIGHT} tiles`,
1047
+ ` World file: ${WORLD_FILE}`,
1048
+ ` File exists: ${existsSync(WORLD_FILE)}`,
1049
+ ].join('\n');
1050
+ },
1051
+ });
1052
+ registerTool({
1053
+ name: 'tile_world_reset',
1054
+ description: 'Generate a fresh tile world with a new seed. Destroys the current saved world.',
1055
+ parameters: {
1056
+ seed: {
1057
+ type: 'number',
1058
+ description: 'Optional seed for the new world. Random if not provided.',
1059
+ },
1060
+ },
1061
+ tier: 'free',
1062
+ execute: async (params) => {
1063
+ const seed = typeof params.seed === 'number' ? params.seed : undefined;
1064
+ const world = initTileWorld(seed);
1065
+ saveWorld(world);
1066
+ return `Fresh tile world generated!\n Seed: ${world.seed}\n Surface level: ${world.surfaceLevel}\n Pre-generated chunks: ${world.chunks.size}\n Saved to: ${WORLD_FILE}`;
1067
+ },
1068
+ });
1069
+ }
1070
+ //# sourceMappingURL=tile-world.js.map