rimlike/scenes/entities/item.gd
megaproxy 61dcf6760b Phase 7 — Crops, hunger, eating, cooking chain (grain → flour → bread)
Three gdscript-refactor agents in parallel; Opus integrated and tuned
the priority + hunger-decay numbers via MCP runtime observation.

Crop entity + PlantProvider (Agent A, scenes/entities/crop.{gd,tscn} +
scenes/ai/plant_provider.gd, ~225 lines):
- Crop: 6-stage state machine (TILLED → SOWN → GROWING_1/2/3 → READY).
  STAGE_TICKS=200 sim ticks per stage × 4 stages = 800 total to maturity.
  Listens to EventBus.sim_tick for growth. Procedural _draw with growing
  plant + ready-state golden grain accent.
- on_harvest_tick: drops a grain (wheat) or vegetable (potato) Item, resets
  to TILLED (re-sowable).
- on_sow_tick: TILLED → SOWN — used when Phase 17 paint UI lands.
- PlantProvider priority=5 (above crafting=4) — harvest-only for Phase 7.
  Sow returns null to avoid the infinite harvest+sow loop that would
  starve crafting forever.
- JobRunner._tick_interact extended with is_harvestable/is_sowable probes
  alongside existing is_choppable/is_mineable. Unified probe array.

Hunger + Eating (Agent B, scenes/pawn/pawn.gd + scenes/ai/eat_provider.gd +
toil.gd/job_runner.gd extensions, ~150 lines):
- Pawn.hunger: float 0..100. HUNGER_DECAY_PER_TICK=0.02 (tuned down 5×
  from agent's 0.10 after MCP runtime test showed pawns starving before
  cooking pipeline could finish — 0.02 means 100→0 in 5000 ticks =
  ~4 min at 1× / ~20s at Ultra).
- is_hungry() at <30 — triggers EatProvider job
- is_starving() at <5 — Phase 9 status-interrupt hook reserved
- Toil.KIND_EAT + JobRunner._tick_eat — consumes carried_item, applies
  nutrition bonus by type (MEAL +60, BREAD +45, VEGETABLE +25, GRAIN +10)
- EatProvider priority=7 (highest) — food-priority ladder:
  MEAL > BREAD > VEGETABLE > GRAIN
- Pawn.skills extended with cooking init; hunger round-trip in to_dict

Cooking recipes (Agent C, recipe_catalog.gd + item.gd + workbench.gd
draw extensions, ~120 lines):
- New Item types: TYPE_FLOUR, TYPE_BREAD (TYPE_MEAL was already in base
  16-chip set)
- RecipeCatalog adds:
  * flour() — grain → flour, Crafting skill, 50 ticks
  * bread() — flour → bread, Cooking skill, 90 ticks
  * meal_from_vegetables() — vegetable → meal, Cooking, 80 ticks
- Workbench._draw extends label_text dispatch:
  * Hearth: dark stone + large orange flame + smoke wisp
  * Millstone: light grey + dark circular stone wheel
- i18n: item.flour, item.bread, item.meal, workbench.hearth, workbench.millstone

Opus integration:
- world.tscn: PlantProvider + EatProvider nodes (8 providers total)
- world.gd registers all 8 in priority order:
  eat=7 > construction=6 > chop=5 > plant=5 > mine=4 > crafting=4 >
  haul=3 > rest=0
- Pawn spawn data extended with cooking skill (Bram=2 / Cora=6 / Edda=1)
  for hearth-recipe quality spread
- _seed_phase5_demo_buildings extended (now spans Phase 5/6/7):
  - Millstone at (46, 27) inside cabin south-row: flour bill FOREVER
  - Hearth at (49, 27) inside cabin south-row: bread + meal bills FOREVER
  - 6 wheat crops east of cabin at (54-55, 24-26), all SOWN at boot
  - 2 pre-baked breads at (45-50, 21) so eat-loop unblocks before cooking
    chain completes

Wall-trap fix from Phase 6 confirmed working — pawn paths now go to
(44, 29) adjacent to the south-west corner wall, not on top of it.

Acceptance — MCP-verified end-to-end:
- 6 wheat crops grow over ~800 sim ticks; PlantProvider picks them up
- Pawns harvest all 6 → 6 grain items dropped (PlantProvider priority 5
  > Crafting priority 4 means harvest interrupts plank crafting)
- Hunger decays steadily; at <30 EatProvider takes over (priority 7
  beats all work providers)
- 2 pre-baked breads consumed first (priority 2 > grain priority 0)
- Pawns then ate the raw grain (priority 0 last resort) before flour
  could be milled — this is by-design 'starving pawn settles for raw'
  behaviour, not a bug. Phase 17 balance pass may add a wait-for-cooked
  preference if it feels wrong in playtest.
- Planks crafted with EXCELLENT quality at (46, 25) — quality system from
  Phase 6 still works on top of the new pipeline

Phase 7 tuning lessons (logged):
- Agent's initial 0.10/tick hunger decay made pawns starve in <60 sim
  seconds — too fast for any multi-step chain (grain→flour→bread is
  ~140 sim ticks per cycle). Tuned to 0.02/tick post-runtime.
- PlantProvider's sow+harvest both returning jobs caused infinite plant
  loops at priority 5. Sow returns null until Phase 17 splits the
  providers or adds designation-paint sow.
- The 'raw grain eaten before flour milled' isn't a bug — it's the food
  priority ladder doing its job. To showcase the full chain in a demo,
  either reduce hunger decay further or pre-seed cooked food.

Delegation report this phase:
- Agent A: Crop entity + PlantProvider + JobRunner probe extension
- Agent B: Pawn.hunger + EatProvider + KIND_EAT toil
- Agent C: Recipe catalog extension (flour/bread/meal) + Workbench draw
  branches for Hearth/Millstone
- Opus: scene wiring + pawn cooking-skill init + demo seed (Millstone +
  Hearth + 6 crops + pre-baked breads) + MCP-driven runtime tuning of
  hunger decay and plant priority

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

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

184 lines
7.1 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.

## Dropped item entity — a single stack of one item type lying on the world floor.
##
## Visuals are drawn procedurally via _draw() (Phase 4 placeholder). Real
## ElvGames item icons land in Phase 5+.
##
## Item type constants mirror the 16 filter chips in docs/design.md. They are
## used by StockpileZone filter bitmasks and pawn-carry typing.
##
## World registration (World.register_item / World.unregister_item) is called
## here but the methods land in World during Opus integration. The script will
## parse cleanly; the call will fail at runtime until then.
class_name Item extends Node2D
const TILE_SIZE_PX: int = 16
# ── canonical type registry — matches docs/design.md "16 filter chips" ───────
const TYPE_WOOD: StringName = &"wood" # Wd
const TYPE_STONE: StringName = &"stone" # St
const TYPE_IRON_ORE: StringName = &"iron_ore" # Ir
const TYPE_COPPER_ORE: StringName = &"copper_ore" # Cu
const TYPE_SILVER: StringName = &"silver" # Ag
const TYPE_GOLD: StringName = &"gold" # Au
const TYPE_CLOTH: StringName = &"cloth" # Cl
const TYPE_VEGETABLE: StringName = &"vegetable" # Veg
const TYPE_MEAT: StringName = &"meat" # Mt
const TYPE_GRAIN: StringName = &"grain" # Gr
const TYPE_MEAL: StringName = &"meal" # Ck (cooked)
const TYPE_MEDICINE: StringName = &"medicine" # Md
const TYPE_TOOL: StringName = &"tool" # Tl
const TYPE_WEAPON: StringName = &"weapon" # Wp
const TYPE_ARMOR: StringName = &"armor" # Ar
const TYPE_CORPSE: StringName = &"corpse" # Co
# Phase 6 — intermediate crafted goods (carpenter bench + smelter outputs).
const TYPE_PLANK: StringName = &"plank"
const TYPE_STONE_BLOCK: StringName = &"stone_block"
# Phase 7 — cooking chain. Grain → Flour (millstone) → Bread (hearth).
# TYPE_MEAL (&"meal") is the generic cooked-dish output and already lives above
# in the 16-chip base set.
const TYPE_FLOUR: StringName = &"flour"
const TYPE_BREAD: StringName = &"bread"
const ALL_TYPES: Array[StringName] = [
TYPE_WOOD, TYPE_STONE, TYPE_IRON_ORE, TYPE_COPPER_ORE,
TYPE_SILVER, TYPE_GOLD, TYPE_CLOTH, TYPE_VEGETABLE,
TYPE_MEAT, TYPE_GRAIN, TYPE_MEAL, TYPE_MEDICINE,
TYPE_TOOL, TYPE_WEAPON, TYPE_ARMOR, TYPE_CORPSE,
TYPE_PLANK, TYPE_STONE_BLOCK,
TYPE_FLOUR, TYPE_BREAD,
]
# ── quality system (docs/architecture.md "Quality system") ───────────────────
# Rolled at craft-completion; stored per item; drives border colour in _draw().
enum Quality { SHODDY, NORMAL, EXCELLENT, MASTERWORK, LEGENDARY }
# ── state ────────────────────────────────────────────────────────────────────
@export var item_type: StringName = TYPE_WOOD
@export var stack_size: int = 1
@export var quality: Quality = Quality.NORMAL
var tile: Vector2i = Vector2i.ZERO
## When true the on-floor visual is suppressed; the carrying pawn renders the
## carry indicator instead.
var being_carried: bool = false
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
position = _tile_to_world(tile)
visible = not being_carried
func _exit_tree() -> void:
World.unregister_item(self)
# ── public API ────────────────────────────────────────────────────────────────
## One-shot initialiser called by the spawning code (Tree.fell, Rock.mined, etc.)
## Sets all fields, syncs position, and registers with World.
func setup(p_type: StringName, p_stack: int, p_tile: Vector2i) -> void:
item_type = p_type
stack_size = p_stack
tile = p_tile
position = _tile_to_world(tile)
visible = not being_carried
queue_redraw()
World.register_item(self)
Audit.log("item", "spawned %s×%d at %s" % [item_type, stack_size, tile])
## Hide/show the on-floor sprite when the pawn picks up or drops this item.
func set_being_carried(value: bool) -> void:
being_carried = value
visible = not being_carried
# ── save / load ───────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"type": String(item_type),
"stack_size": stack_size,
"tile_x": tile.x,
"tile_y": tile.y,
"quality": int(quality),
}
## Returns a plain Dictionary spec for World.load_items() to instantiate from.
## Items cannot reconstruct themselves standalone — they need a parent in the
## scene tree. World adds the node, then calls setup() from the returned dict.
static func from_dict(d: Dictionary) -> Dictionary:
return {
"type": StringName(d.get("type", "wood")),
"stack_size": int(d.get("stack_size", 1)),
"tile_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)),
"quality": int(d.get("quality", Quality.NORMAL)),
}
# ── render ────────────────────────────────────────────────────────────────────
func _draw() -> void:
# 12×12 coloured square centered on the tile; colour hashed from item_type.
var hue := float(item_type.hash() % 360) / 360.0
var fill := Color.from_hsv(hue, 0.6, 0.85)
var half: int = 6
var square := Rect2(Vector2(-half, -half), Vector2(half * 2, half * 2))
draw_rect(square, fill)
draw_rect(square, Color(0.0, 0.0, 0.0, 0.75), false, 1.0)
# Quality border — drawn over the dark outline, colour per quality tier.
# NORMAL has no extra border (base outline is sufficient).
match quality:
Quality.SHODDY:
draw_rect(square, Color(0.40, 0.40, 0.40), false, 1.0)
Quality.EXCELLENT:
draw_rect(square, Color(0.20, 0.55, 0.95), false, 1.0)
Quality.MASTERWORK:
draw_rect(square, Color(0.85, 0.55, 0.10), false, 1.0)
Quality.LEGENDARY:
draw_rect(square, Color(0.85, 0.10, 0.80), false, 2.0)
_:
pass # NORMAL — no extra border
# Stack count badge — bottom-right corner of the square, font_size 7.
if stack_size > 1:
var label := Strings.t(&"item.stack_count").format({"n": stack_size})
draw_string(
ThemeDB.fallback_font,
Vector2(half - 1, half - 1),
label,
HORIZONTAL_ALIGNMENT_RIGHT,
-1,
7,
Color(0.0, 0.0, 0.0, 0.6) # drop-shadow offset below
)
draw_string(
ThemeDB.fallback_font,
Vector2(half - 2, half - 2),
label,
HORIZONTAL_ALIGNMENT_RIGHT,
-1,
7,
Color(1.0, 1.0, 1.0, 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
)