From 774054db56c88e14d3319b6c9067b4f3077d925c Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 11:43:00 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20skip=20unreachable=20job=20t?= =?UTF-8?q?argets=20in=20pickJob=20(Issue=20#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trees/rocks fully enclosed by impassable tiles have no passable neighbour for A* to jump from — pathfinding always returns null, causing a tight 1.5 s retry loop that fills the work log with identical entries. - Add hasAdjacentPassable() helper: checks all 8 neighbours of a tile - pickJob now skips chop/mine candidates with no passable neighbour - idleScanTimer on pathfind failure raised 1500 → 4000 ms as safety net Co-Authored-By: Claude Sonnet 4.6 --- src/systems/VillagerSystem.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index e1776be..30a7168 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -351,12 +351,17 @@ export class VillagerSystem { if (p.chop > 0) { for (const res of Object.values(state.world.resources)) { if (res.kind !== 'tree' || this.claimed.has(res.id)) continue + // Skip trees with no reachable neighbour — A* cannot enter an impassable goal + // tile unless at least one passable neighbour exists to jump from. + if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }) } } if (p.mine > 0) { for (const res of Object.values(state.world.resources)) { if (res.kind !== 'rock' || this.claimed.has(res.id)) continue + // Same reachability guard for rock tiles. + if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine }) } } @@ -397,7 +402,7 @@ export class VillagerSystem { this.claimed.delete(v.job.targetId) this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null }) } - rt.idleScanTimer = 1500 // longer delay after failed pathfind + rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops return } @@ -434,6 +439,22 @@ export class VillagerSystem { return this.nearestBuilding(v, 'bed') as any } + /** + * Returns true if at least one of the 8 neighbours of the given tile is passable. + * Used to pre-filter job targets that are fully enclosed by impassable terrain — + * such as trees deep inside a dense forest cluster where A* can never reach the goal + * tile because no passable tile is adjacent to it. + * @param tileX - Target tile X + * @param tileY - Target tile Y + */ + private hasAdjacentPassable(tileX: number, tileY: number): boolean { + const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const + for (const [dx, dy] of DIRS) { + if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true + } + return false + } + // ─── Spawning ───────────────────────────────────────────────────────────── /** -- 2.49.1 From f2a1811a364fe6ee203fd15576ffc31a35252f0b Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 11:55:24 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20resource-based=20passa?= =?UTF-8?q?bility:=20FOREST/ROCK=20walkable=20without=20a=20resource=20(Is?= =?UTF-8?q?sue=20#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously FOREST and ROCK tile types were always impassable, making 30 % of forest floor and 50 % of rocky terrain permanently blocked even with no object on them. - Remove FOREST + ROCK from IMPASSABLE in types.ts - Add RESOURCE_TERRAIN set (FOREST, ROCK) for tiles that need resource check - WorldSystem: add resourceTiles Set as O(1) spatial index - initResourceTiles() builds index from state on create() - addResourceTile() / removeResourceTile() keep it in sync at runtime - isPassable() now: impassable tiles → false | RESOURCE_TERRAIN → check index | else → true - GameScene: call addResourceTile() when SPAWN_RESOURCE fires (seedling matures) - VillagerSystem: call removeResourceTile() after chop / mine completes Side effect: trees fully enclosed by other trees are now reachable once an adjacent tree is cleared; the hasAdjacentPassable() guard in pickJob still correctly skips resources with zero passable neighbours. Co-Authored-By: Claude Sonnet 4.6 --- src/scenes/GameScene.ts | 1 + src/systems/VillagerSystem.ts | 5 ++-- src/systems/WorldSystem.ts | 54 +++++++++++++++++++++++++++++++++-- src/types.ts | 11 +++++-- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index d050670..e2cae34 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -80,6 +80,7 @@ export class GameScene extends Phaser.Scene { this.worldSystem.setTile(action.tileX, action.tileY, action.tile) } else if (action.type === 'SPAWN_RESOURCE') { this.resourceSystem.spawnResourcePublic(action.resource) + this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY) } } diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 30a7168..906b14b 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -274,10 +274,9 @@ export class VillagerSystem { const res = state.world.resources[job.targetId] if (res) { this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) - // Clear the FOREST tile so the area becomes passable for future pathfinding this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS }) - // Start recovery timer so DARK_GRASS reverts to GRASS after 5 minutes this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY }) + this.worldSystem.removeResourceTile(res.tileX, res.tileY) this.resourceSystem.removeResource(job.targetId) this.addLog(v.id, '✓ Chopped tree (+2 wood)') } @@ -285,8 +284,8 @@ export class VillagerSystem { const res = state.world.resources[job.targetId] if (res) { this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) - // Clear the ROCK tile so the area becomes passable for future pathfinding this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS }) + this.worldSystem.removeResourceTile(res.tileX, res.tileY) this.resourceSystem.removeResource(job.targetId) this.addLog(v.id, '✓ Mined rock (+2 stone)') } diff --git a/src/systems/WorldSystem.ts b/src/systems/WorldSystem.ts index 76608e5..6c33d1b 100644 --- a/src/systems/WorldSystem.ts +++ b/src/systems/WorldSystem.ts @@ -1,6 +1,6 @@ import Phaser from 'phaser' import { TILE_SIZE, WORLD_TILES } from '../config' -import { TileType, IMPASSABLE } from '../types' +import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types' import { stateManager } from '../StateManager' const BIOME_COLORS: Record = { @@ -18,6 +18,12 @@ const BIOME_COLORS: Record = { export class WorldSystem { private scene: Phaser.Scene private map!: Phaser.Tilemaps.Tilemap + /** + * Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile + * that is currently occupied by a tree or rock resource. + * Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked. + */ + private resourceTiles = new Set() private tileset!: Phaser.Tilemaps.Tileset private bgImage!: Phaser.GameObjects.Image private builtLayer!: Phaser.Tilemaps.TilemapLayer @@ -85,6 +91,8 @@ export class WorldSystem { // Camera bounds this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE) + + this.initResourceTiles() } /** Returns the built-tile tilemap layer (floor, wall, soil). */ @@ -111,6 +119,10 @@ export class WorldSystem { /** * Returns whether the tile at the given coordinates can be walked on. + * Water and wall tiles are always impassable. + * Forest and rock terrain tiles are only impassable when a resource + * (tree or rock) currently occupies them — empty forest floor and bare + * rocky ground are walkable. * Out-of-bounds tiles are treated as impassable. * @param tileX - Tile column * @param tileY - Tile row @@ -119,7 +131,45 @@ export class WorldSystem { if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false const state = stateManager.getState() const tile = state.world.tiles[tileY * WORLD_TILES + tileX] - return !IMPASSABLE.has(tile) + if (IMPASSABLE.has(tile)) return false + if (RESOURCE_TERRAIN.has(tile)) { + return !this.resourceTiles.has(tileY * WORLD_TILES + tileX) + } + return true + } + + /** + * Builds the resource tile index from the current world state. + * Called once in create() so that isPassable() has an O(1) lookup. + */ + private initResourceTiles(): void { + this.resourceTiles.clear() + const state = stateManager.getState() + for (const res of Object.values(state.world.resources)) { + this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX) + } + } + + /** + * Registers a newly placed resource so isPassable() treats the tile as blocked. + * Call this whenever a resource is added at runtime (e.g. a seedling matures). + * @param tileX - Resource tile column + * @param tileY - Resource tile row + */ + addResourceTile(tileX: number, tileY: number): void { + this.resourceTiles.add(tileY * WORLD_TILES + tileX) + } + + /** + * Removes a resource from the tile index so isPassable() treats the tile as free. + * Call this when a resource is removed at runtime (e.g. after chopping/mining). + * Not strictly required when the tile type also changes (FOREST → DARK_GRASS), + * but keeps the index clean for correctness. + * @param tileX - Resource tile column + * @param tileY - Resource tile row + */ + removeResourceTile(tileX: number, tileY: number): void { + this.resourceTiles.delete(tileY * WORLD_TILES + tileX) } /** diff --git a/src/types.ts b/src/types.ts index 83cf68c..7e37beb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,14 +12,21 @@ export enum TileType { WATERED_SOIL = 10, } +/** Tiles that are always impassable regardless of what is on them. */ export const IMPASSABLE = new Set([ TileType.DEEP_WATER, TileType.SHALLOW_WATER, - TileType.FOREST, - TileType.ROCK, TileType.WALL, ]) +/** + * Terrain tiles whose passability depends on whether a resource + * (tree or rock) is currently placed on them. + * An empty FOREST tile is walkable forest floor; a ROCK tile without a + * rock resource is just rocky ground. + */ +export const RESOURCE_TERRAIN = new Set([TileType.FOREST, TileType.ROCK]) + /** Tiles on which tree seedlings may be planted. */ export const PLANTABLE_TILES = new Set([TileType.GRASS, TileType.DARK_GRASS]) -- 2.49.1 From 732d9100abf536c3e5ab4eb92dc51f8836319024 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Mon, 23 Mar 2026 12:21:23 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix=20terrain=20canvas=20not?= =?UTF-8?q?=20updating=20after=20tile=20changes=20(Issue=20#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGE_TILE only called worldSystem.setTile() (built-tile layer only), never refreshTerrainTile() — so chopped trees stayed visually dark-green (FOREST color) even though the tile type was already DARK_GRASS. - adapter.onAction for CHANGE_TILE now also calls refreshTerrainTile() → all tile transitions (chop, mine, seedling maturation) update the canvas pixel immediately and consistently in one place - Remove now-redundant explicit refreshTerrainTile() call in TreeSeedlingSystem (the adapter handler covers it) - Tile-recovery path in GameScene (stateManager.tickTileRecovery) is NOT routed through the adapter, so its manual refreshTerrainTile() call is kept as-is Co-Authored-By: Claude Sonnet 4.6 --- src/scenes/GameScene.ts | 1 + src/systems/TreeSeedlingSystem.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index e2cae34..98e933a 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -78,6 +78,7 @@ export class GameScene extends Phaser.Scene { this.adapter.onAction = (action) => { if (action.type === 'CHANGE_TILE') { this.worldSystem.setTile(action.tileX, action.tileY, action.tile) + this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile) } else if (action.type === 'SPAWN_RESOURCE') { this.resourceSystem.spawnResourcePublic(action.resource) this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY) diff --git a/src/systems/TreeSeedlingSystem.ts b/src/systems/TreeSeedlingSystem.ts index 6d91144..5167293 100644 --- a/src/systems/TreeSeedlingSystem.ts +++ b/src/systems/TreeSeedlingSystem.ts @@ -50,7 +50,6 @@ export class TreeSeedlingSystem { this.removeSprite(id) this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id }) this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST }) - this.worldSystem.refreshTerrainTile(tileX, tileY, TileType.FOREST) const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}` this.adapter.send({ -- 2.49.1