rimlike/scenes/world/dirtiness_system.gd
megaproxy 19d28ca9f8 Phase 16: Save/load full coverage + autosave + UI
Three-agent fan-out reusing the contracts-first pattern: Opus pre-wrote
World.clear_all + 4 EventBus signals (save_started/finished, load_started/
finished) before dispatch. Pattern proven across Phases 12/13/14/15/16.

Entity to_dict/from_dict + class_id tagging (Agent A):
- class_id tag added to all 18 entity to_dict methods for loader routing
- Missing pairs filled in: wolf, grave_slot, graveyard_zone, stockpile_zone,
  crate (from_dict). All defensive with d.get(field, default).
- Workbench round-trips label_text so Carpenter/Smelter/Millstone/Hearth/
  Pyre kinds survive reload
- BeautySystem + DirtinessSystem save_dict/apply_dict for sparse maps
- World.save_tilemap_layers / apply_tilemap_layers covering 5 layers
  (Terrain/Floor/Wall/Designation/Roof; Fog runtime-only skipped)

SaveSystem v2 rewrite (Agent B):
- SAVE_VERSION bumped from 1 to 2
- write_save(slot) pauses Sim, emits save_started, collects every entity
  via _collect_entities iterating all World registries, writes payload to
  user://save_<slot>.json
- apply_save full rewrite: pause sim → emit load_started → World.clear_all
  → apply autoloads (GameState/Clock/Weather/Storyteller) → apply tilemap
  layers → iterate payload.entities and dispatch to per-class factories
  → apply beauty/dirt maps → emit load_finished(slot, ok, real_seconds_away)
- Per-class factory registry: 18 class_ids dispatched to setup+add_child+
  from_dict patterns. CremationPyre detected via workbench.label_text == 'Pyre'
- Public slot API: save_to_slot/load_from_slot/has_save/delete_save/
  peek_save_metadata. Slots locked: &manual + &autosave

Autosave + UI + Resume toast (Agent C):
- autoload/autosave.gd — new Autosave autoload. Periodic every
  AUTOSAVE_INTERVAL_TICKS = 6000 (~5 in-game min at 20 Hz) + NOTIFICATION_
  APPLICATION_PAUSED (mobile) + NOTIFICATION_WM_WINDOW_FOCUS_OUT (desktop).
  Gated by _busy flag tied to EventBus.save_started/save_finished.
- TopBar extended with SaveBtn (💾) + LoadBtn buttons, 48×48 min hit area
- scenes/ui/load_menu.gd — CanvasLayer slot picker. Reads peek_save_metadata
  to show 'Manual save (Date Time)' / 'Autosave (Date Time)' rows.
  Version-mismatch warning dialog before continuing on older saves.
- scenes/ui/resume_toast.gd — top-center toast. On load_finished(ok=true):
  'Welcome back — N minutes/hours away' for 5s + 0.8s fade.
  On ok=false: 'Load failed (corrupt or version mismatch)'.
- Strings catalog: 14 new keys (ui.save / ui.load / ui.welcome_back_* /
  ui.load_failed etc.)
- main.gd mounts LoadMenu + ResumeToast as runtime CanvasLayer children

MCP runtime verified:
- Saved at tick 1137 → [save] wrote slot 'manual': 113 entities at tick 1137
- Advanced sim to tick 4600 at ULTRA speed (different state)
- load_from_slot(&manual) → [save] applied slot 'manual': 113 entities,
  0 errors, tick=1137, away=34s
- post-load: Sim.tick=1137 (restored), pawns alive=3, all furniture +
  workbenches + crops + walls + floors back in place
- Resume toast fires: [resume_toast] showing — ok=true seconds_away=34
- Autosave on focus-loss verified: [autosave] focus-loss → wrote autosave
- Screenshot shows TopBar with Save + Load buttons + post-load Lone Wolf
  storyteller modal from fresh dawn roll

Known acceptable gaps (deferred to Phase 20 tuning):
- Pawn JobRunner mid-INTERACT/mid-BUILD restarts from toil 0 on reload
  (walk toil round-trips; multi-step interact does not). Pawns lose a few
  seconds of work.
- Workbench bill mid-craft fetch state isn't fully serialized.
- Wolf.target_pawn re-resolution from name string is Agent A's documented
  pattern; Agent B's apply_save respects pawn-restoration ordering so the
  resolution works after pawns are back.

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

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

132 lines
5.3 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.

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)
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise the sparse dirt map as an array of {x, y, v} dicts.
## Only non-zero tiles are stored (map is already sparse).
func save_dict() -> Dictionary:
var entries: Array = []
for t in dirt_map:
entries.append({"x": t.x, "y": t.y, "v": float(dirt_map[t])})
return {"dirtiness": entries}
## Restore the dirt map from a dict produced by save_dict().
## Replaces the current map in full.
func apply_dict(d: Dictionary) -> void:
dirt_map.clear()
for entry in d.get("dirtiness", []):
if entry is Dictionary:
var t := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0)))
var v: float = clampf(float(entry.get("v", 0.0)), 0.0, 100.0)
if v > 0.0:
dirt_map[t] = v
# ── 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