Phase 13: Rooms + Auto-roof + Beauty + Dirtiness + Cleaning

Three-agent fan-out — Opus pre-wrote Room class, World.rooms/room_at_tile/is_indoor,
4 EventBus signals before dispatch so the slices ran fully parallel.

DECISION: Big-room UX = bump auto-roof cap to 16, banner above. Cabin
(24 tiles) intentionally exceeds cap to exercise the warning path; a
5×5 test shed (9 interior tiles) was added to exercise the roof path.

Room detection (Agent A):
- scenes/world/room.gd — class_name Room, tiles/bounds/is_under_roof,
  contains_tile() bounds-then-list-checked, recompute_bounds()
- scenes/world/room_detector.gd — class_name RoomDetector, BFS 4-dir
  from floor/door tiles, walls/terrain as boundary, doors counted as
  room interior. Detects up to 4× cap; auto-roofs only ≤16.
- World.mark_wall_tile/mark_floor_tile/mark_door_tile hook BFS recompute
- Door._complete() now erases wall-layer stamp + registers door tile
- Designation.TOOL_NO_ROOF paint mode wired (UI button deferred Phase 17)
- EventBus.room_changed / room_too_large signals

Indoor/Shelter (Agent B):
- Pawn._is_sheltered() rerouted: World.is_indoor() first, floor-proxy fallback
- IndoorTintOverlay Node2D — _draw fills roofed-room tiles at α=0.10 warm
- Crop._on_sim_tick skips stage advance when World.is_indoor(tile)

Beauty + Dirtiness + Cleaning + Room thoughts (Agent C):
- BeautySystem sparse map, linear falloff radius=3, Quality multiplier
  (SHODDY 0.5 → LEGENDARY 2.5). Base: Bed +2, Workbench +1, Torch +3, Hearth +4
- DirtinessSystem 0-100, tier crossings (clean<25/dirty<60/filthy≥60)
  emit tile_dirtiness_changed. bump/bump_clean/bump_pawn_traffic API
- CleaningProvider priority=2, KIND_CLEAN toil, 2.5 dirt/tick for ~40 ticks
- Bed/Torch/Workbench _complete() now register with BeautySystem
- 7 room mood thoughts: clean_room (+2), dirty_room (-3), filthy_room (-6),
  beautiful_room (+4), ugly_room (-3), slept_in_room (+3 EVENT, wires Ph 17),
  ate_without_table (-3 EVENT, wires Ph 17)
- Pawn._sync_room_thoughts called from _process_thoughts after cold block,
  defensive against null rooms/systems

Integration recovery (Opus):
- Agent C's BeautySystem/DirtinessSystem/CleaningProvider/IndoorTintOverlay
  instantiation in world.gd never landed (only field declarations + entity
  hooks survived). Added preloads + runtime add_child + autoload bindings +
  CleaningProvider registration + furniture pre-seed in _ready
- Added _prestamp_test_shed_for_room_detector with _spawn_complete_wall/floor
  helpers so a 5×5 visible shed exercises the auto-roof path at boot

MCP runtime verified:
- Rooms: cabin Room#2 size=24 roofed=false (room_too_large fires),
  shed Room#3 size=9 roofed=true (auto-roof active)
- beauty_map size=50 around prebuilt furniture; bed at (47,24) beauty=4.0
- Bram teleported to (36, 25) in shed → indoor=true, sheltered=true,
  thoughts=[clean_room +2], mood=52.0
- Screenshot: shed walls + brown floor visible; cabin warmly torch-lit;
  Spring 1/12 indicator; Day 1 07:52

Delegation: 3× gdscript-refactor (Sonnet) agents in parallel;
integration recovery + MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-11 17:19:23 +01:00
parent 92f4e5c945
commit 9cf9b7dbfd
28 changed files with 1286 additions and 28 deletions

View file

@ -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

View file

@ -0,0 +1 @@
uid://d3gw4tpsuvwiu

View file

@ -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.

View file

@ -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).

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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 ─────────────────────────────────────────

View file

@ -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 ─────────────────────────────────────────────────────────────

View file

@ -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

View file

@ -0,0 +1 @@
uid://bc7s1jr1y01o

View file

@ -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])

View file

@ -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

View file

@ -0,0 +1 @@
uid://dtgne6uq4wdsf

View file

@ -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)

View file

@ -0,0 +1 @@
uid://bg0fqaxphkieo

63
scenes/world/room.gd Normal file
View file

@ -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)

1
scenes/world/room.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://bpcf16gi3g05d

View file

@ -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

View file

@ -0,0 +1 @@
uid://e16fao7xi26a

View file

@ -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:

View file

@ -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