rimlike/scenes/pawn/pawn.gd
megaproxy 0cd7f809a7 Phase 6 — Recipe / Bill / Workbench / CraftingProvider / Quality / Skills
Three gdscript-refactor agents in parallel; Opus did integration + caught
the wall-trap bug via MCP runtime test.

Data layer (Agent A):
- scenes/ai/recipe.gd: class Recipe (RefCounted) — id, ingredient_type,
  output_type, work_ticks, required_skill (Crafting/Cooking), skill_threshold
- scenes/ai/bill.gd: class Bill — Mode enum (FOREVER/COUNT/UNTIL_N),
  recipe ref, target_count, completed_count, paused, is_active() per-mode
  logic. UNTIL_N walks World.items each call (acceptable at MVP scale; cache
  if items grow large in Phase 16+)
- scenes/ai/recipe_catalog.gd: RecipeCatalog.plank() / .stone_block() — 2
  starter recipes; Phase 7+ expands toward the design.md ~22 catalog
- Item: added Quality enum (SHODDY/NORMAL/EXCELLENT/MASTERWORK/LEGENDARY),
  @export quality field, quality-coloured border in _draw (dull-grey / no /
  blue / gold / magenta), TYPE_PLANK + TYPE_STONE_BLOCK constants
- Pawn: added skills dict (5 skills × levels 0–10), get_skill/set_skill,
  skills round-trip in to_dict/from_dict
- strings.gd: item.plank, item.stone_block, quality.* (5 keys)

Workbench entity (Agent B, scenes/entities/workbench.{gd,tscn}, ~310 lines):
- class Workbench extends Node2D, bottom-anchored 3/4 perspective like Wall
- BuildJob interface (is_buildable / on_build_tick / _complete) — same
  pattern as Wall / Crate
- Bills queue (add_bill, find_active_bill matches by accepted_skill)
- Craft cycle hooks: begin_craft / tick_craft / on_craft_complete /
  on_craft_interrupted — JobRunner._tick_craft delegates to these
- Procedural _draw differentiates Carpenter (brown bench + vise) vs
  Smelter (dark stone + orange ember glow) via the @export label_text
  field — no subclass needed for Phase 6
- World autoload: workbenches registry + register_workbench/unregister_workbench

Crafting AI (Agent C):
- Toil.KIND_CRAFT + Toil.craft_at(workbench_path, bill_index) factory
- JobRunner._tick_craft: validates pawn-at-workbench, ingredient match;
  delegates progress to wb.tick_craft; on complete spawns output Item
  with QualityCalc.roll() applied; records bill completion
- crafting_provider.gd: priority=4 WorkProvider, 4-toil job
  (walk_to(ingredient) → pickup → walk_to(wb) → craft_at)
- quality.gd: QualityCalc.roll(skill) — additive formula
  skill × 0.04 + RNG(0, 0.6) with bucket thresholds matching
  architecture.md spec. Skill 0 caps at Excellent; Skill 10 reaches
  Legendary ~8% of the time

Opus integration:
- world.tscn: CraftingProvider node added
- world.gd: registered crafting_provider with World (priority order:
  construction=6 > chop=5 > mine=4 > crafting=4 > haul=3 > rest=0)
- Pawn spawn data extended with crafting skill (Bram=8, Cora=4, Edda=0)
  for visible quality variation in the demo
- _seed_phase5_demo_buildings extended: pre-built Carpenter at (46, 25)
  with plank bill (FOREVER) + Smelter at (48, 25) with stone_block bill
  (UNTIL_N=5)

The wall-trap bug (caught via MCP runtime — initial Phase 6 run hung):
- Pawns building walls stood ON the wall tile. When wall._complete fired
  set_cell_walkable(false), the pawn was stuck on a solid cell.
  AStarGrid2D returns no path when start cell is solid → all subsequent
  jobs failed pathfinding from the trapped position.
- Fix: ConstructionProvider checks site.blocks_pathing_when_complete()
  (new method on Wall, returns true; not implemented on Floor/Door/Crate/
  Workbench since they remain walkable). Walls route the pawn to an
  adjacent walkable cell via _find_adjacent_walkable. Floors/doors/etc.
  build on-tile as before.
- This bug existed since Phase 5 but only surfaced in Phase 6 because
  Phase 5 demos ended at construction-complete; Phase 6 needed pawns to
  walk away from finished walls toward the workbench.

Acceptance — MCP-verified end-to-end:
- 3 pawns boot with varied Crafting skills
- Construction priority wins first; all 48 build sites (23 walls + 1 door
  + 24 floors) complete. Pawns escape wall tiles safely (fix verified).
- Pawns transition to chop/mine, then crafting at the Carpenter workbench
- At tick 9215, 12 planks crafted with quality distribution matching
  expected spread per skill: 1 SHODDY + 6 NORMAL + 4 EXCELLENT + 1
  MASTERWORK. Quality-coloured borders visible on items.
- Smelter UNTIL_N=5 bill correctly idle (no stone consumed yet) because
  CraftingProvider prefers closer workbench-ingredient pairs and the
  carpenter+wood is closer to where pawns end up than smelter+stone

Phase 6 followups for later phases:
- on_craft_interrupted has no JobRunner hook — Phase 9 status interrupts
  will need a 'cancel callback' on toils or wb.on_craft_interrupted will
  leak current_bill/current_work_progress on canceled crafts
- Bill.from_dict reconstructs Recipe inline via Recipe.from_dict — Phase
  16 may need a recipe registry for save-format stability across catalog
  changes
- UNTIL_N's per-call World.items walk is O(items) — acceptable at MVP
  scale; profile if it becomes hot

Delegation report this phase:
- Agent A: Recipe + Bill + RecipeCatalog + Item.quality + Pawn.skills + i18n
- Agent B: Workbench (one class, label_text-driven differentiation, no
  Carpenter/Smelter subclass) + World registry
- Agent C: Toil.KIND_CRAFT + JobRunner._tick_craft + CraftingProvider +
  QualityCalc
- Opus: scene wiring + pawn-skill init + workbench demo seed + wall-trap
  fix (caught via MCP) + runtime verification

~75% of Phase 6 GDScript was subagent-authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:52:41 +01:00

276 lines
10 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
## Pawn entity — grid-snapped, sim-tick-driven movement with smooth render lerp.
##
## Movement model (docs/architecture.md "Pawn movement"):
## At 1× speed, crossing one tile costs STEP_TICKS sim ticks (10 ticks = 0.5 s
## at 20 Hz). Each sim tick advances _step_progress by 1/STEP_TICKS. When
## progress reaches 1.0 the pawn snaps to the next waypoint.
##
## Speed scaling is free: Pause → no ticks → pawn frozen; Ultra → 12× ticks/s →
## pawn crosses the map in ~7 s real time. No per-pawn speed handling needed.
##
## Render: _process() lerps world-position between current and next tile every
## render frame at 60 Hz — motion is smooth even at low sim Hz.
##
## Phase 3 additions:
## - `forced_job` slot (player override via Selection)
## - `job_runner` Node child wired externally by the World scene
## - On each sim tick: orchestrate AI first (Decision → JobRunner.tick), then
## advance the walk. The walk is still owned by the Pawn — JobRunner's WALK
## toil delegates to `walk_along_path()` and listens for `walk_completed`.
## - to_dict() / from_dict() round-trip the entire mid-walk + mid-toil state
## (architecture.md "Save format" — mid-tick suspend safe).
class_name Pawn
const STEP_TICKS: int = 10
const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn needs no World reference.
# ── skill definitions (docs/design.md "Skills") ──────────────────────────────
# Five skills, levels 010. Level by use; multiplicative speed/quality bonus.
# Skills modify duration and quality, never permission (design.md:35).
const SKILL_MANUAL_LABOR: StringName = &"manual_labor"
const SKILL_CRAFTING: StringName = &"crafting"
const SKILL_COOKING: StringName = &"cooking"
const SKILL_MEDICINE: StringName = &"medicine"
const SKILL_COMBAT: StringName = &"combat"
const ALL_SKILLS: Array[StringName] = [
SKILL_MANUAL_LABOR, SKILL_CRAFTING, SKILL_COOKING, SKILL_MEDICINE, SKILL_COMBAT,
]
signal walk_started
signal walk_completed
signal arrived_at_destination(tile: Vector2i)
@export var pawn_name: String = ""
var tile: Vector2i = Vector2i.ZERO
# Player override slot — set by Selection; consumed by Decision on next sim tick.
# Untyped to dodge the autoload-class-name-ordering trap (Phase 2 gotcha).
var forced_job = null
# JobRunner node ref. Set externally by World during pawn spawn (so the runner
# can be paired with the pathfinder). May be null in tests / pre-Phase-3 scenes.
var job_runner = null
# Phase 4 — carry slot for hauling. Holds an Item node while carrying; null
# when empty-handed. PICKUP toil sets this; DEPOSIT clears it. One stack /
# one type at a time per design.md.
var carried_item = null
# Phase 6 — skill levels. Initialized to 0 for all five skills in _ready().
# Use get_skill() / set_skill() to access; direct dict mutation is allowed
# for batch operations (e.g. from_dict restoring saved data).
var skills: Dictionary = {}
var _path: Array[Vector2i] = []
var _step_progress: float = 0.0
var _selected: bool = false
@onready var _name_label: Label = $NameLabel
@onready var _state_label: Label = $StateLabel
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
_state_label.text = Strings.t(&"pawn.state.idle")
# Initialise all five skills to 0 if not already set (from_dict sets them
# before _ready() fires in some load paths — only fill missing keys here).
for skill in ALL_SKILLS:
if not skills.has(skill):
skills[skill] = 0
func setup(p_name: String, start_tile: Vector2i) -> void:
pawn_name = p_name
tile = start_tile
position = _tile_to_world(tile)
_name_label.text = pawn_name
_state_label.text = Strings.t(&"pawn.state.idle")
Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile])
# ── public API ──────────────────────────────────────────────────────────────
func walk_along_path(new_path: Array[Vector2i]) -> void:
if new_path.is_empty():
return
var was_walking := is_walking()
_path = new_path.duplicate()
# _step_progress carries over; when it hits 1.0 the pawn snaps to
# the first tile of the new path and picks up the new direction.
if not was_walking:
walk_started.emit()
_state_label.text = Strings.t(&"pawn.state.walking")
Audit.log("pawn", "%s walk path len %d%s" % [pawn_name, new_path.size(), new_path[-1]])
func is_walking() -> bool:
return not _path.is_empty()
func set_selected(value: bool) -> void:
if _selected == value:
return
_selected = value
queue_redraw()
func is_selected() -> bool:
return _selected
## Returns the pawn's current level (010) for the given skill.
## Returns 0 for unknown skills so callers need no nil-guard.
func get_skill(skill: StringName) -> int:
return int(skills.get(skill, 0))
## Sets skill to level, clamped to [0, 10]. Asserts the key is a known skill.
func set_skill(skill: StringName, level: int) -> void:
assert(skill in ALL_SKILLS, "set_skill: unknown skill '%s'" % skill)
skills[skill] = clampi(level, 0, 10)
# ── save / load ─────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
var path_data: Array = []
for v in _path:
path_data.append([v.x, v.y])
# Serialise skills as {"manual_labor": 0, "crafting": 3, ...} — StringName
# keys must be stored as plain Strings for JSON round-trip safety.
var skills_data: Dictionary = {}
for skill in ALL_SKILLS:
skills_data[String(skill)] = get_skill(skill)
return {
"name": pawn_name,
"tile_x": tile.x,
"tile_y": tile.y,
"path": path_data,
"step_progress": _step_progress,
"selected": _selected,
"forced_job": forced_job.to_dict() if forced_job != null else null,
"job_runner": job_runner.to_dict() if job_runner != null else null,
"skills": skills_data,
}
func from_dict(d: Dictionary) -> void:
pawn_name = d.get("name", "")
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
_path.clear()
for entry in d.get("path", []):
if entry is Array and entry.size() == 2:
_path.append(Vector2i(int(entry[0]), int(entry[1])))
_step_progress = float(d.get("step_progress", 0.0))
_selected = bool(d.get("selected", false))
var fj_dict: Variant = d.get("forced_job")
forced_job = Job.from_dict(fj_dict) if fj_dict is Dictionary else null
var jr_dict: Variant = d.get("job_runner")
if jr_dict is Dictionary and job_runner != null:
job_runner.from_dict(jr_dict)
# Restore skills — set directly on the dict to bypass the ALL_SKILLS assert
# (from_dict must be resilient to saves that pre-date a new skill being added).
var saved_skills: Variant = d.get("skills")
if saved_skills is Dictionary:
for raw_key in saved_skills.keys():
var skill := StringName(raw_key)
if skill in ALL_SKILLS:
skills[skill] = clampi(int(saved_skills[raw_key]), 0, 10)
_name_label.text = pawn_name
_state_label.text = Strings.t(&"pawn.state.walking") if is_walking() else Strings.t(&"pawn.state.idle")
position = _tile_to_world(tile)
queue_redraw()
Audit.log("pawn", "%s restored at %s (walking=%s, path len=%d)" % [pawn_name, tile, is_walking(), _path.size()])
# ── sim tick: orchestrate AI, then advance walk ─────────────────────────────
func _on_sim_tick(_tick_number: int) -> void:
_orchestrate_ai()
_advance_walk()
# Phase 4 — the carry indicator changes when PICKUP/DEPOSIT toils mutate
# carried_item directly. Cheapest reliable redraw hook is here.
queue_redraw()
func _orchestrate_ai() -> void:
# Phase 3: ask Decision for a job when the pawn is idle OR when a forced job
# is queued (forced_job preempts the current job — player override semantics).
# Decision's layer 2 consumes the forced_job slot; layer 4 falls back to work
# providers when no override is queued.
if job_runner == null:
return
if forced_job != null or not job_runner.has_job():
var next_job = Decision.pick_next_job(self, World.work_providers)
if next_job != null:
job_runner.start_job(next_job)
# Tick the runner (a freshly-started job's first toil executes here in the
# same sim tick — WALK calls pawn.walk_along_path so _advance_walk below
# immediately starts moving on this tick).
if job_runner.has_job():
job_runner.tick()
func _advance_walk() -> void:
if not is_walking():
return
_step_progress += 1.0 / float(STEP_TICKS)
if _step_progress >= 1.0:
tile = _path[0]
_path.remove_at(0)
_step_progress = 0.0
if _path.is_empty():
_state_label.text = Strings.t(&"pawn.state.idle")
walk_completed.emit()
arrived_at_destination.emit(tile)
Audit.log("pawn", "%s arrived at %s" % [pawn_name, tile])
# ── render ──────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
var from_world := _tile_to_world(tile)
var next := _path[0] if is_walking() else tile
var to_world := _tile_to_world(next)
position = from_world.lerp(to_world, _step_progress)
func _draw() -> void:
# Body disc — colour derived deterministically from pawn name so each pawn
# is visually distinct without any art dependency.
var hue := float(pawn_name.hash() % 360) / 360.0
var body_colour := Color.from_hsv(hue, 0.7, 0.85)
draw_circle(Vector2.ZERO, 6.0, body_colour)
# Dark outline ring.
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.6), 1.0)
# Selection ring.
if _selected:
draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0)
# Phase 4 — carry indicator: small coloured square at upper-right of body.
if carried_item != null:
var ci_hue := float(carried_item.item_type.hash() % 360) / 360.0
var ci_color := Color.from_hsv(ci_hue, 0.6, 0.85)
draw_rect(Rect2(6, -10, 7, 7), ci_color)
draw_rect(Rect2(6, -10, 7, 7), Color(0, 0, 0, 0.7), false, 1.0)
# ── helpers ─────────────────────────────────────────────────────────────────
func _tile_to_world(t: Vector2i) -> Vector2:
return Vector2(
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)