diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index fee9323..8a1f620 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -25,3 +25,9 @@ signal pawn_status_removed(pawn, status) ## Emitted by Pawn.remove_status_ # Phase 12 — Seasons + Weather. signal season_changed(season: StringName) ## Emitted by Clock when current_season() rolls over (Spring → Summer → Autumn → Winter). signal weather_changed(weather: StringName) ## Emitted by Weather autoload when the daily roll resolves to a new weather kind. + +# Phase 13 — Rooms + Roofing + Beauty + Dirtiness + Cleaning. +signal room_changed(room_id: int) ## Emitted when a room is created, destroyed, or recomputed (id may be invalid post-destroy). +signal room_too_large(top_left: Vector2i, cell_count: int) ## Emitted when BFS hits ROOM_MAX_CELLS — surfaces the "split with interior wall" banner. +signal tile_beauty_changed(tile: Vector2i, beauty: float) ## Emitted when beauty recomputes for a tile (Phase 13 beauty system). +signal tile_dirtiness_changed(tile: Vector2i, dirt: float) ## Emitted when dirtiness crosses a tier threshold (clean/dirty/filthy). diff --git a/autoload/world.gd b/autoload/world.gd index b2cf385..f981675 100644 --- a/autoload/world.gd +++ b/autoload/world.gd @@ -262,6 +262,9 @@ func mark_wall_tile(tile: Vector2i, material: StringName) -> void: # Real material→atlas mapping lands when assets are imported. var atlas := Vector2i(2, 0) if material == &"stone" else Vector2i(3, 0) wall_layer.set_cell(tile, 0, atlas) + # Phase 13 — trigger room recompute around the changed tile. + if room_detector != null: + room_detector.recompute_around(tile) func mark_floor_tile(tile: Vector2i, material: StringName) -> void: @@ -269,6 +272,33 @@ func mark_floor_tile(tile: Vector2i, material: StringName) -> void: return var atlas := Vector2i(1, 0) if material == &"dirt" else Vector2i(2, 0) floor_layer.set_cell(tile, 0, atlas) + # Phase 13 — trigger room recompute around the changed tile. + if room_detector != null: + room_detector.recompute_around(tile) + + +## Phase 13 — Called by Door._complete() to notify RoomDetector that a door +## has been placed (doors are interior boundary tiles, not walls). +func mark_door_tile(tile: Vector2i) -> void: + if room_detector != null: + room_detector.recompute_around(tile) + + +## Phase 13 — Toggle a tile's no-roof designation. Tiles in no_roof_cells are +## treated as open-sky by RoomDetector's BFS, so rooms containing them will be +## detected but NOT auto-roofed (courtyard behaviour). +## This is also the test-helper entry point the spec asks for: +## World.toggle_no_roof_at(tile) +func toggle_no_roof_at(tile: Vector2i) -> void: + if no_roof_cells.has(tile): + no_roof_cells.erase(tile) + Audit.log("room", "no-roof cleared at %s" % tile) + else: + no_roof_cells[tile] = true + Audit.log("room", "no-roof designated at %s" % tile) + # Recompute the room that may have contained this tile. + if room_detector != null: + room_detector.recompute_around(tile) # Returns the first StockpileZone OR Crate covering `tile`, or null. @@ -279,3 +309,52 @@ func stockpile_at_tile(tile: Vector2i): if sp.covers_tile(tile): return sp return null + + +# ── Phase 13 — Room registry + lookup ──────────────────────────────────────── +# RoomDetector (scenes/world/room_detector.gd) populates this dict. +# Keys = stable room ids (int), values = Room class instances. +# Callers should treat this as read-only. + +var rooms: Dictionary = {} + +# Reference to the RoomDetector child of the World scene node. +# Set by World._ready() so mark_wall_tile / mark_floor_tile / mark_door_tile +# can trigger recompute_around() without a get_node() call. +var room_detector = null + +# Persistent set of tiles the player has designated as no-roof (courtyards). +# Keys are Vector2i, value is unused boolean. Written by toggle_no_roof_at(). +var no_roof_cells: Dictionary = {} + + +## Returns the Room covering `tile`, or null if the tile is outdoor / unenclosed. +## O(rooms) bounds-checked sweep — cheap at MVP scale (< 20 rooms). +func room_at_tile(tile: Vector2i): + for id in rooms: + var r = rooms[id] + if r.contains_tile(tile): + return r + return null + + +## True if the tile is inside an enclosed, roofed room. Replaces Phase 12's +## "has floor below" shelter proxy — Pawn._is_sheltered() will reroute here once +## RoomDetector is live. +func is_indoor(tile: Vector2i) -> bool: + var r = room_at_tile(tile) + return r != null and r.is_under_roof + + +# ── Phase 13 — Beauty + Dirtiness systems ──────────────────────────────────── +# Set by World scene's _ready() after adding BeautySystem / DirtinessSystem +# as child nodes. All callers use World.get("beauty_system") defensively. + +## BeautySystem child of the World scene. +## Exposes: beauty_at(tile), register_furniture(entity), recompute_around(tile). +var beauty_system = null + +## DirtinessSystem child of the World scene. +## Exposes: dirt_at(tile), bump(tile, amount), bump_clean(tile, amount), +## bump_pawn_traffic(tile, indoor). +var dirtiness_system = null diff --git a/docs/implementation.md b/docs/implementation.md index f530edf..1f33ab4 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -19,7 +19,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for | ✅ done — HP + Status registry (Bleeding/Downed), pawn `take_damage`/`heal`/downed visual, DoctorProvider (priority 9, highest), medical bed (red cross marker), Rescue + Treat toils, EventBus damage/status signals, Decision Layer-1 incapacitation interrupt | **Phase 9 — Status effects + Medicine** | | ✅ done — Wolf entity (4-state APPROACH/ENGAGE/FLEE/DEAD, procedural canine sprite with red eyes), WolfSpawner (1–2 wolves at random map edge, triggers at darkness≥0.8 with daily cooldown), two-roll combat (70% hit + 50% bleed chance on hit), World.wolves registry | **Phase 10 — Combat + Wolves** | | ✅ done — 48-day year (4 seasons × 12 days), Clock season API + season_changed signal, Weather autoload with season-weighted daily roll (clear/rain/storm/cold_snap), procedural rain overlay + storm white-flash, terrain seasonal palette modulate, top-bar season indicator ("Spring 1/12"), Wet status (Damp/Soaked) + Cold status with mood thoughts, _is_sheltered() floor-proxy (Phase 13 replaces with Room BFS) | **Phase 12 — Seasons + Weather** | -| ⏳ next | **Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning** | +| ✅ done — Room data class + RoomDetector (BFS, 4-dir, door-as-boundary), 16-cell auto-roof cap with `room_too_large` banner signal, World.room_at_tile()/is_indoor() lookups, IndoorTintOverlay (subtle warm draw_rect at α=0.10), Pawn._is_sheltered() rerouted from floor-proxy to Room API (Phase 12 debt paid), BeautySystem with linear falloff × Quality multiplier, DirtinessSystem (traffic + tier thresholds), CleaningProvider (priority 2) + KIND_CLEAN toil, 7 room/dirt/beauty mood thoughts in catalog, plants-don't-grow-indoors guard, No-Roof paint tool stubbed | **Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning** | +| ⏳ next | **Phase 14 — Death, corpses, burial** | Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it. @@ -296,27 +297,24 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod --- -## Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning (~2–3 weeks) +## Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning (~2–3 weeks) — ✅ done 2026-05-11 **Goal:** built-environment systems — your cabin matters now. -- [ ] **EnclosureDetector** + **RoomDetector** (per `architecture.md:967` and 982) -- [ ] **Auto-roof BFS** (≤8 cells, per `architecture.md:983`) — sets Layer-4 Roof flag -- [ ] **No-Roof designation** (paint mode) — courtyards stay open -- [ ] **`room_too_large` signal** when BFS hits the cap on an enclosed area (locked decision from this session) -- [ ] **DECIDE: big-room UX** (open question in `memory.md`): - - (a) Keep ≤8 cap, surface "split with an interior wall" banner — minimal scope - - (b) Bump cap to ~16, banner at the new threshold - - (c) Detect any enclosed area regardless of size — bigger architectural shift - - Recommendation lands here; deferring past Phase 13 means bugs. -- [ ] **Indoor tint** driven by Roof flag — wires to the shader skeleton from Phase 1 -- [ ] Plants-don't-grow-indoors rule wires up properly (was stubbed in Phase 7) -- [ ] **Beauty score** per cell, derived from nearby furniture × Quality multiplier -- [ ] **Dirtiness** accumulation, traffic-weighted, spike events (blood from combat = +20, corpse decay = +5/h) -- [ ] **Cleaning WorkProvider** (the 9-list category — earlier doc text said "8th"; that's stale) -- [ ] Room thoughts: clean/dirty, beautiful/ugly, ate-without-table, slept-in-room -- [ ] **Spike (~1 hr):** room detection on a stress map (50+ rooms). Does it stutter on rebuild? -- [ ] **Acceptance:** Build a kitchen → mood reflects it (table, beauty). Bloody combat in bedroom → room turns ugly until cleaned. Build a 12-cell enclosed room → big-room banner fires (per (a)/(b)/(c) decision). +- [x] **RoomDetector** at `scenes/world/room_detector.gd` (Agent A) — 4-dir BFS from floor/door tiles, walls/empty-terrain as boundary, doors counted as room interior. EnclosureDetector folded into the same module rather than split out; the BFS-cap-exceeded path serves the "no enclosure" role. +- [x] **Auto-roof BFS** — `Room.ROOM_AUTOROOF_CAP = 16`. Discovery BFS allows up to 4× that for the too-large warning. Roof flag stored on the Room instance as `is_under_roof`. +- [x] **No-Roof designation** — `Designation.TOOL_NO_ROOF` paint tool wired into the dispatch + atlas + ghost system. UI button activation deferred to Phase 17 build-drawer. +- [x] **`room_too_large` signal** — `EventBus.room_too_large(top_left: Vector2i, cell_count: int)`. UI banner consumer deferred to Phase 17. +- [x] **DECISION: big-room UX** — Option (b) chosen 2026-05-11: bump cap to 16, banner above. Cabin's 24-tile interior intentionally exceeds cap so the warning path is exercised at boot. +- [x] **Indoor tint** — `scenes/world/indoor_tint_overlay.gd` Node2D, listens to `room_changed`, `_draw()` fills roofed-room tiles with `Color(1.0, 0.95, 0.85, 0.10)`. Deliberately subtle. +- [x] Plants-don't-grow-indoors — `Crop._on_sim_tick` skips stage advancement when `World.is_indoor(tile)`; one Audit line per crop on first detection. +- [x] **Beauty score** — `BeautySystem`, sparse `beauty_map: Dictionary[Vector2i, float]`. Linear falloff over 3 tiles. Base values: Bed +2, Workbench +1, Torch +3, Hearth +4. Quality multiplier: SHODDY 0.5, NORMAL 1.0, EXCELLENT 1.5, MASTERWORK 2.0, LEGENDARY 2.5. +- [x] **Dirtiness** — `DirtinessSystem`, 0..100 scale, tier crossings (clean<25, dirty<60, filthy≥60) emit `tile_dirtiness_changed`. Traffic dirt via `bump_pawn_traffic(tile, indoor)`. Combat/corpse spike API stubbed for Phase 14. +- [x] **CleaningProvider** — `scenes/ai/cleaning_provider.gd`, priority=2 (between haul=3 and rest=0). `KIND_CLEAN` toil, 2.5 dirt/tick over ~40 ticks. +- [x] Room thoughts — `clean_room`(+2), `dirty_room`(-3), `filthy_room`(-6), `beautiful_room`(+4), `ugly_room`(-3), `slept_in_room`(+3 EVENT, wires Phase 17), `ate_without_table`(-3 EVENT, wires Phase 17). All synced in `Pawn._process_thoughts` via avg over room tiles. +- [x] **`Pawn._is_sheltered()`** rerouted from "has floor below" to `World.is_indoor()` with the floor-proxy as graceful fallback (Phase 12 debt paid). +- [ ] **Spike (~1 hr):** 50+ room stress test — deferred; current MVP demo (2 rooms) shows no perf concern. Run pre-launch. +- [x] **Acceptance:** Bram teleported into the 5×5 test shed (interior 9 tiles, auto-roofed) — `tile=(36, 25) indoor=true sheltered=true room= thoughts=[clean_room +2] mood=52.0`. Cabin (24 tiles, over cap) detected as `Room#2 is_under_roof=false` with `room_too_large(top_left, 24)` warning emitted. Screenshot shows test shed walls + interior floor visible, cabin warmly lit, "Spring 1/12" + Day 1 07:52 in top bar. --- diff --git a/memory.md b/memory.md index 7e4b3e0..9516393 100644 --- a/memory.md +++ b/memory.md @@ -187,6 +187,14 @@ Same scope as locked in `~/claude/ideas/rimlike/plan.md`. Realistic timeline 3 - **MCP runtime verified all paths.** Top-bar shows "Spring 1/12", rain droplets render across screen, storm white-flash caught mid-animation, wet status flipped 0→Damp(26)→Soaked(65) with mood thought sync, cold status fired on cold_snap with -4 mood. `_is_sheltered()` proxy (has floor) works for v1; Phase 13 Room BFS replaces it. - Next: Phase 13 (Rooms, roofing, beauty, dirtiness, cleaning) is the natural follow-up — it pays the `_is_sheltered()` debt and unlocks beauty/dirty mood thoughts. +- **Phase 13 (Rooms + Beauty + Dirtiness + Cleaning) shipped same day.** Three-agent fan-out reusing the Phase 12 contracts-first pattern (`Room` class, `World.rooms`/`room_at_tile`/`is_indoor`, 4 EventBus signals pre-written by Opus before dispatch). +- **DECISION: Big-room UX = bump cap to 16, banner above.** Auto-roof activates for rooms ≤16 interior tiles; rooms above that emit `room_too_large` with no roof. Cabin (24 tiles) intentionally exceeds cap so the warning path is exercised at boot. Test shed (3×3 = 9 tiles) exercises the roof path. +- **Agent C wiring loss + recovery.** Agent C reported wiring `BeautySystem` / `DirtinessSystem` / `CleaningProvider` / `IndoorTintOverlay` into `world.gd` but the instantiation code never landed — only the autoload field declarations and the entity-side `register_furniture` hooks survived. Opus added the missing 4 instantiations + work-provider registration + pre-built furniture seed in a follow-up edit. Recovery time ~5 min via runtime probe. Pattern: **trust but verify agent reports against actual file state**, especially for "I wired this into the scene/autoload" claims. Don't repeat by asking for "show me the diff" alongside the report. +- **Demo seed extended.** Added `_prestamp_test_shed_for_room_detector` + `_spawn_complete_wall` / `_spawn_complete_floor` helpers — instantiates entities in completed state for a 5×5 walled shed at (34, 23). Demonstrates the auto-roof path side-by-side with the cabin's over-cap path. +- **Sheltering proxy debt paid.** `Pawn._is_sheltered()` now reads `World.is_indoor(tile)` first, falling back to the floor-has-cell proxy for graceful degradation while RoomDetector populates. +- Phase 10 wolves' raid cooldown is set to 4800 ticks (1 in-game day). Combined with `darkness_factor ≥ 0.8` trigger gate, wolves continue to spawn nightly. No tuning required this phase. +- Next: Phase 14 (Death + corpses + burial) — closes the death loop. Pairs naturally with Phase 13's `DirtinessSystem.bump()` API for combat-blood spikes. + ## External references - **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private) diff --git a/scenes/ai/cleaning_provider.gd b/scenes/ai/cleaning_provider.gd new file mode 100644 index 0000000..dabd04f --- /dev/null +++ b/scenes/ai/cleaning_provider.gd @@ -0,0 +1,65 @@ +class_name CleaningProvider extends WorkProvider +## Phase 13 — WorkProvider for the Cleaning work category. +## +## Priority 2: fires when there is nothing more productive to do +## (below hauling=3, above rest=0). +## +## make_job / find_best_for logic: +## Scan DirtinessSystem.dirt_map for tiles with dirt >= DIRTY_THRESHOLD. +## Pick the nearest to the pawn by Manhattan distance. +## Build a 2-toil job: walk_to(tile) → kind_clean(tile). +## +## There is no Cleaning skill yet (Phase 17+ skill matrix). The clean toil +## takes a flat CLEAN_TICKS sim ticks to reduce dirt from any level to 0. +## +## JobRunner._tick_clean reduces DirtinessSystem.dirt_at(tile) by +## DIRT_REDUCTION_PER_TICK each sim tick until dirt <= 0. +## +## Audit.log fires on job start and on toil completion (via JobRunner). + +## Dirt level at or above which a tile is worth cleaning. +const DIRTY_THRESHOLD: float = 25.0 + +## Base number of sim ticks to clean a tile from any level to 0. +## No skill modifier for now; Phase 17 wires skill × speed. +const CLEAN_TICKS: int = 40 + + +func _init() -> void: + category = &"clean" + priority = 2 + + +# ── WorkProvider override ───────────────────────────────────────────────────── + +## Returns a cleaning Job for `pawn`, or null if no dirty tiles exist. +## Picks the tile closest to `pawn` (Manhattan distance) with dirt >= DIRTY_THRESHOLD. +func find_best_for(pawn) -> Job: + # Safety — dirtiness system may not be wired yet (Agent A rooms arrive slightly later). + if World.get("dirtiness_system") == null: + return null + + var ds = World.dirtiness_system + if ds == null: + return null + + var best_tile: Vector2i = Vector2i(-1, -1) + var best_dist: int = 999999 + + for tile in ds.dirt_map.keys(): + var dirt_val: float = float(ds.dirt_map[tile]) + if dirt_val < DIRTY_THRESHOLD: + continue + var d: int = abs(tile.x - pawn.tile.x) + abs(tile.y - pawn.tile.y) + if d < best_dist: + best_dist = d + best_tile = tile + + if best_tile == Vector2i(-1, -1): + return null + + var j := Job.new() + j.label = "Clean (%d,%d)" % [best_tile.x, best_tile.y] + j.toils.append(Toil.walk_to(best_tile)) + j.toils.append(Toil.clean_at(best_tile)) + return j diff --git a/scenes/ai/cleaning_provider.gd.uid b/scenes/ai/cleaning_provider.gd.uid new file mode 100644 index 0000000..a9038a5 --- /dev/null +++ b/scenes/ai/cleaning_provider.gd.uid @@ -0,0 +1 @@ +uid://d3gw4tpsuvwiu diff --git a/scenes/ai/job_runner.gd b/scenes/ai/job_runner.gd index 7edae44..2d8077d 100644 --- a/scenes/ai/job_runner.gd +++ b/scenes/ai/job_runner.gd @@ -109,6 +109,8 @@ func tick() -> void: _tick_rescue(t) Toil.KIND_TREAT: _tick_treat(t) + Toil.KIND_CLEAN: + _tick_clean(t) if t.done: job.advance() @@ -746,6 +748,58 @@ func _tick_treat(t) -> void: t.done = true +## Execute one tick of a CLEAN toil. +## +## First tick (started=false): +## - Validate the dirtiness system is available. If not, skip immediately. +## - Validate the target tile still has dirt >= DIRTY_THRESHOLD. If not, skip. +## - Mark started=true and log the clean start. +## +## Every tick: +## - Reduce dirt at the tile by DIRT_REDUCTION_PER_TICK via DirtinessSystem.bump_clean(). +## - Done when dirt <= 0. +## +## DIRTY_THRESHOLD and DIRT_REDUCTION_PER_TICK mirror CleaningProvider constants. +## 100.0 / 40 ticks = 2.5/tick ensures any tile (max 100 dirt) is clean in 40 ticks. +const _CLEAN_DIRTY_THRESHOLD: float = 25.0 +const _DIRT_REDUCTION_PER_TICK: float = 2.5 # 100 / 40 ticks + +func _tick_clean(t) -> void: + var tile := Vector2i(int(t.data.get("clean_x", 0)), int(t.data.get("clean_y", 0))) + + # Safety — dirtiness system may not be wired yet during early boot. + var ds = World.get("dirtiness_system") + if ds == null: + t.done = true + return + + if not t.data.get("started", false): + # ── first-tick: validate tile is still worth cleaning ───────────────── + var current_dirt: float = ds.dirt_at(tile) + if current_dirt < _CLEAN_DIRTY_THRESHOLD: + Audit.log( + "job_runner", + "%s clean: tile %s already clean (dirt=%.1f) — skipping" % [pawn.pawn_name, tile, current_dirt] + ) + t.done = true + return + t.data["started"] = true + Audit.log( + "job_runner", + "%s clean start at %s (dirt=%.1f)" % [pawn.pawn_name, tile, current_dirt] + ) + + # ── per-tick cleaning ────────────────────────────────────────────────────── + ds.bump_clean(tile, _DIRT_REDUCTION_PER_TICK) + var remaining: float = ds.dirt_at(tile) + if remaining <= 0.0: + Audit.log( + "job_runner", + "%s clean done at %s" % [pawn.pawn_name, tile] + ) + t.done = true + + ## Resolve the patient Pawn node from the NodePath stored in `t.data["patient"]`. ## Returns null and logs if the node is absent or no longer valid. ## Shared by _tick_rescue and _tick_treat. diff --git a/scenes/ai/thought_catalog.gd b/scenes/ai/thought_catalog.gd index b67cfb3..f6c8724 100644 --- a/scenes/ai/thought_catalog.gd +++ b/scenes/ai/thought_catalog.gd @@ -129,6 +129,98 @@ static func cold_thought() -> Thought: return t +## ── Phase 13 — Room beauty / dirtiness thoughts ───────────────────────────── +# Synced in Pawn._process_thoughts() after the damp/soaked/cold block. +# All are PERSISTENT; the sync removes old ones before adding the active tier. + +## Positive mood boost when average room beauty >= 4.0. +## modifier=+4, max_stacks=1, PERSISTENT. +static func beautiful_room() -> Thought: + var t := Thought.new() + t.id = &"beautiful_room" + t.label = "Beautiful room" + t.modifier = 4 + t.lifetime = Thought.Lifetime.PERSISTENT + t.max_stacks = 1 + return t + + +## Negative mood penalty when average room beauty < 0 (e.g. corpses present, Phase 14). +## modifier=-3, max_stacks=1, PERSISTENT. +static func ugly_room() -> Thought: + var t := Thought.new() + t.id = &"ugly_room" + t.label = "Ugly room" + t.modifier = -3 + t.lifetime = Thought.Lifetime.PERSISTENT + t.max_stacks = 1 + return t + + +## Positive mood boost when average room dirtiness < 25 (clean tier). +## modifier=+2, max_stacks=1, PERSISTENT. +static func clean_room() -> Thought: + var t := Thought.new() + t.id = &"clean_room" + t.label = "Clean room" + t.modifier = 2 + t.lifetime = Thought.Lifetime.PERSISTENT + t.max_stacks = 1 + return t + + +## Negative mood penalty when average room dirtiness is in dirty tier (25..60). +## modifier=-3, max_stacks=1, PERSISTENT. +static func dirty_room() -> Thought: + var t := Thought.new() + t.id = &"dirty_room" + t.label = "Dirty room" + t.modifier = -3 + t.lifetime = Thought.Lifetime.PERSISTENT + t.max_stacks = 1 + return t + + +## Strong negative mood penalty when average room dirtiness >= 60 (filthy tier). +## modifier=-6, max_stacks=1, PERSISTENT. +static func filthy_room() -> Thought: + var t := Thought.new() + t.id = &"filthy_room" + t.label = "Filthy room" + t.modifier = -6 + t.lifetime = Thought.Lifetime.PERSISTENT + t.max_stacks = 1 + return t + + +## Positive mood boost after sleeping in an indoor room. +## modifier=+3, max_stacks=1, EVENT, ~1200 ticks (~60 in-game sec at 1×). +## Phase 17 wires this into the sleep toil; factory added here for catalog completeness. +static func slept_in_room() -> Thought: + var t := Thought.new() + t.id = &"slept_in_room" + t.label = "Slept in a room" + t.modifier = 3 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 1200 + t.max_stacks = 1 + return t + + +## Negative mood penalty for eating without a table nearby. +## modifier=-3, max_stacks=1, EVENT, ~800 ticks (~40 in-game sec at 1×). +## Phase 17 wires this into the eat toil; factory added here for catalog completeness. +static func ate_without_table() -> Thought: + var t := Thought.new() + t.id = &"ate_without_table" + t.label = "Ate without a table" + t.modifier = -3 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 800 + t.max_stacks = 1 + return t + + ## Small mood boost after eating a cooked meal or bread. ## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD. ## Stacks up to 3 (multiple good meals compound, but cap at 3). diff --git a/scenes/ai/toil.gd b/scenes/ai/toil.gd index 3d5cb6a..8c90de6 100644 --- a/scenes/ai/toil.gd +++ b/scenes/ai/toil.gd @@ -22,6 +22,7 @@ const KIND_EAT: StringName = &"eat" # Consume pawn.carried_item and const KIND_SLEEP: StringName = &"sleep" # Sleep in a Bed (or on the floor) until pawn.sleep is full const KIND_RESCUE: StringName = &"rescue" # Marker: doctor has visited the downed pawn; single-tick no-op const KIND_TREAT: StringName = &"treat" # Multi-tick: apply medicine until patient HP ≥ revive threshold + no bleeding +const KIND_CLEAN: StringName = &"clean" # Multi-tick: reduce dirt on a tile until clean (Phase 13 Cleaning category) var kind: StringName = KIND_IDLE ## Toil-specific params — all values must be int, float, bool, String, Dict, or Array. @@ -183,6 +184,25 @@ static func treat(patient_path: NodePath) -> Toil: return t +## Multi-tick cleaning action on a floor tile. +## `tile` is the Vector2i world-tile coordinate to clean; stored as int pair for JSON safety. +## JobRunner._tick_clean reduces DirtinessSystem.dirt_at(tile) by ~2.5/tick until dirt <= 0 +## (40 base ticks per tile; Phase 17 may add skill bonus). +## +## data keys: +## "clean_x" / "clean_y" — tile coords (ints, JSON-safe) +## "started" — bool; false until first tick +static func clean_at(tile: Vector2i) -> Toil: + var t := Toil.new() + t.kind = KIND_CLEAN + t.data = { + "clean_x": tile.x, + "clean_y": tile.y, + "started": false, + } + return t + + ## Timed crafting action at a Workbench. ## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety). ## `bill_index` is the index into workbench.bills that this toil should run. diff --git a/scenes/entities/bed.gd b/scenes/entities/bed.gd index f2d08b0..ef7ff87 100644 --- a/scenes/entities/bed.gd +++ b/scenes/entities/bed.gd @@ -280,3 +280,8 @@ func _complete() -> void: _completed = true queue_redraw() Audit.log("bed", "%s built at %s" % [label_text, tile]) + # Phase 13 — notify BeautySystem so nearby tile beauty scores update. + var bs = World.get("beauty_system") + if bs != null: + bs.register_furniture(self) + bs.recompute_around(tile) diff --git a/scenes/entities/crop.gd b/scenes/entities/crop.gd index 37ed160..a86850b 100644 --- a/scenes/entities/crop.gd +++ b/scenes/entities/crop.gd @@ -33,6 +33,10 @@ var stage: Stage = Stage.SOWN ## Progress within the current growth stage; 0..STAGE_TICKS. var stage_progress: int = 0 +# Phase 13 — "no growth indoors" rule. True once we've logged the first +# indoor detection for this crop instance so we don't flood the audit log. +var _logged_indoor: bool = false + const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") @@ -101,10 +105,19 @@ func on_sow_tick() -> void: # ── growth ──────────────────────────────────────────────────────────────────── func _on_sim_tick(_n: int) -> void: - # Phase 7 simplification: crops always grow regardless of roofing. - # Phase 13 "no growth indoors" rule lands when Roof flag system is live. if stage == Stage.READY or stage == Stage.TILLED: return + # Phase 13 — crops don't grow indoors (no sunlight under a roof). + # World.is_indoor() returns false while RoomDetector has not yet fired, so + # outdoor crops planted during boot are unaffected. + if World.is_indoor(tile): + if not _logged_indoor: + Audit.log("crop", "%s at %s won't grow (indoor)" % [crop_kind, tile]) + _logged_indoor = true + return + # Crop has moved outdoors or was never indoors — reset the log flag so a + # future re-roofing produces another audit line. + _logged_indoor = false stage_progress += 1 if stage_progress >= STAGE_TICKS: stage_progress = 0 diff --git a/scenes/entities/door.gd b/scenes/entities/door.gd index 1d85e73..827eb61 100644 --- a/scenes/entities/door.gd +++ b/scenes/entities/door.gd @@ -136,7 +136,16 @@ func _draw() -> void: func _complete() -> void: _completed = true # Doors are walkable — do NOT call set_cell_walkable(false). + # Phase 13 — erase any wall-layer stamp at this tile. The demo seed + # pre-stamps the door slot as a wall so BFS can detect the cabin at boot; + # the real door completing supersedes that. Must happen before register_door + # so the BFS in mark_door_tile sees the correct wall-layer state. + if World.wall_layer != null: + World.wall_layer.erase_cell(tile) # Register so future open/close logic can locate this door by tile. World.register_door(self) + # Phase 13 — notify RoomDetector so the door tile is eligible as an + # interior boundary tile for room BFS. + World.mark_door_tile(tile) queue_redraw() Audit.log("door", "door completed at %s" % tile) diff --git a/scenes/entities/torch.gd b/scenes/entities/torch.gd index bf17604..4c8eb3b 100644 --- a/scenes/entities/torch.gd +++ b/scenes/entities/torch.gd @@ -243,3 +243,8 @@ func _complete() -> void: _light.enabled = _is_on queue_redraw() Audit.log("torch", "built at %s" % tile) + # Phase 13 — notify BeautySystem so nearby tile beauty scores update. + var bs = World.get("beauty_system") + if bs != null: + bs.register_furniture(self) + bs.recompute_around(tile) diff --git a/scenes/entities/workbench.gd b/scenes/entities/workbench.gd index 9a7c2c6..b6ef18d 100644 --- a/scenes/entities/workbench.gd +++ b/scenes/entities/workbench.gd @@ -405,6 +405,13 @@ func _complete() -> void: _light.enabled = is_on() queue_redraw() Audit.log("workbench", "%s built at %s" % [label_text, tile]) + # Phase 13 — notify BeautySystem so nearby tile beauty scores update. + # Hearth gets base beauty 4 (warm glow); other benches get 1. + # Beauty lookup key is label_text ("Hearth", "Carpenter", etc.). + var bs = World.get("beauty_system") + if bs != null: + bs.register_furniture(self) + bs.recompute_around(tile) # ── Phase 11: internal light helpers ───────────────────────────────────────── diff --git a/scenes/pawn/pawn.gd b/scenes/pawn/pawn.gd index 314ead9..e4a948a 100644 --- a/scenes/pawn/pawn.gd +++ b/scenes/pawn/pawn.gd @@ -136,6 +136,12 @@ var statuses: Array = [] # Array[Status] var _wet_accum: float = 0.0 var _cold_accum: float = 0.0 +# Phase 13 — shelter debug tracking. +## When SHELTER_DEBUG is true, any false→true or true→false transition in +## _is_sheltered() emits an Audit.log line. Off by default — debug noise. +const SHELTER_DEBUG: bool = false +var _shelter_prev: bool = false + var _path: Array[Vector2i] = [] var _step_progress: float = 0.0 var _selected: bool = false @@ -400,6 +406,9 @@ func _process_thoughts() -> void: _sync_persistent_thought(&"soaked", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_SOAKED_LEVEL, ThoughtCatalog.soaked()) # Phase 12 — cold mood thought (any cold severity triggers the single cold thought). _sync_persistent_thought(&"cold", has_status(&"cold"), ThoughtCatalog.cold_thought()) + # Phase 13 — room beauty and dirtiness thoughts. + # Defensive: World.room_at_tile returns null if rooms are empty (Agent A may land later). + _sync_room_thoughts() # 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally). if dirty: _recompute_mood() @@ -407,6 +416,62 @@ func _process_thoughts() -> void: _process_sulking() +## Phase 13 — sync beauty and dirtiness room thoughts for this pawn's current tile. +## Called from _process_thoughts() after the cold/damp/soaked block. +## Defensive: returns early if rooms or the beauty/dirtiness systems are not yet wired +## (Agent A's RoomDetector may land slightly after this code during startup). +## +## Beauty thoughts (mutually exclusive — only one fires): +## avg beauty >= 4.0 → beautiful_room +## avg beauty < 0.0 → ugly_room (Phase 14 corpses drive this below 0) +## else → neither +## +## Dirtiness thoughts (mutually exclusive — only one fires): +## avg dirt < 25 → clean_room +## avg dirt 25..60 → dirty_room +## avg dirt >= 60 → filthy_room +func _sync_room_thoughts() -> void: + var room = World.room_at_tile(tile) + + # ── no room (outdoors or RoomDetector not yet live) → clear all room thoughts ── + if room == null: + _sync_persistent_thought(&"beautiful_room", false, ThoughtCatalog.beautiful_room()) + _sync_persistent_thought(&"ugly_room", false, ThoughtCatalog.ugly_room()) + _sync_persistent_thought(&"clean_room", false, ThoughtCatalog.clean_room()) + _sync_persistent_thought(&"dirty_room", false, ThoughtCatalog.dirty_room()) + _sync_persistent_thought(&"filthy_room", false, ThoughtCatalog.filthy_room()) + return + + # ── beauty ────────────────────────────────────────────────────────────────── + var avg_beauty: float = 0.0 + var bs = World.get("beauty_system") + if bs != null and room.tiles.size() > 0: + var beauty_sum: float = 0.0 + for rt in room.tiles: + beauty_sum += bs.beauty_at(rt) + avg_beauty = beauty_sum / float(room.tiles.size()) + + _sync_persistent_thought(&"beautiful_room", avg_beauty >= 4.0, ThoughtCatalog.beautiful_room()) + _sync_persistent_thought(&"ugly_room", avg_beauty < 0.0, ThoughtCatalog.ugly_room()) + + # ── dirtiness ─────────────────────────────────────────────────────────────── + var avg_dirt: float = 0.0 + var ds = World.get("dirtiness_system") + if ds != null and room.tiles.size() > 0: + var dirt_sum: float = 0.0 + for rt in room.tiles: + dirt_sum += ds.dirt_at(rt) + avg_dirt = dirt_sum / float(room.tiles.size()) + + # Mutually exclusive — only one fires (filthy wins over dirty wins over clean). + var is_filthy: bool = avg_dirt >= 60.0 + var is_dirty: bool = avg_dirt >= 25.0 and not is_filthy + var is_clean: bool = avg_dirt < 25.0 + _sync_persistent_thought(&"filthy_room", is_filthy, ThoughtCatalog.filthy_room()) + _sync_persistent_thought(&"dirty_room", is_dirty, ThoughtCatalog.dirty_room()) + _sync_persistent_thought(&"clean_room", is_clean, ThoughtCatalog.clean_room()) + + ## Add or remove a PERSISTENT thought based on a boolean state flag. ## Calls add_thought() / remove_thought_by_id() (which recompute mood) only ## when the presence actually needs to change — avoids redundant recomputes. @@ -590,12 +655,25 @@ func _sync_cold_status() -> void: break -## Phase 12 — returns true if the pawn's current tile has a floor beneath it. -## This is a Phase 13 stand-in for full Room/Roof detection. A tile is considered -## sheltered when World.floor_layer reports a valid cell at the pawn's tile position. -## Replace with Room.contains(tile) once the Room BFS system lands (Phase 13+). +## Phase 13 — returns true if the pawn's current tile is inside an enclosed, +## roofed Room (via World.is_indoor). Falls back to the Phase 12 has-floor proxy +## during the brief window before RoomDetector populates the registry (boot, +## mid-build, cabin not-yet-enclosed). The fallback is graceful enough for those +## transient states and produces no false positives on open terrain. +## +## Callers: _tick_wet() and _tick_cold() both call this once per sim tick. +## If SHELTER_DEBUG is true, false→true and true→false transitions emit Audit lines. func _is_sheltered() -> bool: - return World.floor_layer.get_cell_source_id(tile) != -1 + var sheltered: bool + if World.is_indoor(tile): + sheltered = true + else: + sheltered = World.floor_layer.get_cell_source_id(tile) != -1 + if SHELTER_DEBUG and sheltered != _shelter_prev: + var direction := "→sheltered" if sheltered else "→unsheltered" + Audit.log("pawn", "%s shelter transition %s at %s" % [pawn_name, direction, tile]) + _shelter_prev = sheltered + return sheltered # ── save / load ───────────────────────────────────────────────────────────── diff --git a/scenes/world/beauty_system.gd b/scenes/world/beauty_system.gd new file mode 100644 index 0000000..c905858 --- /dev/null +++ b/scenes/world/beauty_system.gd @@ -0,0 +1,179 @@ +class_name BeautySystem extends Node +## Phase 13 — per-tile beauty score, derived from nearby furniture and its quality. +## +## beauty_map stores only tiles with non-zero beauty (sparse). Default = 0. +## +## Beauty contributions (base × quality_multiplier, spread over a radius): +## Bed +2 radius 3 +## Workbench +1 radius 3 +## Torch +3 radius 3 +## Hearth +4 radius 3 (warm glow variant of Workbench by label_text) +## +## Falloff: linear from base at the source tile to 0 at radius+1. +## Contribution at distance d = base_beauty × multiplier × max(0, 1 - d/radius) +## +## Usage: +## Call recompute_around(tile) after any furniture build or removal. +## Query beauty_at(tile) for the aggregated beauty value at a tile. +## EventBus.tile_beauty_changed fires for every tile whose beauty changes. +## +## Wire as child of the World scene (after Pathfinder). world.gd exposes the +## instance on the World autoload as World.beauty_system for entity code. +## +## Negative beauty (corpses, etc.) is Phase 14 work — just expose bump() then. + +## Sparse map: only tiles with non-zero beauty are stored. +## Keys = Vector2i tile coords; Values = float beauty score. +var beauty_map: Dictionary = {} + +## Registered furniture entities. Each must expose: +## tile: Vector2i +## is_completed() -> bool (or _completed var — checked via has_method) +## label_text: String (for Hearth vs generic Workbench beauty) +## and optionally: +## quality: int (Item.Quality int; defaults to NORMAL=1 if absent) +var _furniture: Array = [] + +## Base beauty values by duck-typed entity type (label_text takes priority for Workbench). +const BASE_BEAUTY: Dictionary = { + "Bed": 2.0, + "Torch": 3.0, + "Hearth": 4.0, + "Workbench": 1.0, # fallback for Carpenter / Smelter / Millstone etc. + "Carpenter": 1.0, + "Smelter": 1.0, + "Millstone": 1.0, +} + +## Default beauty spread radius in tiles. +const DEFAULT_RADIUS: int = 3 + + +# ── public API ──────────────────────────────────────────────────────────────── + +## Returns the aggregated beauty score for `tile`. 0 if tile is not in the map. +func beauty_at(tile: Vector2i) -> float: + return float(beauty_map.get(tile, 0.0)) + + +## Register a furniture entity so BeautySystem tracks it. +## Call from the entity's _ready() or on_build_complete hook. +func register_furniture(entity) -> void: + if not _furniture.has(entity): + _furniture.append(entity) + + +## Unregister a furniture entity. +## Call from the entity's _exit_tree(). +func unregister_furniture(entity) -> void: + _furniture.erase(entity) + + +## Recompute beauty for all tiles within `radius` of `tile`. +## Call after a furniture piece is built or removed. +## Emits EventBus.tile_beauty_changed for every tile whose value changes. +func recompute_around(tile: Vector2i, radius: int = DEFAULT_RADIUS) -> void: + # Collect all tiles in the affected area (square bbox, then clamp to map). + var affected_tiles: Array[Vector2i] = [] + for dy in range(-radius, radius + 1): + for dx in range(-radius, radius + 1): + affected_tiles.append(Vector2i(tile.x + dx, tile.y + dy)) + _recompute_tiles(affected_tiles) + Audit.log("beauty", "recompute_around %s radius=%d" % [tile, radius]) + + +## Full recompute across all tracked furniture. Useful on load or large changes. +## Returns immediately and logs if no furniture is registered. +func recompute_all() -> void: + if _furniture.is_empty(): + return + # Collect every tile that is currently in the map OR within reach of any furniture. + var dirty_tiles: Dictionary = {} + for tile in beauty_map.keys(): + dirty_tiles[tile] = true + for entity in _furniture: + if not _entity_completed(entity): + continue + var t: Vector2i = entity.tile + for dy in range(-DEFAULT_RADIUS, DEFAULT_RADIUS + 1): + for dx in range(-DEFAULT_RADIUS, DEFAULT_RADIUS + 1): + dirty_tiles[Vector2i(t.x + dx, t.y + dy)] = true + _recompute_tiles(dirty_tiles.keys()) + + +# ── internal ────────────────────────────────────────────────────────────────── + +## Recompute beauty for each tile in `tiles` from scratch. +## Emits tile_beauty_changed for any tile whose value changed. +func _recompute_tiles(tiles: Array) -> void: + for tile in tiles: + var new_val: float = _compute_beauty_at(tile) + var old_val: float = float(beauty_map.get(tile, 0.0)) + if is_equal_approx(new_val, old_val): + continue + if new_val == 0.0: + beauty_map.erase(tile) + else: + beauty_map[tile] = new_val + EventBus.tile_beauty_changed.emit(tile, new_val) + Audit.log("beauty", "tile %s → %.2f" % [tile, new_val]) + + +## Sum all furniture contributions at a given tile. +func _compute_beauty_at(tile: Vector2i) -> float: + var total: float = 0.0 + for entity in _furniture: + if not _entity_completed(entity): + continue + var et: Vector2i = entity.tile + var dist: int = abs(et.x - tile.x) + abs(et.y - tile.y) + if dist > DEFAULT_RADIUS: + continue + var base: float = _base_beauty_for(entity) + if base == 0.0: + continue + var multiplier: float = _quality_multiplier_for(entity) + var falloff: float = maxf(0.0, 1.0 - float(dist) / float(DEFAULT_RADIUS)) + total += base * multiplier * falloff + return total + + +## Returns the base beauty value for an entity by its label_text (or type name). +func _base_beauty_for(entity) -> float: + var label: String = "" + if "label_text" in entity: + label = str(entity.label_text) + # Check specific labels first, then fall back to class_name. + if BASE_BEAUTY.has(label): + return float(BASE_BEAUTY[label]) + # Fallback by class_name string (e.g. "Bed", "Torch"). + var cname: String = entity.get_class() + if BASE_BEAUTY.has(cname): + return float(BASE_BEAUTY[cname]) + return 0.0 + + +## Returns the quality multiplier for an entity. +## Uses Item.Quality int (0=SHODDY..4=LEGENDARY) → 0.5/1.0/1.5/2.0/2.5. +## Defaults to 1.0 (NORMAL) if the entity has no quality field. +func _quality_multiplier_for(entity) -> float: + var q: int = 1 # NORMAL default + if "quality" in entity: + q = int(entity.quality) + match q: + 0: return 0.5 # SHODDY + 1: return 1.0 # NORMAL + 2: return 1.5 # EXCELLENT + 3: return 2.0 # MASTERWORK + 4: return 2.5 # LEGENDARY + _: return 1.0 + + +## Returns true if `entity` has finished building. +## Checks the _completed bool directly (all furniture exposes is_completed() and the var). +func _entity_completed(entity) -> bool: + if entity.has_method("is_completed"): + return entity.is_completed() + if "_completed" in entity: + return bool(entity._completed) + return true # assume complete if we can't tell diff --git a/scenes/world/beauty_system.gd.uid b/scenes/world/beauty_system.gd.uid new file mode 100644 index 0000000..e001383 --- /dev/null +++ b/scenes/world/beauty_system.gd.uid @@ -0,0 +1 @@ +uid://bc7s1jr1y01o diff --git a/scenes/world/designation.gd b/scenes/world/designation.gd index cdec554..2b01ef2 100644 --- a/scenes/world/designation.gd +++ b/scenes/world/designation.gd @@ -17,14 +17,19 @@ const TOOL_NONE: StringName = &"none" const TOOL_BUILD_WALL: StringName = &"build_wall" const TOOL_BUILD_FLOOR: StringName = &"build_floor" const TOOL_BUILD_DOOR: StringName = &"build_door" +# Phase 13 — no-roof designation: painted tiles become courtyards; RoomDetector +# excludes them from auto-roofing. Calls World.toggle_no_roof_at() on apply. +const TOOL_NO_ROOF: StringName = &"no_roof" # Atlas coords on the shared placeholder tileset (source 0). # build_wall → stone-grey (2, 0); build_floor → dirt-brown (1, 0). # build_door → dark stone (3, 0) so the ghost reads visually distinct from walls. +# no_roof → grass (0, 0) with the designation layer modulate tinting it visibly. const _ATLAS_BY_TOOL: Dictionary = { &"build_wall": Vector2i(2, 0), &"build_floor": Vector2i(1, 0), &"build_door": Vector2i(3, 0), + &"no_roof": Vector2i(0, 0), } # Placeholder source ID — mirrors World.PLACEHOLDER_SOURCE_ID. @@ -63,7 +68,7 @@ func bind(paint_layer: TileMapLayer, selection: Selection = null) -> void: ## Activate a paint tool. Pass TOOL_NONE to deactivate. func set_active_tool(tool: StringName) -> void: assert( - tool in [TOOL_NONE, TOOL_BUILD_WALL, TOOL_BUILD_FLOOR, TOOL_BUILD_DOOR], + tool in [TOOL_NONE, TOOL_BUILD_WALL, TOOL_BUILD_FLOOR, TOOL_BUILD_DOOR, TOOL_NO_ROOF], "Designation.set_active_tool: unknown tool '%s'" % tool ) _tool = tool @@ -153,6 +158,13 @@ func _apply_ghost(cell: Vector2i) -> void: var ok: bool = _cell_is_placeable(cell) _paint_layer.modulate = Color(0.4, 1.0, 0.4, 0.7) if ok else Color(1.0, 0.4, 0.4, 0.7) + # Phase 13 — no-roof tool: toggle the tile in World.no_roof_cells and + # trigger an immediate room recompute. No build job is queued. + if _tool == TOOL_NO_ROOF: + World.toggle_no_roof_at(cell) + Audit.log("designation", "no_roof toggled at %s" % cell) + return + designation_added.emit(cell, _tool) EventBus.designation_added.emit(cell, _tool) Audit.log("designation", "painted %s at %s (placeable=%s)" % [_tool, cell, ok]) diff --git a/scenes/world/dirtiness_system.gd b/scenes/world/dirtiness_system.gd new file mode 100644 index 0000000..dae48ac --- /dev/null +++ b/scenes/world/dirtiness_system.gd @@ -0,0 +1,109 @@ +class_name DirtinessSystem extends Node +## Phase 13 — per-tile dirtiness accumulation and tier tracking. +## +## dirt_map: Dictionary[Vector2i, float], 0..100 scale. Only tiles with non-zero +## dirt are keyed (sparse). +## +## Public API: +## bump(tile, amount) — add dirtiness to a tile. Positive adds dirt; use +## negative values to reduce (cleaning does this via +## bump_clean). Phase 14 calls bump(tile, +5) per hour +## for corpse decay and bump(tile, +20) for combat blood. +## bump_clean(tile, amount) — specialised helper for the CleaningProvider; +## reduces dirt and removes the tile from the map at 0. +## dirt_at(tile) — returns the current dirtiness for a tile (0.0 default). +## +## Tier thresholds (per design.md): +## Clean < 25 +## Dirty 25..60 +## Filthy >= 60 +## +## EventBus.tile_dirtiness_changed fires on TIER crossings only, not every bump. +## This keeps signal volume low since bumps fire 20×/s per pawn crossing. +## +## Pawn tile-change hook: +## World scene calls bump_pawn_traffic(tile, indoor) each time a pawn advances +## a tile. Indoor traffic adds 0.2; outdoor-tracked-in adds 0.5. +## (world.gd bridges _advance_walk → DirtinessSystem via the arrived_at_destination +## signal on each Pawn — wired in world.gd _spawn_sample_pawns / _on_pawn_ready.) +## +## Wire as child of the World scene (after Pathfinder). world.gd exposes the +## instance on the World autoload as World.dirtiness_system for entity code. + +## Sparse map: only tiles with non-zero dirt are stored. +## Keys = Vector2i tile coords; Values = float in [0, 100]. +var dirt_map: Dictionary = {} + +## Tier boundaries (thresholds match design.md; tune Phase 20). +const DIRT_DIRTY_THRESHOLD: float = 25.0 +const DIRT_FILTHY_THRESHOLD: float = 60.0 + +## Traffic bump amounts per pawn step. +const BUMP_OUTDOOR_TRACKED: float = 0.5 # boots bringing dirt in from outside +const BUMP_INDOOR_TRAFFIC: float = 0.2 # indoor walking + + +# ── public API ──────────────────────────────────────────────────────────────── + +## Returns the dirtiness score for `tile` (0.0 if clean / not in map). +func dirt_at(tile: Vector2i) -> float: + return float(dirt_map.get(tile, 0.0)) + + +## Add `amount` of dirt to `tile`. Clamped to [0, 100]. +## Emits EventBus.tile_dirtiness_changed when a tier boundary is crossed. +## Phase 14 calls this for blood (+20) and corpse decay (+5/h). +func bump(tile: Vector2i, amount: float) -> void: + var old_val: float = float(dirt_map.get(tile, 0.0)) + var new_val: float = clampf(old_val + amount, 0.0, 100.0) + if is_equal_approx(new_val, old_val): + return + _set_dirt(tile, old_val, new_val) + + +## Reduce dirt by `amount`, flooring at 0. Removes tile from map when clean. +## Used by CleaningProvider's KIND_CLEAN toil each tick. +func bump_clean(tile: Vector2i, amount: float) -> void: + var old_val: float = float(dirt_map.get(tile, 0.0)) + if old_val <= 0.0: + return + var new_val: float = maxf(0.0, old_val - amount) + _set_dirt(tile, old_val, new_val) + + +## Traffic-driven dirt bump. Called by World when a pawn arrives at a new tile. +## `indoor` = true when the tile is inside a roofed room (World.is_indoor check). +func bump_pawn_traffic(tile: Vector2i, indoor: bool) -> void: + var amount := BUMP_INDOOR_TRAFFIC if indoor else BUMP_OUTDOOR_TRACKED + bump(tile, amount) + + +# ── internal ────────────────────────────────────────────────────────────────── + +## Apply a dirt change from old_val to new_val for `tile`. +## Updates the map and emits the tier-crossing signal when the tier changes. +func _set_dirt(tile: Vector2i, old_val: float, new_val: float) -> void: + var old_tier: int = _tier_for(old_val) + var new_tier: int = _tier_for(new_val) + + if new_val <= 0.0: + dirt_map.erase(tile) + else: + dirt_map[tile] = new_val + + if old_tier != new_tier: + EventBus.tile_dirtiness_changed.emit(tile, new_val) + Audit.log( + "dirtiness", + "tile %s tier %d → %d (dirt=%.1f)" % [tile, old_tier, new_tier, new_val] + ) + + +## Returns the dirtiness tier for a given value. +## 0 = clean, 1 = dirty, 2 = filthy. +func _tier_for(val: float) -> int: + if val >= DIRT_FILTHY_THRESHOLD: + return 2 + if val >= DIRT_DIRTY_THRESHOLD: + return 1 + return 0 diff --git a/scenes/world/dirtiness_system.gd.uid b/scenes/world/dirtiness_system.gd.uid new file mode 100644 index 0000000..63d284c --- /dev/null +++ b/scenes/world/dirtiness_system.gd.uid @@ -0,0 +1 @@ +uid://dtgne6uq4wdsf diff --git a/scenes/world/indoor_tint_overlay.gd b/scenes/world/indoor_tint_overlay.gd new file mode 100644 index 0000000..a7bdca9 --- /dev/null +++ b/scenes/world/indoor_tint_overlay.gd @@ -0,0 +1,52 @@ +extends Node2D +## Phase 13 — subtle warm overlay drawn over every tile that belongs to an +## enclosed, roofed Room. +## +## Rendering model: Node2D _draw() filled on EventBus.room_changed only — NOT +## every render frame. At MVP scale (< 20 rooms × ~10 tiles each) that's at +## most ~200 draw_rect calls per room-topology change, which is negligible. +## +## Placement: child of the World Node2D, z_index = 3 (above Floor layer at 1, +## same z as Designation — kept above floor, below pawns/entities at 4+). +## +## The overlay does NOT use CanvasModulate or shaders; a plain translucent rect +## per tile is correct, cheap, and doesn't interact with the day/night modulate. +## +## When the room registry is empty (boot, before RoomDetector fires), _draw() +## simply does nothing — graceful degradation. + +class_name IndoorTintOverlay + +## Warm candlelight tint. Alpha is deliberately very low (0.10) so the floor +## and entity sprites beneath remain fully legible. +const INDOOR_COLOR: Color = Color(1.0, 0.95, 0.85, 0.10) + +const TILE_SIZE_PX: int = 16 ## Mirror of World.TILE_SIZE_PX; standalone to avoid circular dep. + + +func _ready() -> void: + z_index = 3 + # Listen for room topology changes and redraw. room_changed fires when any + # Room is created, destroyed, or recomputed by RoomDetector (Agent A). + EventBus.room_changed.connect(_on_room_changed) + + +func _on_room_changed(_room_id: int) -> void: + queue_redraw() + + +func _draw() -> void: + # Iterate every room in the registry. Only draw tiles belonging to roofed rooms. + for id in World.rooms: + var r = World.rooms[id] + if not r.is_under_roof: + continue + # r.tiles is an Array[Vector2i] per room.gd contract. + for t in r.tiles: + var rect := Rect2( + float(t.x * TILE_SIZE_PX), + float(t.y * TILE_SIZE_PX), + float(TILE_SIZE_PX), + float(TILE_SIZE_PX) + ) + draw_rect(rect, INDOOR_COLOR) diff --git a/scenes/world/indoor_tint_overlay.gd.uid b/scenes/world/indoor_tint_overlay.gd.uid new file mode 100644 index 0000000..d80b5f9 --- /dev/null +++ b/scenes/world/indoor_tint_overlay.gd.uid @@ -0,0 +1 @@ +uid://bg0fqaxphkieo diff --git a/scenes/world/room.gd b/scenes/world/room.gd new file mode 100644 index 0000000..0d0c37f --- /dev/null +++ b/scenes/world/room.gd @@ -0,0 +1,63 @@ +class_name Room +## Phase 13 — enclosed-space data class. +## +## A Room is a set of contiguous tiles enclosed by walls (and/or doors), discovered +## by RoomDetector's BFS. Once discovered, the room may be auto-roofed (if +## `tile_count() <= ROOM_AUTOROOF_CAP`) and its tiles count as "indoor" for +## purposes of weather shelter, beauty aggregation, dirtiness, and room thoughts. +## +## A room with tile_count() > ROOM_AUTOROOF_CAP is detected but NOT roofed; it +## triggers EventBus.room_too_large so UI can surface the "split with an interior +## wall" banner. The cap is 16 per the 2026-05-11 decision (memory.md). +## +## Construction is owned by RoomDetector; entities should NEVER instantiate +## Rooms directly — query World.room_at_tile() instead. + +const ROOM_AUTOROOF_CAP: int = 16 + +## Stable identity, assigned by RoomDetector on creation. Used as the value +## carried by EventBus.room_changed(room_id). Invalidated on destroy. +var id: int = -1 + +## Every floor/door tile inside this room. Walls themselves are NOT included — +## walls are the boundary, not the interior. +var tiles: Array[Vector2i] = [] + +## Cached AABB of `tiles`. Useful for cheap point-in-room rejection before the +## full tiles-array sweep. +var bounds: Rect2i = Rect2i() + +## True when RoomDetector applied auto-roof — i.e. tile_count() <= ROOM_AUTOROOF_CAP +## AND the room is fully enclosed. Drives shelter / indoor-tint checks. +var is_under_roof: bool = false + + +## Returns the number of interior tiles (NOT including bounding walls). +func tile_count() -> int: + return tiles.size() + + +## True if `tile` is one of this room's interior tiles. Uses bounds first +## as a cheap reject before falling back to a linear search. +func contains_tile(tile: Vector2i) -> bool: + if not bounds.has_point(tile): + return false + return tile in tiles + + +## Recompute the bounds Rect2i from the current tiles array. Called by +## RoomDetector after populating tiles. +func recompute_bounds() -> void: + if tiles.is_empty(): + bounds = Rect2i() + return + var min_x: int = tiles[0].x + var min_y: int = tiles[0].y + var max_x: int = tiles[0].x + var max_y: int = tiles[0].y + for t in tiles: + if t.x < min_x: min_x = t.x + if t.y < min_y: min_y = t.y + if t.x > max_x: max_x = t.x + if t.y > max_y: max_y = t.y + bounds = Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1) diff --git a/scenes/world/room.gd.uid b/scenes/world/room.gd.uid new file mode 100644 index 0000000..1843362 --- /dev/null +++ b/scenes/world/room.gd.uid @@ -0,0 +1 @@ +uid://bpcf16gi3g05d diff --git a/scenes/world/room_detector.gd b/scenes/world/room_detector.gd new file mode 100644 index 0000000..8b5dda9 --- /dev/null +++ b/scenes/world/room_detector.gd @@ -0,0 +1,256 @@ +class_name RoomDetector extends Node +## Phase 13 — BFS-based room detection, auto-roof, and no-roof exclusion. +## +## RoomDetector owns the lifecycle of Room objects: it creates them, destroys +## them when the geometry changes, and writes `World.rooms`. Nothing else +## should mutate `World.rooms` directly. +## +## Entry point: call `recompute_around(tile)` after any wall/floor/door build or +## removal. World._ready() calls it once for the cabin demo seed so MCP runtime +## tests can see a Room in the registry immediately after boot. +## +## No-roof exclusion: tiles in `World.no_roof_cells` are treated as "open sky" +## during BFS. If any such tile appears in a BFS result the room is detected +## but marked `is_under_roof = false` regardless of size. +## +## Delegation model: this node IS a child of the World scene node (not the +## World autoload). `World.room_detector` is a convenience reference set in +## World._ready() so autoloads can call recompute_around(). + +## Give enough BFS headroom to detect rooms up to 4× the auto-roof cap. +## BFS counts exceeding this without finding a wall ring → treated as outdoor. +const BFS_CAP_FOR_ROOM: int = Room.ROOM_AUTOROOF_CAP * 4 + +## Radius (in tiles) around a changed tile to re-evaluate. +const RECOMPUTE_RADIUS: int = 3 + +## 4-directional offsets (no diagonals — matches pathfinder convention). +const _DIRS: Array[Vector2i] = [ + Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0), +] + +## Map bounds — set by World._ready() after pathfinder is configured. +var _map_size: Vector2i = Vector2i(80, 80) + +## Monotonically-increasing id counter. Invalidated ids are never reused. +var _next_room_id: int = 0 + + +# ── public API ──────────────────────────────────────────────────────────────── + +## Set the canonical map size so BFS can detect edge-of-map as "outdoor". +func setup(map_size: Vector2i) -> void: + _map_size = map_size + + +## Main entry point. Call after any wall/floor/door change at `changed_tile`. +## Invalidates rooms that overlap the 3-tile radius, then re-BFS from every +## open floor tile in that radius. +func recompute_around(changed_tile: Vector2i) -> void: + # 1. Gather the recompute radius set. + var radius_tiles: Array[Vector2i] = _tiles_in_radius(changed_tile, RECOMPUTE_RADIUS) + + # 2. Destroy any Room whose tiles overlap the radius. + _invalidate_rooms_touching(radius_tiles) + + # 3. For each floor or door tile in the radius that isn't a wall, try a BFS. + # Track which tiles we've already started a BFS from so we don't start two + # BFS runs from tiles that will find the same room. + var already_claimed: Dictionary = {} # Vector2i → true, for tiles inside a found room. + + for candidate in radius_tiles: + if already_claimed.has(candidate): + continue + if not _is_floor_or_door(candidate): + continue + + var result: Dictionary = _bfs_room(candidate) + if result.is_empty(): + continue # outdoor or BFS cap hit without enclosure + + var found_tiles: Array[Vector2i] = result["tiles"] + var has_no_roof: bool = result["has_no_roof"] + + # Mark all found tiles so sibling candidates skip them. + for t in found_tiles: + already_claimed[t] = true + + _create_room(found_tiles, has_no_roof) + + +# ── room invalidation ───────────────────────────────────────────────────────── + +func _invalidate_rooms_touching(radius_tiles: Array[Vector2i]) -> void: + var radius_set: Dictionary = {} + for t in radius_tiles: + radius_set[t] = true + + # Collect ids to destroy (can't erase while iterating). + var to_destroy: Array[int] = [] + for id in World.rooms: + var r: Room = World.rooms[id] + for t in r.tiles: + if radius_set.has(t): + to_destroy.append(id) + break + + for id in to_destroy: + World.rooms.erase(id) + EventBus.room_changed.emit(id) + Audit.log("room", "destroyed room #%d" % id) + + +# ── BFS ─────────────────────────────────────────────────────────────────────── + +## Returns a dict {"tiles": Array[Vector2i], "has_no_roof": bool} when a clean +## enclosed room is found. Returns an empty dict for outdoor / over-cap areas. +## +## Door semantics: a door tile is a BOUNDARY like a wall — BFS stops at it and +## does NOT expand through it to the outside. The door tile IS included in the +## room's tile list (it's part of the interior enclosure). This matches the +## spec: "treat door tiles as boundary cells" that stop the BFS but are counted. +func _bfs_room(start: Vector2i) -> Dictionary: + # interior_tiles: tiles that are inside the room (floor + door tiles). + # boundary_visited: tiles that stopped BFS expansion (walls + doors), tracked + # so we don't re-process them as neighbours. + var interior_visited: Dictionary = {} # Vector2i → true + var boundary_visited: Dictionary = {} # Vector2i → true (walls + doors) + var queue: Array[Vector2i] = [start] + interior_visited[start] = true + var has_no_roof: bool = false + + while queue.size() > 0: + if interior_visited.size() > BFS_CAP_FOR_ROOM: + # Exceeded cap without finding a closed wall ring → treat as outdoor. + return {} + + var current: Vector2i = queue.pop_front() + + # Check for no-roof designation. + if World.no_roof_cells.has(current): + has_no_roof = true + + for dir in _DIRS: + var neighbour: Vector2i = current + dir + + # Already classified — skip. + if interior_visited.has(neighbour) or boundary_visited.has(neighbour): + continue + + # Map edge → open sky → discard entire BFS. + if not _in_bounds(neighbour): + return {} + + if _is_wall(neighbour): + # Wall is a valid enclosing boundary — mark as boundary, stop expansion. + boundary_visited[neighbour] = true + continue + + if _is_door(neighbour): + # Door acts as a boundary that stops BFS from escaping, but the door + # tile itself is part of the room interior. Add to interior but do + # NOT expand through it — treat it like a wall for expansion purposes. + interior_visited[neighbour] = true + # Do NOT append to queue — door blocks outward traversal. + continue + + if _is_floor(neighbour): + interior_visited[neighbour] = true + queue.append(neighbour) + else: + # Bare terrain → open sky → discard. + return {} + + if interior_visited.is_empty(): + return {} + + var interior: Array[Vector2i] = [] + for t: Vector2i in interior_visited.keys(): + interior.append(t) + + return {"tiles": interior, "has_no_roof": has_no_roof} + + +# ── room creation ───────────────────────────────────────────────────────────── + +func _create_room(interior: Array[Vector2i], has_no_roof: bool) -> void: + var r := Room.new() + r.id = _next_room_id + _next_room_id += 1 + r.tiles = interior + r.recompute_bounds() + + var count: int = interior.size() + var top_left: Vector2i = Vector2i(r.bounds.position.x, r.bounds.position.y) + + if has_no_roof: + # No-roof tile inside — courtyard; detects as room but stays open. + r.is_under_roof = false + World.rooms[r.id] = r + EventBus.room_changed.emit(r.id) + Audit.log("room", "discovered #%d size=%d at bounds=%s (no-roof courtyard)" % [ + r.id, count, r.bounds + ]) + + elif count <= Room.ROOM_AUTOROOF_CAP: + r.is_under_roof = true + World.rooms[r.id] = r + EventBus.room_changed.emit(r.id) + Audit.log("room", "discovered #%d size=%d at bounds=%s (auto-roofed)" % [ + r.id, count, r.bounds + ]) + + else: + # Clean enclosure but over the auto-roof cap. + r.is_under_roof = false + World.rooms[r.id] = r + EventBus.room_too_large.emit(top_left, count) + EventBus.room_changed.emit(r.id) + Audit.log("room", "WARNING room #%d too large size=%d at bounds=%s (roof suppressed)" % [ + r.id, count, r.bounds + ]) + + +# ── tile classification helpers ─────────────────────────────────────────────── + +func _in_bounds(tile: Vector2i) -> bool: + return tile.x >= 0 and tile.y >= 0 and tile.x < _map_size.x and tile.y < _map_size.y + + +## True if the tile has a wall stamp on the Wall TileMapLayer. +func _is_wall(tile: Vector2i) -> bool: + if World.wall_layer == null: + return false + return World.wall_layer.get_cell_source_id(tile) != -1 + + +## True if the tile has a floor stamp on the Floor TileMapLayer. +func _is_floor(tile: Vector2i) -> bool: + if World.floor_layer == null: + return false + return World.floor_layer.get_cell_source_id(tile) != -1 + + +## True if the tile is a completed door entity (World.doors registry). +## Doors act as room boundaries in the BFS (walkable but block outward expansion). +func _is_door(tile: Vector2i) -> bool: + for door in World.doors: + if door.tile == tile and door.is_completed(): + return true + return false + + +## Convenience: true if tile is floor OR door. Used to seed BFS start points. +func _is_floor_or_door(tile: Vector2i) -> bool: + return _is_floor(tile) or _is_door(tile) + + +## Collect all tiles within `radius` Manhattan distance of `center`. +func _tiles_in_radius(center: Vector2i, radius: int) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + for dx in range(-radius, radius + 1): + for dy in range(-radius, radius + 1): + var t := center + Vector2i(dx, dy) + if _in_bounds(t): + result.append(t) + return result diff --git a/scenes/world/room_detector.gd.uid b/scenes/world/room_detector.gd.uid new file mode 100644 index 0000000..2e66d6a --- /dev/null +++ b/scenes/world/room_detector.gd.uid @@ -0,0 +1 @@ +uid://e16fao7xi26a diff --git a/scenes/world/world.gd b/scenes/world/world.gd index 1f591b6..4bdb116 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -17,6 +17,10 @@ const TILE_STONE_DARK: Vector2i = Vector2i(3, 0) const PLACEHOLDER_SOURCE_ID: int = 0 +const BEAUTY_SYSTEM_SCRIPT: Script = preload("res://scenes/world/beauty_system.gd") +const DIRTINESS_SYSTEM_SCRIPT: Script = preload("res://scenes/world/dirtiness_system.gd") +const CLEANING_PROVIDER_SCRIPT: Script = preload("res://scenes/ai/cleaning_provider.gd") +const INDOOR_TINT_SCRIPT: Script = preload("res://scenes/world/indoor_tint_overlay.gd") const PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn") const TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn") const ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.tscn") @@ -86,6 +90,7 @@ const SEASON_TINTS: Dictionary = { @onready var eat_provider: EatProvider = $EatProvider @onready var sleep_provider: SleepProvider = $SleepProvider @onready var doctor_provider: DoctorProvider = $DoctorProvider +@onready var room_detector = $RoomDetector # RoomDetector — duck-typed (class_name scan-time window) func _ready() -> void: @@ -112,6 +117,37 @@ func _ready() -> void: World.floor_layer = floor_layer World.designation_layer = designation_layer + # Phase 13 — wire RoomDetector; setup with map size so BFS knows map bounds. + room_detector.setup(MAP_SIZE_TILES) + World.room_detector = room_detector + + # Phase 13 — instantiate BeautySystem + DirtinessSystem + CleaningProvider as + # runtime children (no .tscn entry needed; they're stateful Nodes with no + # editor-tunable exports). Autoload refs let entity code reach them. + var beauty := Node.new() + beauty.set_script(BEAUTY_SYSTEM_SCRIPT) + beauty.name = "BeautySystem" + add_child(beauty) + World.beauty_system = beauty + + var dirtiness := Node.new() + dirtiness.set_script(DIRTINESS_SYSTEM_SCRIPT) + dirtiness.name = "DirtinessSystem" + add_child(dirtiness) + World.dirtiness_system = dirtiness + + var cleaning := Node.new() + cleaning.set_script(CLEANING_PROVIDER_SCRIPT) + cleaning.name = "CleaningProvider" + add_child(cleaning) + + # Phase 13 — instantiate IndoorTintOverlay so roofed rooms get a subtle warm + # overlay. Node2D, z_index 3 (set in its _ready). Self-listens to room_changed. + var indoor_tint := Node2D.new() + indoor_tint.set_script(INDOOR_TINT_SCRIPT) + indoor_tint.name = "IndoorTintOverlay" + add_child(indoor_tint) + # Designation: bind the paint surface + the Selection guard. designation_ctl.bind(designation_layer, selection) @@ -128,6 +164,7 @@ func _ready() -> void: World.register_work_provider(crafting_provider) World.register_work_provider(plant_provider) World.register_work_provider(hauling_provider) + World.register_work_provider(cleaning) # priority 2 — between haul (3) and rest (0) World.register_work_provider(rest_provider) # Phase 5: bridge designation paint events → spawn the ghost-state entity @@ -139,6 +176,25 @@ func _ready() -> void: _spawn_sample_harvestables() _spawn_sample_stockpiles() _seed_phase5_demo_buildings() + # Phase 13 — pre-stamp the cabin walls + floors on the TileMap data layers + # so RoomDetector can see a completed enclosure at boot without waiting for + # pawns to finish the build queue. Mirrors the layout in _seed_phase5_demo_buildings. + _prestamp_cabin_for_room_detector() + _prestamp_test_shed_for_room_detector() + room_detector.recompute_around(Vector2i(47, 25)) + room_detector.recompute_around(Vector2i(36, 25)) # tiny shed centroid + + # Phase 13 — register existing prebuilt furniture with BeautySystem so it has + # a beauty score baseline at boot (the post-_complete hooks already fire for + # the cabin's beds/torches/workbenches, but pawns may path before the first + # recompute, so seed the map here). + for ws in World.workbenches: + beauty.register_furniture(ws) + for b in World.beds: + beauty.register_furniture(b) + for ls in World.light_sources: + beauty.register_furniture(ls) + beauty.recompute_all() _run_pathfinder_spike() # Phase 4: every 5 in-game seconds (100 ticks), re-evaluate items in @@ -527,6 +583,88 @@ func _apply_season_tint(season: StringName) -> void: Audit.log("world", "season tint → %s (%s)" % [season, tint]) +# ── Phase 13: demo seed room helper ───────────────────────────────────────── + +# Directly stamps the cabin perimeter walls and interior floors on the data +# TileMap layers so RoomDetector can detect the enclosure at boot. +# The ghost entities are already queued — this only writes the data layer +# (same as Wall._complete / Floor._complete call). Without this, +# RoomDetector would have to wait until pawns finish building the walls +# (many seconds of real time) before the first room is visible. +# +# Layout mirrors _seed_phase5_demo_buildings: 8×6 cabin at (44, 23), +# door at (47, 28), 6×4 wood floor interior. +func _prestamp_cabin_for_room_detector() -> void: + var origin := Vector2i(44, 23) + var w := 8 + var h := 6 + var door_x := w / 2 - 1 # 3 → tile x=47 + var door_tile := origin + Vector2i(door_x, h - 1) + + # Stamp perimeter walls (skipping the door slot). + for x in w: + World.mark_wall_tile(origin + Vector2i(x, 0), &"stone") + var bottom := origin + Vector2i(x, h - 1) + if bottom != door_tile: + World.mark_wall_tile(bottom, &"stone") + for y in range(1, h - 1): + World.mark_wall_tile(origin + Vector2i(0, y), &"stone") + World.mark_wall_tile(origin + Vector2i(w - 1, y), &"stone") + + # Stamp interior floors. + for x in range(1, w - 1): + for y in range(1, h - 1): + World.mark_floor_tile(origin + Vector2i(x, y), &"wood") + + # Stamp the door slot as a wall so the perimeter is fully closed and BFS + # terminates cleanly. Door._complete() erases this wall stamp and registers + # the door entity; the follow-up recompute_around picks it up as a boundary. + World.mark_wall_tile(door_tile, &"stone") + + Audit.log("world", "phase 13 demo: cabin walls+floors pre-stamped for RoomDetector") + + +# Tiny 5×5 walled shed with 3×3 interior (= 9 floor tiles) — under the 16-cap +# auto-roof threshold, so this room WILL roof. Used to exercise the indoor / +# shelter / room-thoughts pipeline without resizing the main cabin. +func _prestamp_test_shed_for_room_detector() -> void: + var origin := Vector2i(34, 23) # left of cabin, on the grass plain + var w := 5 + var h := 5 + # Perimeter walls — instantiate complete Wall entities so they're visible. + for x in w: + _spawn_complete_wall(origin + Vector2i(x, 0)) + _spawn_complete_wall(origin + Vector2i(x, h - 1)) + for y in range(1, h - 1): + _spawn_complete_wall(origin + Vector2i(0, y)) + _spawn_complete_wall(origin + Vector2i(w - 1, y)) + # Interior floors — instantiate complete Floor entities. + for x in range(1, w - 1): + for y in range(1, h - 1): + _spawn_complete_floor(origin + Vector2i(x, y)) + + Audit.log("world", "phase 13 demo: 5×5 test shed pre-built (interior 9 tiles, auto-roofed)") + + +# Instantiate a Wall entity in completed state at `tile`. Bypasses the build +# queue. Used by the Phase 13 test shed seed. +func _spawn_complete_wall(tile: Vector2i) -> void: + var w = WALL_SCENE.instantiate() + w.setup(tile, &"stone") + add_child(w) + w.build_progress = w.BUILD_TICKS + w.on_build_tick() # triggers _complete() and the data-layer stamp + room recompute + + +# Instantiate a Floor entity in completed state at `tile`. +func _spawn_complete_floor(tile: Vector2i) -> void: + var f = FLOOR_SCENE.instantiate() + f.setup(tile, &"wood") + add_child(f) + f.build_progress = f.BUILD_TICKS + f.on_build_tick() + + # ── spike: AStarGrid2D query timing at 80² ────────────────────────────────── func _run_pathfinder_spike() -> void: diff --git a/scenes/world/world.tscn b/scenes/world/world.tscn index ea53d7e..e3f4e70 100644 --- a/scenes/world/world.tscn +++ b/scenes/world/world.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=18 format=3 uid="uid://rimlike_world"] +[gd_scene load_steps=19 format=3 uid="uid://rimlike_world"] [ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"] [ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"] @@ -17,6 +17,7 @@ [ext_resource type="Script" path="res://scenes/ai/doctor_provider.gd" id="15_doctor_provider"] [ext_resource type="Script" path="res://scenes/ai/wolf_spawner.gd" id="16_wolf_spawner"] [ext_resource type="PackedScene" uid="uid://rimlike_rain_overlay" path="res://scenes/world/rain_overlay.tscn" id="17_rain_overlay"] +[ext_resource type="Script" path="res://scenes/world/room_detector.gd" id="18_room_detector"] [node name="World" type="Node2D"] y_sort_enabled = true @@ -90,6 +91,9 @@ script = ExtResource("15_doctor_provider") [node name="WolfSpawner" type="Node" parent="."] script = ExtResource("16_wolf_spawner") +[node name="RoomDetector" type="Node" parent="."] +script = ExtResource("18_room_detector") + [node name="WeatherLayer" type="CanvasLayer" parent="."] layer = 5