rimlike/autoload/world.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:55 +01:00

560 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Node
## Runtime entity registry + tile-related sim state.
##
## All gameplay entities (pawns, items, furniture, animals, corpses) live here.
## TileMap data is owned by the world-view scene; World holds the *indirect*
## state (designation queue, dirty-haul set, zone records, etc.) that doesn't
## belong on the TileMap itself.
##
## See docs/architecture.md.
# Phase 2 — pawn registry. items/furniture/animals/corpses arrive in later phases.
var pawns: Array[Pawn] = []
# Phase 3 — work providers (e.g. RestProvider, ChopProvider, HaulingProvider).
# World scene registers them on _ready. Decision.pick_next_job() iterates by .priority desc.
var work_providers: Array = []
# Phase 4 — harvestables + items + stockpiles. Entities call register_*/unregister_*
# from their _ready/_exit_tree. Phase 16 will add stable IDs and persistence wiring.
var trees: Array = [] # Array of Tree
var rocks: Array = [] # Array of Rock
var big_rock_nodes: Array = [] # Array of BigRockNode (permanent stone outcrops)
var items: Array = [] # Array of Item (on-floor stacks)
var stockpiles: Array = [] # Array of StorageDestination (StockpileZone for now; containers Phase 5)
# Phase 4 — pathfinder reference exposed for entity code that needs walkability
# checks (e.g. Tree.fell() picking neighbour tiles for wood drops). The actual
# Pathfinder node lives on the World scene as a child; the scene sets this in
# its _ready(). Don't access before the world scene is mounted.
var pathfinder = null
## Reference to the in-scene Designation controller. Wired by world.gd _ready
## so entities completing a job can call World.clear_designation_at(tile) to
## remove the lingering ghost paint without depending on the scene tree path.
var designation_ctl = null
# Phase 5 — build queue. Holds Wall/Floor/Door/Crate ghost entities (not yet
# completed). ConstructionProvider iterates this for the nearest buildable site.
# Entities call register_build_site() in _ready and unregister_build_site() when
# they finish or are cancelled.
var build_queue: Array = []
# Phase 5 — completed Door entities, keyed for future open/close logic.
# Door._complete() calls register_door(); Phase 7+ uses this for toggling.
var doors: Array = []
# Phase 6 — workbench entities. Workbench._ready() calls register_workbench();
# _exit_tree() calls unregister_workbench(). CraftingProvider iterates this
# to find bench+bill pairs for eligible pawns.
var workbenches: Array = []
# Phase 7 — crop entities. Crop._ready() calls register_crop();
# _exit_tree() calls unregister_crop(). PlantProvider iterates this to find
# harvestable (READY) and sowable (TILLED) crops for eligible pawns.
var crops: Array = []
# Phase 8 — bed entities. Bed._ready() calls register_bed();
# _exit_tree() calls unregister_bed(). SleepProvider iterates this to find
# available (completed, unoccupied) beds for tired pawns.
# Storyteller also reads beds.size() for the "First Beds" state predicate.
var beds: Array = []
# Phase 11 — light-source entities (Torch + Hearth workbench). Entities call
# register_light_source() in _ready and unregister_light_source() in _exit_tree.
# is_tile_lit() is queried by the "in darkness" thought and any future
# darkness-rendering shader bridge. All entries expose the duck-type interface:
# is_on() → bool | get_light_tile() → Vector2i | get_light_radius() → int
var light_sources: Array = []
# Phase 10 — wolf entities. Wolf._ready() calls register_wolf();
# Wolf._exit_tree() calls unregister_wolf(). WolfSpawner reads/writes
# nothing from this array directly — it only add_child()s new wolves.
# CombatSystem (Phase 10) will iterate this for threat detection.
# Untyped array — avoids class_name ordering window (Phase 2 gotcha).
var wolves: Array = []
# Phase 14 — corpse entities + grave markers. Corpse._ready() calls
# register_corpse() / unregister_corpse() (on rot, burial, or cremation).
# GraveMarker is the permanent post-burial entity; markers persist for the
# duration of the save.
var corpses: Array = []
var grave_markers: Array = []
# Phase 4 — hauling dirty set. Keys are Items, value is unused (we just use .keys()).
# An Item is added when it spawns (Tree.fell, Rock.mined, workbench drop, ...)
# and removed when it lands at its highest-priority valid destination.
# HaulingProvider.sweep_for_better_destinations() re-marks items when a higher
# priority stockpile opens up (the priority cascade per design.md).
var items_needing_haul: Dictionary = {}
## Clear the designation ghost at `tile`, if any. Entities call this from
## their _complete / fell / mined handlers so the visual highlight disappears
## once the job is done. Safe no-op if designation_ctl isn't wired (headless).
func clear_designation_at(tile: Vector2i) -> void:
if designation_ctl != null:
designation_ctl.clear_cell(tile)
func register_work_provider(wp) -> void:
assert(wp != null, "World.register_work_provider: provider is null")
if not work_providers.has(wp):
work_providers.append(wp)
func clear_work_providers() -> void:
work_providers.clear()
func register_pawn(p: Pawn) -> void:
assert(p != null, "World.register_pawn: pawn is null")
if pawns.has(p):
return
pawns.append(p)
func unregister_pawn(p: Pawn) -> void:
pawns.erase(p)
func pawn_at_tile(tile: Vector2i) -> Pawn:
for p in pawns:
if p.tile == tile:
return p
return null
## Returns the Workbench occupying `tile`, or null if none. Used by Selection
## to route taps on a workbench to the bill-editor panel.
func workbench_at_tile(tile: Vector2i):
for w in workbenches:
if w.tile == tile:
return w
return null
func clear_pawns() -> void:
# For save-load / new-game flow in Phase 16.
pawns.clear()
## Phase 16 — wipe every entity registry on the autoload AND queue_free the
## live Nodes inside the World scene so apply_save can rebuild from scratch.
## The World *scene* (TileMapLayers, providers, autoloads) survives — only the
## per-entity registrations + their Node2D children get cleared.
##
## Safe to call between sim ticks. Called by SaveSystem.apply_save.
func clear_all() -> void:
# 1. queue_free every registered entity Node so it leaves the tree.
var to_free: Array = []
to_free.append_array(pawns)
to_free.append_array(items)
to_free.append_array(trees)
to_free.append_array(rocks)
to_free.append_array(build_queue) # ghost entities
to_free.append_array(doors)
to_free.append_array(workbenches)
to_free.append_array(crops)
to_free.append_array(beds)
to_free.append_array(light_sources)
to_free.append_array(wolves)
to_free.append_array(corpses)
to_free.append_array(grave_markers)
to_free.append_array(stockpiles)
for ent in to_free:
if ent != null and is_instance_valid(ent) and ent.has_method("queue_free"):
ent.queue_free()
# 2. Clear every registry (entity _exit_tree handlers will also try to erase
# themselves; double-clear is harmless on a Dictionary/Array).
pawns.clear()
items.clear()
trees.clear()
rocks.clear()
build_queue.clear()
doors.clear()
workbenches.clear()
crops.clear()
beds.clear()
light_sources.clear()
wolves.clear()
corpses.clear()
grave_markers.clear()
stockpiles.clear()
items_needing_haul.clear()
rooms.clear()
# BeautySystem / DirtinessSystem maps survive (their owning Nodes do too);
# their .clear_all() helpers (if any) live on the system itself.
Audit.log("world", "clear_all: registries wiped, %d nodes queue_freed" % to_free.size())
# ── Phase 4: harvestables + items + stockpiles ──────────────────────────────
func register_tree(t) -> void:
if not trees.has(t):
trees.append(t)
func unregister_tree(t) -> void:
trees.erase(t)
func register_rock(r) -> void:
if not rocks.has(r):
rocks.append(r)
func unregister_rock(r) -> void:
rocks.erase(r)
func register_big_rock_node(n) -> void:
if not big_rock_nodes.has(n):
big_rock_nodes.append(n)
func unregister_big_rock_node(n) -> void:
big_rock_nodes.erase(n)
## Returns the BigRockNode whose 2×2 footprint covers `tile`, or null.
## Used by `paint_quarry` designation to validate the build site.
func big_rock_node_at_tile(tile: Vector2i):
for n in big_rock_nodes:
if n.is_at(tile):
return n
return null
func register_item(it) -> void:
if items.has(it):
return
items.append(it)
# Newly-spawned items always start as "needs haul" — HaulingProvider will
# clear the flag once the item lands in its highest-priority destination.
items_needing_haul[it] = true
func unregister_item(it) -> void:
items.erase(it)
items_needing_haul.erase(it)
func register_stockpile(s) -> void:
if not stockpiles.has(s):
stockpiles.append(s)
EventBus.stockpile_layout_changed.emit()
func unregister_stockpile(s) -> void:
stockpiles.erase(s)
EventBus.stockpile_layout_changed.emit()
func mark_item_needs_haul(it) -> void:
items_needing_haul[it] = true
func clear_item_haul_flag(it) -> void:
items_needing_haul.erase(it)
# ── Phase 5: build queue + tile-data stamping for walls / floors ────────────
func register_build_site(entity) -> void:
if not build_queue.has(entity):
build_queue.append(entity)
func unregister_build_site(entity) -> void:
build_queue.erase(entity)
func register_door(d) -> void:
if not doors.has(d):
doors.append(d)
func unregister_door(d) -> void:
doors.erase(d)
func register_workbench(wb) -> void:
if not workbenches.has(wb):
workbenches.append(wb)
func unregister_workbench(wb) -> void:
workbenches.erase(wb)
func register_crop(c) -> void:
if not crops.has(c):
crops.append(c)
func unregister_crop(c) -> void:
crops.erase(c)
func register_bed(b) -> void:
if not beds.has(b):
beds.append(b)
func unregister_bed(b) -> void:
beds.erase(b)
# ── Phase 11: light-source registry ────────────────────────────────────────
func register_light_source(ls) -> void:
if not light_sources.has(ls):
light_sources.append(ls)
func unregister_light_source(ls) -> void:
light_sources.erase(ls)
# ── Phase 10: wolf registry ────────────────────────────────────────────────
func register_wolf(w) -> void:
if not wolves.has(w):
wolves.append(w)
func unregister_wolf(w) -> void:
wolves.erase(w)
# ── Phase 14: corpses + grave markers ───────────────────────────────────────
func register_corpse(c) -> void:
if not corpses.has(c):
corpses.append(c)
func unregister_corpse(c) -> void:
corpses.erase(c)
func register_grave_marker(gm) -> void:
if not grave_markers.has(gm):
grave_markers.append(gm)
func unregister_grave_marker(gm) -> void:
grave_markers.erase(gm)
## Returns the first Corpse covering `tile`, or null.
func corpse_at_tile(tile: Vector2i):
for c in corpses:
if c.tile == tile:
return c
return null
## Returns true if `tile` is within get_light_radius() of any is_on() light
## source. Uses Manhattan distance (no wall-occlusion in Phase 11; Phase 13
## may add BFS-based occlusion through the room/roof system).
##
## Called by the "in darkness" Thought trigger on each pawn sim tick.
## O(light_sources) per call; trivial at our scale (< 50 sources in MVP).
func is_tile_lit(p_tile: Vector2i) -> bool:
for ls in light_sources:
if not ls.is_on():
continue
var d: int = abs(ls.get_light_tile().x - p_tile.x) + abs(ls.get_light_tile().y - p_tile.y)
if d <= ls.get_light_radius():
return true
return false
# Called by Wall.on_build_tick() when construction completes.
# Stamps the data-only Wall TileMap layer so room/roof/save logic sees the
# wall. World scene exposes wall_layer via a getter set during _ready.
var terrain_layer = null
var wall_layer = null
var floor_layer = null
var designation_layer = null
var roof_layer = null
func mark_wall_tile(tile: Vector2i, material: StringName) -> void:
if wall_layer == null:
Audit.log("world", "mark_wall_tile: layer not yet wired — skipping")
return
# Atlas coord encodes material — for Phase 5 placeholder atlas:
# stone → (2, 0), dark stone → (3, 0)
# 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:
if floor_layer == null:
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.
# Used by JobRunner._tick_deposit (Phase 5 refactor) to route deposits into
# Crate contents when applicable.
func stockpile_at_tile(tile: Vector2i):
for sp in stockpiles:
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
# ── Phase 16: TileMap layer serialisation helpers ────────────────────────────
## Serialize the five sim-relevant TileMap layers (Terrain, Floor, Wall,
## Designation, Roof) into a plain Dictionary. Fog layer is runtime-only and
## is NOT serialized.
##
## Returns:
## {
## "terrain": [{x, y, source_id, atlas_x, atlas_y}, ...],
## "floor": [...],
## "wall": [...],
## "designation": [...],
## "roof": [...],
## }
##
## Called by SaveSystem.build_save() between sim ticks.
func save_tilemap_layers() -> Dictionary:
var result: Dictionary = {}
var layer_map: Dictionary = {
"terrain": terrain_layer,
"floor": floor_layer,
"wall": wall_layer,
"designation": designation_layer,
"roof": roof_layer,
}
for layer_name in layer_map:
var layer = layer_map[layer_name]
var entries: Array = []
if layer == null:
result[layer_name] = entries
continue
for cell in layer.get_used_cells():
var src_id: int = layer.get_cell_source_id(cell)
var atlas: Vector2i = layer.get_cell_atlas_coords(cell)
entries.append({
"x": cell.x,
"y": cell.y,
"source_id": src_id,
"atlas_x": atlas.x,
"atlas_y": atlas.y,
})
result[layer_name] = entries
return result
## Restore the five TileMap layers from a dict produced by save_tilemap_layers().
## Clears each layer first, then stamps every saved cell.
##
## Called by SaveSystem.apply_save() before respawning entities.
func apply_tilemap_layers(d: Dictionary) -> void:
var layer_map: Dictionary = {
"terrain": terrain_layer,
"floor": floor_layer,
"wall": wall_layer,
"designation": designation_layer,
"roof": roof_layer,
}
for layer_name in layer_map:
var layer = layer_map[layer_name]
if layer == null:
continue
layer.clear()
for entry in d.get(layer_name, []):
if not entry is Dictionary:
continue
var cell := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0)))
var src_id: int = int(entry.get("source_id", 0))
var atlas := Vector2i(int(entry.get("atlas_x", 0)), int(entry.get("atlas_y", 0)))
layer.set_cell(cell, src_id, atlas)