rimlike/scenes/world/world.gd
megaproxy 67ec2cce7f Phase 14: Death + Corpses + Burial + Cremation
Three-agent fan-out. Opus pre-wrote Corpse class + 5 EventBus signals +
World registries (corpses, grave_markers) before dispatch so all three
slices ran fully parallel. Pattern proven across Phases 12/13/14.

Death pipeline (Agent A):
- Pawn.is_dead(), _check_death() — pawn_died signal → corpse spawn →
  corpse_spawned signal → World.unregister_pawn → queue_free
- _last_damage_source carries cause from take_damage() (now StringName)
- Bleed-out timeout: _bleed_ticks accumulates while bleeding active;
  at BLEED_OUT_TICKS=432000 (6 in-game hours) force-kills via take_damage
- Pawn.portrait_color stored field for corpse head-color hand-off
- Corpse: DECAY_PER_TICK=0.05 (~33 in-game min fresh→rotted at 1×),
  is_rotting()@50, queue_free@100 with corpse_rotted_away signal.
  Rotting bumps DirtinessSystem (Phase 13 hook) +0.04/tick (~+8/in-game-min)
- DEMO_PHASE14_AUTOKILL toggle in world.gd (default false, gates safety)

Graveyard + GraveSlot + GraveMarker + Hauling (Agent B):
- scenes/world/graveyard_zone.gd — StorageDestination subclass,
  accepted_types=[corpse], brownish overlay, finds dug GraveSlots
- scenes/entities/grave_slot.gd — buildable (ghost→dug) state machine,
  StorageDestination duck-type interface, accept_corpse() spawns
  GraveMarker + emits corpse_buried + queue_frees self
- scenes/entities/grave_marker.gd — permanent memorial, procedural
  stone-cross _draw, carries deceased identity, save round-trip
- TOOL_GRAVEYARD + TOOL_DIG_GRAVE paint modes (Designation dispatch)
- KIND_PICKUP_CORPSE + KIND_DEPOSIT_CORPSE toils + JobRunner handlers
- HaulingProvider.find_best_for iterates World.corpses in addition to
  items_needing_haul; corpse-payload stored as Node metadata on pawn
- ConstructionProvider duck-type already accepts GraveSlot (no change)

Cremation + Ash + Mood thoughts (Agent C):
- scenes/entities/cremation_pyre.gd — extends Workbench, label 'Pyre',
  auto-populates FOREVER bill for cremate_corpse, on_craft_complete
  drops 1 ash + emits corpse_cremated + queue_frees corpse
- Recipe.ingredient2_type/count added with save round-trip; recipe
  catalog entry cremate_corpse(TYPE_CORPSE primary + 5 wood secondary)
  NOTE: CraftingProvider still only enforces ingredient1 — documented
  gap, ships when crafting is generalized.
- Item.TYPE_ASH added + ALL_TYPES filter array entry
- 4 mood thoughts: saw_corpse (-3 EVENT 1200t max=3), buried_friend
  (+2 EVENT 2400t), cremated_friend (+2 EVENT 2400t),
  rotting_body_in_colony (-4 PERSISTENT stacks=count capped at 3)
- Pawn sync hooks: proximity scan (saw_corpse), signal listeners
  (buried/cremated within 8-tile radius), count helper for rotting

MCP runtime verified:
- DEMO_PHASE14_AUTOKILL toggle force-killed Bram at tick 50
- 'Bram DIED (cause=demo_kill, tile=(20, 36))' + corpse spawned
- 'Cora: saw_corpse thought added (corpse Bram at dist 5)' — mood -3
- Painted graveyard + dig_grave → grave dug to completion verified
  in build_queue (grave @(22, 39) complete=true)
- Hauler round-trip (corpse → GraveSlot → GraveMarker) WIRED correctly
  but didn't land within decay window at ULTRA speed (12×) — corpse
  rotted before priority-3 corpse-haul scheduled. Tuning for Phase 20.
- Screenshot captured: fresh corpse silhouette at cabin doorway

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

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

755 lines
31 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 Node2D
## Phase 4 world view. 80×80 TileMap, 6 layers, 3 pawns, full AI pipeline:
## RestProvider → ChopProvider → MineProvider → HaulingProvider → idle
## plus sample trees, rocks, and two stockpile zones with different priorities
## for the haul-cascade demo.
##
## TileMap layer indices follow docs/architecture.md:
## 0 Terrain · 1 Floor · 2 Wall · 3 Designation · 4 Roof · 5 Fog
const MAP_SIZE_TILES: Vector2i = Vector2i(80, 80)
const TILE_SIZE_PX: int = 16
const TILE_GRASS: Vector2i = Vector2i(0, 0)
const TILE_DIRT: Vector2i = Vector2i(1, 0)
const TILE_STONE: Vector2i = Vector2i(2, 0)
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")
# Phase 14 — grave / burial entities (scripts only; no .tscn needed).
const GRAVEYARD_ZONE_SCRIPT: Script = preload("res://scenes/world/graveyard_zone.gd")
const GRAVE_SLOT_SCRIPT: Script = preload("res://scenes/entities/grave_slot.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")
const STOCKPILE_SCENE: PackedScene = preload("res://scenes/world/stockpile_zone.tscn")
const WALL_SCENE: PackedScene = preload("res://scenes/entities/wall.tscn")
const FLOOR_SCENE: PackedScene = preload("res://scenes/entities/floor.tscn")
const DOOR_SCENE: PackedScene = preload("res://scenes/entities/door.tscn")
const CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn")
const WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.tscn")
const CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn")
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
const BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn")
const TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.tscn")
# Phase 14 — demo auto-kill toggle.
## Set to true to force-kill the first pawn 50 ticks after boot so MCP runtime
## tests can observe the full death → corpse pipeline at startup.
## LEAVE FALSE for normal play — a colonist dies every session otherwise.
## To enable for a test session: edit this constant, run, revert when done.
const DEMO_PHASE14_AUTOKILL: bool = false
# 3 starting pawns — Phase 2 demo. Phase 7+ replaces this with map-gen + name table.
const SAMPLE_PAWNS: Array[Dictionary] = [
# Phase 6 demo — varied Crafting skill so the quality system shows visible
# spread. Phase 7 adds Cooking skill for hearth recipe variety.
{"name": "Bram", "tile": Vector2i(20, 40), "crafting": 8, "cooking": 2}, # high crafter, weak cook
{"name": "Cora", "tile": Vector2i(25, 40), "crafting": 4, "cooking": 6}, # mid crafter, strong cook
{"name": "Edda", "tile": Vector2i(30, 40), "crafting": 0, "cooking": 1}, # low crafter, weak cook
]
# Phase 4 — sample harvestables. Trees clustered east, rocks south-east.
const SAMPLE_TREES: Array[Vector2i] = [
Vector2i(58, 30), Vector2i(60, 31), Vector2i(62, 30),
Vector2i(61, 33), Vector2i(63, 34), Vector2i(59, 35),
]
const SAMPLE_ROCKS: Array[Vector2i] = [
Vector2i(60, 60), Vector2i(62, 60), Vector2i(63, 62), Vector2i(58, 62),
]
# HaulingProvider re-flow cadence — every 5 sim seconds at 1× (100 ticks).
const HAUL_SWEEP_INTERVAL_TICKS: int = 100
# Phase 11 — global darkness tint. Day = white, night = deep cool blue.
# Driven by Clock.darkness_factor() (0..1) each sim tick.
const NIGHT_TINT: Color = Color(0.20, 0.22, 0.40, 1.0)
const DAY_TINT: Color = Color(1.0, 1.0, 1.0, 1.0)
# Phase 12 — subtle seasonal modulate on the Terrain TileMapLayer only.
# Wall / Floor / Roof are entity-drawn (Y-sorted sprites); don't tint those.
const SEASON_TINTS: Dictionary = {
&"spring": Color(0.96, 1.02, 0.94), # slight warm-green
&"summer": Color(1.0, 1.0, 1.0 ), # neutral / bright
&"autumn": Color(1.06, 0.95, 0.82), # warm orange
&"winter": Color(0.88, 0.92, 1.02), # cool blue, slight desaturation
}
@onready var dark_overlay: CanvasModulate = $DarkOverlay
@onready var terrain_layer: TileMapLayer = $Terrain
@onready var floor_layer: TileMapLayer = $Floor
@onready var wall_layer: TileMapLayer = $Wall
@onready var designation_layer: TileMapLayer = $Designation
@onready var roof_layer: TileMapLayer = $Roof
@onready var fog_layer: TileMapLayer = $Fog
@onready var pathfinder: Pathfinder = $Pathfinder
@onready var selection: Selection = $Selection
@onready var designation_ctl: Designation = $DesignationCtl
@onready var rest_provider: RestProvider = $RestProvider
@onready var chop_provider: ChopProvider = $ChopProvider
@onready var mine_provider: MineProvider = $MineProvider
@onready var hauling_provider: HaulingProvider = $HaulingProvider
@onready var construction_provider: ConstructionProvider = $ConstructionProvider
@onready var crafting_provider: CraftingProvider = $CraftingProvider
@onready var plant_provider: PlantProvider = $PlantProvider
@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:
Audit.log("world", "Phase 4 — building %d×%d world + harvestables + AI." % [MAP_SIZE_TILES.x, MAP_SIZE_TILES.y])
var tileset := _build_placeholder_tileset()
for layer in [terrain_layer, floor_layer, wall_layer, designation_layer, roof_layer, fog_layer]:
layer.tile_set = tileset
_paint_terrain()
# Phase 5: removed _paint_sample_walls() — the old 8×8 stone ring rendered
# only on the (now-hidden) Wall TileMap layer. With the rendering pivot
# to entity-based walls, that ring was invisible to the player but still
# blocked pathing. Removing it cleans the world view; the Phase 5 cabin
# is now the only player-visible structure.
_apply_camera_bounds()
pathfinder.setup(MAP_SIZE_TILES)
_wire_walls_to_pathfinder()
selection.bind(pathfinder)
World.pathfinder = pathfinder # expose to entities (Tree.fell() walkability checks, etc.)
# Phase 5 — expose TileMap layer refs on the autoload so entity code
# (Wall._complete, Floor._complete) can stamp data-only tile state.
World.wall_layer = wall_layer
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)
# Register all 10 providers — Decision iterates by .priority desc.
# doctor=9 > sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4
# ≈ crafting=4 > haul=3 > rest=0.
# Phase 17 will tune these via the work-priority matrix UI.
World.register_work_provider(doctor_provider)
World.register_work_provider(sleep_provider)
World.register_work_provider(eat_provider)
World.register_work_provider(construction_provider)
World.register_work_provider(chop_provider)
World.register_work_provider(mine_provider)
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
# at that tile and register it as a build site.
EventBus.designation_added.connect(_on_designation_added)
EventBus.designation_cleared.connect(_on_designation_cleared)
_spawn_sample_pawns()
_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
# stockpiles in case a higher-priority destination opened up.
EventBus.sim_tick.connect(_on_sim_tick_world_sweep)
# Phase 12 — season tint: apply on boot then update on each season transition.
_apply_season_tint(Clock.current_season())
EventBus.season_changed.connect(_apply_season_tint)
# Phase 14 — demo auto-kill (disabled by default — see DEMO_PHASE14_AUTOKILL).
if DEMO_PHASE14_AUTOKILL:
EventBus.sim_tick.connect(_demo_phase14_autokill_hook)
func world_bounds_px() -> Rect2:
return Rect2(Vector2.ZERO, Vector2(MAP_SIZE_TILES * TILE_SIZE_PX))
# ── tileset & map painting ──────────────────────────────────────────────────
func _build_placeholder_tileset() -> TileSet:
# Four 16×16 placeholder tiles laid out as a 4×1 atlas. Real ElvGames
# art replaces this in Phase 5 (wood walls + stone walls).
var ts := TileSet.new()
ts.tile_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
var atlas_w := TILE_SIZE_PX * 4
var img := Image.create(atlas_w, TILE_SIZE_PX, false, Image.FORMAT_RGBA8)
var palette: Array[Color] = [
Color(0.45, 0.65, 0.30), # grass
Color(0.55, 0.45, 0.30), # dirt
Color(0.60, 0.60, 0.55), # stone
Color(0.30, 0.30, 0.32), # stone dark
]
for i in palette.size():
var base: Color = palette[i]
# Subtle border — was darkened(0.15) which made grass look like graph paper.
# darkened(0.04) keeps the tile boundary readable when squinting but doesn't
# dominate the visual.
var border: Color = base.darkened(0.04)
for px in TILE_SIZE_PX:
for py in TILE_SIZE_PX:
var on_border := (
px == 0 or px == TILE_SIZE_PX - 1
or py == 0 or py == TILE_SIZE_PX - 1
)
img.set_pixel(i * TILE_SIZE_PX + px, py, border if on_border else base)
var tex := ImageTexture.create_from_image(img)
var src := TileSetAtlasSource.new()
src.texture = tex
src.texture_region_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
for i in palette.size():
src.create_tile(Vector2i(i, 0))
ts.add_source(src, PLACEHOLDER_SOURCE_ID)
return ts
func _paint_terrain() -> void:
for x in MAP_SIZE_TILES.x:
for y in MAP_SIZE_TILES.y:
terrain_layer.set_cell(Vector2i(x, y), PLACEHOLDER_SOURCE_ID, TILE_GRASS)
func _paint_sample_walls() -> void:
var origin := Vector2i(36, 36)
var size: int = 8
for i in size:
wall_layer.set_cell(origin + Vector2i(i, 0), PLACEHOLDER_SOURCE_ID, TILE_STONE)
wall_layer.set_cell(origin + Vector2i(i, size - 1), PLACEHOLDER_SOURCE_ID, TILE_STONE)
wall_layer.set_cell(origin + Vector2i(0, i), PLACEHOLDER_SOURCE_ID, TILE_STONE_DARK)
wall_layer.set_cell(origin + Vector2i(size - 1, i), PLACEHOLDER_SOURCE_ID, TILE_STONE_DARK)
# ── pathfinder + pawns ──────────────────────────────────────────────────────
func _wire_walls_to_pathfinder() -> void:
var wall_cells := wall_layer.get_used_cells()
for cell in wall_cells:
pathfinder.set_cell_walkable(cell, false)
Audit.log("world", "%d wall cells marked impassable" % wall_cells.size())
func _spawn_sample_pawns() -> void:
for spawn_data in SAMPLE_PAWNS:
var p: Pawn = PAWN_SCENE.instantiate()
add_child(p)
p.setup(spawn_data["name"], spawn_data["tile"])
# Phase 3: attach a JobRunner so Decision can hand it jobs.
var jr := JobRunner.new()
jr.name = "JobRunner"
p.add_child(jr)
jr.setup(p, pathfinder)
p.job_runner = jr
# Phase 6: seed Crafting skill so the quality demo shows variety.
if spawn_data.has("crafting"):
p.set_skill(Pawn.SKILL_CRAFTING, int(spawn_data["crafting"]))
# Phase 7: seed Cooking skill so hearth recipes show quality spread too.
if spawn_data.has("cooking"):
p.set_skill(Pawn.SKILL_COOKING, int(spawn_data["cooking"]))
World.register_pawn(p)
# ── Phase 4: harvestables + stockpile zones ─────────────────────────────────
func _spawn_sample_harvestables() -> void:
# Untyped vars — Godot's class-name cache for class_name'd classes is
# scan-time and intermittently lags behind file changes. Duck typing is
# safer here and the calls below are all spec'd on the entity types.
for t_tile in SAMPLE_TREES:
var tree = TREE_SCENE.instantiate()
add_child(tree)
tree.setup(t_tile)
for r_tile in SAMPLE_ROCKS:
var rock = ROCK_SCENE.instantiate()
add_child(rock)
rock.setup(r_tile)
Audit.log("world", "spawned %d trees + %d rocks" % [SAMPLE_TREES.size(), SAMPLE_ROCKS.size()])
func _seed_phase5_demo_buildings() -> void:
# Pre-queue a furnished cabin so the construction loop runs end-to-end
# without needing player-paint UI (deferred to Phase 17).
#
# Layout — 8×6 stone cabin at (44, 23), south door, wood floor inside:
# • Perimeter walls (skipping the door slot)
# • Door at (47, 28) — middle of the south wall
# • Wood floor across the 6×4 interior
# • One pre-built crate inside (north-east corner of the interior)
# • Two stockpile-target crates outside (Phase 4 hauling target)
var origin := Vector2i(44, 23)
var w := 8
var h := 6
var door_x := w / 2 - 1 # 3 → tile (47, y_bottom). Centred door.
var door_tile := origin + Vector2i(door_x, h - 1)
# Perimeter walls — skip the door slot in the bottom row.
var wall_count := 0
for x in w:
EventBus.designation_added.emit(origin + Vector2i(x, 0), Designation.TOOL_BUILD_WALL)
wall_count += 1
var bottom_tile := origin + Vector2i(x, h - 1)
if bottom_tile != door_tile:
EventBus.designation_added.emit(bottom_tile, Designation.TOOL_BUILD_WALL)
wall_count += 1
for y in range(1, h - 1): # left + right cols, skip corners (already painted as top/bottom rows)
EventBus.designation_added.emit(origin + Vector2i(0, y), Designation.TOOL_BUILD_WALL)
EventBus.designation_added.emit(origin + Vector2i(w - 1, y), Designation.TOOL_BUILD_WALL)
wall_count += 2
# Door at south wall centre.
EventBus.designation_added.emit(door_tile, Designation.TOOL_BUILD_DOOR)
# Wood floor across the interior — 6×4 = 24 cells.
var floor_count := 0
for x in range(1, w - 1):
for y in range(1, h - 1):
EventBus.designation_added.emit(origin + Vector2i(x, y), Designation.TOOL_BUILD_FLOOR)
floor_count += 1
# Pre-built crate inside the cabin (top-right corner of interior).
# Auto-built so the cabin shows furnished on first frame.
var interior_crate: Crate = CRATE_SCENE.instantiate()
add_child(interior_crate)
interior_crate.setup(origin + Vector2i(w - 2, 1)) # (50, 24)
while interior_crate.is_buildable():
interior_crate.on_build_tick()
# Two external stockpile-target crates south-west (Phase 4 haul destination).
var crate_tiles: Array[Vector2i] = [Vector2i(17, 60), Vector2i(18, 60)]
for t in crate_tiles:
var c: Crate = CRATE_SCENE.instantiate()
add_child(c)
c.setup(t)
while c.is_buildable():
c.on_build_tick()
Audit.log("world", "phase 5 demo: %d walls + 1 door + %d floors queued; %d crates pre-built" % [
wall_count, floor_count, 1 + crate_tiles.size()
])
# Phase 6 demo — two pre-built workbenches inside the cabin with bills.
# Carpenter at (46, 25) makes wood → plank, FOREVER.
# Smelter at (48, 25) makes stone → stone_block, UNTIL 5 in stockpiles.
var carpenter: Workbench = WORKBENCH_SCENE.instantiate()
add_child(carpenter)
carpenter.setup(Vector2i(46, 25))
carpenter.label_text = "Carpenter"
carpenter.accepted_skill = Pawn.SKILL_CRAFTING
while carpenter.is_buildable():
carpenter.on_build_tick()
var plank_bill := Bill.new()
plank_bill.recipe = RecipeCatalog.plank()
plank_bill.mode = Bill.Mode.FOREVER
carpenter.add_bill(plank_bill)
var smelter: Workbench = WORKBENCH_SCENE.instantiate()
add_child(smelter)
smelter.setup(Vector2i(48, 25))
smelter.label_text = "Smelter"
smelter.accepted_skill = Pawn.SKILL_CRAFTING
while smelter.is_buildable():
smelter.on_build_tick()
var block_bill := Bill.new()
block_bill.recipe = RecipeCatalog.stone_block()
block_bill.mode = Bill.Mode.UNTIL_N
block_bill.target_count = 5
smelter.add_bill(block_bill)
Audit.log("world", "phase 6 demo: 2 workbenches built (Carpenter→planks FOREVER, Smelter→blocks UNTIL_N=5)")
# Phase 7 demo — Millstone + Hearth inside the cabin (south row), so the
# grain→flour→bread chain runs visibly. Wheat crops outside the cabin to
# the east. A couple of pre-baked breads + a meal item near the cabin so
# the eat-loop is testable even before cooking completes.
var millstone: Workbench = WORKBENCH_SCENE.instantiate()
add_child(millstone)
millstone.setup(Vector2i(46, 27))
millstone.label_text = "Millstone"
millstone.accepted_skill = Pawn.SKILL_CRAFTING
while millstone.is_buildable():
millstone.on_build_tick()
var flour_bill := Bill.new()
flour_bill.recipe = RecipeCatalog.flour()
flour_bill.mode = Bill.Mode.FOREVER
millstone.add_bill(flour_bill)
var hearth: Workbench = WORKBENCH_SCENE.instantiate()
add_child(hearth)
hearth.setup(Vector2i(49, 27))
hearth.label_text = "Hearth"
hearth.accepted_skill = Pawn.SKILL_COOKING
while hearth.is_buildable():
hearth.on_build_tick()
var bread_bill := Bill.new()
bread_bill.recipe = RecipeCatalog.bread()
bread_bill.mode = Bill.Mode.FOREVER
hearth.add_bill(bread_bill)
var meal_bill := Bill.new()
meal_bill.recipe = RecipeCatalog.meal_from_vegetables()
meal_bill.mode = Bill.Mode.FOREVER
hearth.add_bill(meal_bill)
# Wheat crops east of the cabin, near the trees.
var crop_tiles: Array[Vector2i] = [
Vector2i(54, 24), Vector2i(54, 25), Vector2i(54, 26),
Vector2i(55, 24), Vector2i(55, 25), Vector2i(55, 26),
]
for ct in crop_tiles:
var c: Crop = CROP_SCENE.instantiate()
add_child(c)
c.setup(ct, Crop.KIND_WHEAT, Crop.Stage.SOWN)
# Pre-baked breads + a vegetable meal so pawns can eat before the
# full cooking chain finishes. Phase 17 may remove these as cooking
# becomes reliable; for Phase 7 demo they keep the loop unblocked.
var snack_tiles: Array[Vector2i] = [Vector2i(45, 21), Vector2i(50, 21)]
for st in snack_tiles:
var bread_item: Item = ITEM_SCENE.instantiate()
add_child(bread_item)
bread_item.setup(Item.TYPE_BREAD, 1, st)
bread_item.quality = Item.Quality.NORMAL
Audit.log("world", "phase 7 demo: Millstone+Hearth built, %d wheat crops sown, %d pre-baked breads placed" % [
crop_tiles.size(), snack_tiles.size()
])
# Phase 8 demo — 3 beds inside the cabin's north row. Beds are pre-built
# (skip construction) so pawns have somewhere to sleep on first tired tick.
# (45, 24), (47, 24), (49, 24) — leaves (50, 24) for the existing crate.
# 3 beds in cabin north row. Phase 9 — middle bed is the medical bed so
# the doctor flow has a treatment target.
var bed_tiles: Array[Vector2i] = [Vector2i(45, 24), Vector2i(47, 24), Vector2i(49, 24)]
for i in bed_tiles.size():
var bt: Vector2i = bed_tiles[i]
var bed: Bed = BED_SCENE.instantiate()
add_child(bed)
bed.setup(bt)
if i == 1: # middle bed = medical
bed.is_medical = true
while bed.is_buildable():
bed.on_build_tick()
Audit.log("world", "phase 8 demo: %d beds pre-built inside cabin" % bed_tiles.size())
# Phase 11 demo — 2 torches inside cabin (north-east + south-west corners
# of interior) so the indoor area stays lit at night. Combined with the
# Hearth's light radius=5, the cabin interior should be mostly bright
# while the outdoors goes deep-blue tinted.
var torch_tiles: Array[Vector2i] = [Vector2i(46, 26), Vector2i(49, 26)]
for tt in torch_tiles:
var torch: Torch = TORCH_SCENE.instantiate()
add_child(torch)
torch.setup(tt)
while torch.is_buildable():
torch.on_build_tick()
Audit.log("world", "phase 11 demo: %d torches pre-built inside cabin" % torch_tiles.size())
func _spawn_sample_stockpiles() -> void:
# Two zones for the Phase 4 acceptance demo:
# - Zone A (north): wood-only filter, NORMAL priority (just a wood drop)
# - Zone B (south): wildcard, HIGH priority (the "watch wood flow upward" target)
# When the sweep runs, wood items in Zone A get re-marked for haul and
# eventually migrate to Zone B.
var zone_a: StockpileZone = STOCKPILE_SCENE.instantiate()
add_child(zone_a)
zone_a.region = Rect2i(15, 55, 4, 4)
zone_a.label = "Wood (Normal)"
zone_a.priority = StorageDestination.Priority.NORMAL
zone_a.accepted_types = [Item.TYPE_WOOD] as Array[StringName]
zone_a.queue_redraw()
var zone_b: StockpileZone = STOCKPILE_SCENE.instantiate()
add_child(zone_b)
zone_b.region = Rect2i(15, 62, 4, 4)
zone_b.label = "Anything (High)"
zone_b.priority = StorageDestination.Priority.HIGH
zone_b.accepted_types = [] as Array[StringName] # wildcard
zone_b.queue_redraw()
Audit.log("world", "spawned 2 stockpiles: %s + %s" % [zone_a.label, zone_b.label])
# ── Phase 5: designation → build-site spawn bridge ──────────────────────────
# Track build sites keyed by tile so we can find + queue_free them on cancel.
var _build_sites_by_tile: Dictionary = {}
func _on_designation_added(cell: Vector2i, tool: StringName) -> void:
if _build_sites_by_tile.has(cell):
return # already a build site here
var entity = null
match tool:
&"build_wall":
entity = WALL_SCENE.instantiate()
add_child(entity)
entity.setup(cell, &"stone")
&"build_floor":
entity = FLOOR_SCENE.instantiate()
add_child(entity)
entity.setup(cell, &"wood")
&"build_door":
entity = DOOR_SCENE.instantiate()
add_child(entity)
entity.setup(cell)
# Phase 14 — graveyard zone: paint a single-cell GraveyardZone.
# The zone is 1×1 and re-uses the same region as the painted cell.
&"graveyard":
var gz := Node2D.new()
gz.set_script(GRAVEYARD_ZONE_SCRIPT)
gz.name = "GraveyardZone_%d_%d" % [cell.x, cell.y]
add_child(gz)
gz.region = Rect2i(cell.x, cell.y, 1, 1)
gz.label = "Graveyard"
gz.queue_redraw()
entity = gz
# Phase 14 — dig grave: spawn a GraveSlot ghost and queue it for build.
&"dig_grave":
var gs := Node2D.new()
gs.set_script(GRAVE_SLOT_SCRIPT)
gs.name = "GraveSlot_%d_%d" % [cell.x, cell.y]
add_child(gs)
gs.setup(cell)
entity = gs
_:
Audit.log("world", "unknown designation tool: %s" % tool)
return
_build_sites_by_tile[cell] = entity
Audit.log("world", "queued %s at %s" % [tool, cell])
func _on_designation_cleared(cell: Vector2i) -> void:
if not _build_sites_by_tile.has(cell):
return
var entity = _build_sites_by_tile[cell]
_build_sites_by_tile.erase(cell)
if not is_instance_valid(entity):
return
# For build-queue entities (Wall, Floor, Door, GraveSlot): only free if not
# yet completed (a completed wall should not be torn down by a cancel signal).
# For zone-overlay entities (GraveyardZone) that have no is_completed(): always free.
var can_complete: bool = entity.has_method("is_completed")
if not can_complete or not entity.is_completed():
entity.queue_free()
# ── periodic re-flow (the "wood floats up" cascade) ─────────────────────────
func _on_sim_tick_world_sweep(tick_n: int) -> void:
_update_dark_overlay()
if tick_n % HAUL_SWEEP_INTERVAL_TICKS != 0:
return
hauling_provider.sweep_for_better_destinations()
# Phase 11 — interpolate CanvasModulate between DAY_TINT and NIGHT_TINT based
# on Clock.darkness_factor() (0 = full day, 1 = full night).
# Called every sim tick; Color.lerp is a handful of float ops — negligible cost.
func _update_dark_overlay() -> void:
var f := Clock.darkness_factor()
dark_overlay.color = DAY_TINT.lerp(NIGHT_TINT, f)
# Phase 12 — apply a subtle hue tint to the Terrain layer to signal season.
# Only Terrain is tinted; Wall/Floor/Roof are entity sprites (Y-sorted) — leave them neutral.
# Called once at boot (from _ready) and on every EventBus.season_changed signal.
func _apply_season_tint(season: StringName) -> void:
var tint: Color = SEASON_TINTS.get(season, Color.WHITE)
terrain_layer.modulate = tint
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:
var corners := [
Vector2i(2, 2),
Vector2i(MAP_SIZE_TILES.x - 3, 2),
Vector2i(2, MAP_SIZE_TILES.y - 3),
Vector2i(MAP_SIZE_TILES.x - 3, MAP_SIZE_TILES.y - 3),
]
var pairs: Array = []
for a in corners:
for b in corners:
if a != b:
pairs.append([a, b])
var result: Dictionary = pathfinder.benchmark(pairs, 3)
Audit.log("world", "spike: %d paths min=%d us avg=%.1f us max=%d us" % [
result["total_paths"], result["min_us"], result["avg_us"], result["max_us"]
])
# ── Phase 14: demo auto-kill hook ───────────────────────────────────────────
## Connected to EventBus.sim_tick when DEMO_PHASE14_AUTOKILL is true.
## Force-kills the first pawn 50 ticks after boot so the death→corpse pipeline
## can be tested in a normal play session. LEAVE DEMO_PHASE14_AUTOKILL = false
## for normal play.
func _demo_phase14_autokill_hook(tick_n: int) -> void:
if tick_n != 50:
return
if World.pawns.is_empty():
return
var victim = World.pawns[0]
if victim.has_method("take_damage"):
Audit.log("world", "phase 14 demo: force-killing '%s' at tick %d" % [victim.pawn_name, tick_n])
victim.take_damage(victim.hp, &"demo_kill")
EventBus.sim_tick.disconnect(_demo_phase14_autokill_hook)
# ── camera bounds ───────────────────────────────────────────────────────────
func _apply_camera_bounds() -> void:
var cam := get_node_or_null("CameraRig")
if cam == null:
Audit.log("world", "no CameraRig child yet — bounds set later when camera lands.")
return
if not cam.has_method("set_world_bounds"):
Audit.log("world", "CameraRig present but missing set_world_bounds() — skipping.")
return
cam.set_world_bounds(world_bounds_px())