From 34aca29082139fe0b9ca299e4f53da9389e54a69 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 18:57:50 +0000 Subject: [PATCH] fix: sleep emoji, invention history race condition, and gathering stuck state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add persistent 💤 emoji over sleeping NPCs (tracks sprite position) - Buffer history events (inventions, narration, stockpile) in SocketClient to fix race condition where data arrived before GameScene handlers registered - Fix NPCs getting stuck on 'gather' goal: tree trunks are both obstacles and resource tiles, so pathfinding always failed. Now findNearestResource returns adjacent walkable tiles for non-walkable resources, and gatheringSystem checks adjacent tiles. Added wander fallback when pathfinding to any gather target fails. Co-Authored-By: Claude Opus 4.6 --- client/src/network/SocketClient.ts | 46 ++++++++++++++++++++++++--- client/src/scenes/GameScene.ts | 37 +++++++++++++++++++++ server/src/systems/gatheringSystem.ts | 7 ++-- server/src/systems/npcBrainSystem.ts | 32 +++++++++++++++---- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/client/src/network/SocketClient.ts b/client/src/network/SocketClient.ts index f2d8e37..6c519ca 100644 --- a/client/src/network/SocketClient.ts +++ b/client/src/network/SocketClient.ts @@ -26,6 +26,12 @@ export class SocketClient { onStockpileHistory: ((data: StockpileLogEntry[]) => void) | null = null; onStockpileSummary: ((data: Record) => void) | null = null; + // Buffers for history events that may arrive before handlers are registered + private _bufferedInventionHistory: InventionSummary[] | null = null; + private _bufferedNarrationHistory: NarrationEvent[] | null = null; + private _bufferedStockpileHistory: StockpileLogEntry[] | null = null; + private _bufferedStockpileSummary: Record | null = null; + constructor(url: string) { this.socket = io(url); @@ -55,20 +61,52 @@ export class SocketClient { this.socket.on('narration-event', (data) => this.onNarrationEvent?.(data)); this.socket.on('narration-update', (data) => this.onNarrationUpdate?.(data)); - this.socket.on('narration-history', (data) => this.onNarrationHistory?.(data)); + this.socket.on('narration-history', (data) => { + if (this.onNarrationHistory) this.onNarrationHistory(data); + else this._bufferedNarrationHistory = data; + }); this.socket.on('npc-thought', (data) => this.onNpcThought?.(data)); this.socket.on('memory-event', (data) => this.onMemoryEvent?.(data)); this.socket.on('memory-history', (data) => this.onMemoryHistory?.(data)); this.socket.on('invention-event', (data) => this.onInventionEvent?.(data)); - this.socket.on('invention-history', (data) => this.onInventionHistory?.(data)); + this.socket.on('invention-history', (data) => { + if (this.onInventionHistory) this.onInventionHistory(data); + else this._bufferedInventionHistory = data; + }); this.socket.on('stockpile-event', (data) => this.onStockpileEvent?.(data)); - this.socket.on('stockpile-history', (data) => this.onStockpileHistory?.(data)); - this.socket.on('stockpile-summary', (data) => this.onStockpileSummary?.(data)); + this.socket.on('stockpile-history', (data) => { + if (this.onStockpileHistory) this.onStockpileHistory(data); + else this._bufferedStockpileHistory = data; + }); + this.socket.on('stockpile-summary', (data) => { + if (this.onStockpileSummary) this.onStockpileSummary(data); + else this._bufferedStockpileSummary = data; + }); } get playerId(): string | null { return this._playerId; } get entityId(): number | null { return this._entityId; } + /** Flush any history events that arrived before handlers were registered */ + flushBufferedHistory(): void { + if (this._bufferedNarrationHistory && this.onNarrationHistory) { + this.onNarrationHistory(this._bufferedNarrationHistory); + this._bufferedNarrationHistory = null; + } + if (this._bufferedInventionHistory && this.onInventionHistory) { + this.onInventionHistory(this._bufferedInventionHistory); + this._bufferedInventionHistory = null; + } + if (this._bufferedStockpileHistory && this.onStockpileHistory) { + this.onStockpileHistory(this._bufferedStockpileHistory); + this._bufferedStockpileHistory = null; + } + if (this._bufferedStockpileSummary && this.onStockpileSummary) { + this.onStockpileSummary(this._bufferedStockpileSummary); + this._bufferedStockpileSummary = null; + } + } + sendInput(input: PlayerInput): void { this.socket.emit('player-input', input); } diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 521c89d..27a1c68 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -49,6 +49,7 @@ export class GameScene extends Phaser.Scene { private npcThoughts: Map = new Map(); private memoryEvents: Map = new Map(); private activeThoughtEmojis: Map = new Map(); + private sleepEmojis: Map = new Map(); private currentGameTime = 0; private targetGameTime = 0; private lastPhaseKey = ''; @@ -284,6 +285,9 @@ export class GameScene extends Phaser.Scene { this.stocksPanel.updateSummary(data); }; + // Flush any history events that arrived before these handlers were registered + this.client.flushBufferedHistory(); + document.addEventListener('narration-follow', ((e: CustomEvent) => { const { entityId } = e.detail; const npcIds = this.getNpcIds(); @@ -704,6 +708,39 @@ export class GameScene extends Phaser.Scene { } } + // Update persistent sleep emojis + for (const entity of update.entities) { + const isSleeping = entity.npcBrain?.currentGoal === 'sleep'; + const es = this.entitySprites.get(entity.id); + const existingSleep = this.sleepEmojis.get(entity.id); + + if (isSleeping && es) { + if (existingSleep) { + // Update position to follow sprite + existingSleep.setPosition(es.sprite.x, es.sprite.y - 30); + existingSleep.setDepth(es.sprite.depth + 1); + } else { + // Create persistent sleep emoji + const text = this.add.text(es.sprite.x, es.sprite.y - 30, '\u{1F4A4}', { fontSize: '16px' }) + .setOrigin(0.5) + .setDepth(es.sprite.depth + 1); + this.sleepEmojis.set(entity.id, text); + } + } else if (existingSleep) { + // NPC woke up or was removed - remove sleep emoji + existingSleep.destroy(); + this.sleepEmojis.delete(entity.id); + } + } + + // Clean up sleep emojis for removed entities + for (const [id] of this.sleepEmojis) { + if (!activeIds.has(id)) { + this.sleepEmojis.get(id)!.destroy(); + this.sleepEmojis.delete(id); + } + } + // Update interaction emojis const entityPositions = new Map(); const cam = this.cameras.main; diff --git a/server/src/systems/gatheringSystem.ts b/server/src/systems/gatheringSystem.ts index ba26717..2700480 100644 --- a/server/src/systems/gatheringSystem.ts +++ b/server/src/systems/gatheringSystem.ts @@ -57,10 +57,13 @@ export function gatheringSystem(world: World, map: GameMap): void { continue; } - // Not yet gathering — check if at resource tile and idle + // Not yet gathering — check if at or adjacent to resource tile and idle if (movement.state !== 'idle') continue; - const resourceTile = map.resourceTiles.find(r => r.x === pos.x && r.y === pos.y); + const resourceTile = map.resourceTiles.find(r => r.x === pos.x && r.y === pos.y) + ?? map.resourceTiles.find(r => + Math.abs(r.x - pos.x) + Math.abs(r.y - pos.y) === 1 && !map.isWalkable(r.x, r.y) + ); if (!resourceTile) continue; // Start gathering diff --git a/server/src/systems/npcBrainSystem.ts b/server/src/systems/npcBrainSystem.ts index d54e785..906c715 100644 --- a/server/src/systems/npcBrainSystem.ts +++ b/server/src/systems/npcBrainSystem.ts @@ -16,13 +16,22 @@ import type { PickupState } from './pickupSystem.js'; function findNearestResource(map: GameMap, from: Position): Position | null { const tiles = map.resourceTiles; if (tiles.length === 0) return null; - let best = tiles[0]; - let bestDist = Math.abs(from.x - best.x) + Math.abs(from.y - best.y); - for (let i = 1; i < tiles.length; i++) { - const d = Math.abs(from.x - tiles[i].x) + Math.abs(from.y - tiles[i].y); - if (d < bestDist) { best = tiles[i]; bestDist = d; } + + // Sort by distance so we try closest first + const sorted = [...tiles].sort((a, b) => + (Math.abs(from.x - a.x) + Math.abs(from.y - a.y)) - + (Math.abs(from.x - b.x) + Math.abs(from.y - b.y)) + ); + + for (const tile of sorted) { + if (map.isWalkable(tile.x, tile.y)) { + return { x: tile.x, y: tile.y }; + } + // Resource is on a non-walkable tile (e.g. tree trunk) — find adjacent walkable tile + const adjacent = map.findNearestWalkable(tile.x, tile.y, 1); + if (adjacent) return adjacent; } - return { x: best.x, y: best.y }; + return null; } function isNighttime(gameTime: number): boolean { @@ -196,6 +205,17 @@ export function npcBrainSystem(world: World, map: GameMap, gameTime: number, eve movement.target = target; movement.path = path; movement.direction = directionTo(pos, path[0]); + } else if (goal === 'gather') { + // Can't reach any resource — fall back to wander + brain.currentGoal = 'wander'; + const wanderTarget = map.getRandomWalkable(); + const wanderPath = findPath(map, pos, wanderTarget); + if (wanderPath && wanderPath.length > 0) { + movement.state = 'walking'; + movement.target = wanderTarget; + movement.path = wanderPath; + movement.direction = directionTo(pos, wanderPath[0]); + } } } }