rimlike/scenes/world/beauty_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

203 lines
7.6 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 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
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise the sparse beauty 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 beauty_map:
entries.append({"x": t.x, "y": t.y, "v": float(beauty_map[t])})
return {"beauty": entries}
## Restore the beauty map from a dict produced by save_dict().
## Replaces the current map; furniture list is NOT restored here (entities
## re-register themselves when their nodes are re-added by the loader).
func apply_dict(d: Dictionary) -> void:
beauty_map.clear()
for entry in d.get("beauty", []):
if entry is Dictionary:
var t := Vector2i(int(entry.get("x", 0)), int(entry.get("y", 0)))
var v: float = float(entry.get("v", 0.0))
if v != 0.0:
beauty_map[t] = v
## 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