Player report: pawns starve even with harvested crops because cooking never happens. Root cause: CraftingProvider handled both crafting-skill and cooking-skill bills with priority 4, below Plant=5 and Chop=5 in Decision's tiebreaker. Pawns endlessly harvested + chopped instead of cooking the food already on the floor; raw +25 vegetable couldn't outpace HUNGER_DECAY × 3 pawns. CraftingProvider now filters bills to required_skill == &"crafting" only. New CookingProvider (category=&"cooking", priority=6) handles required_skill == &"cooking" bills (bread, meal_from_vegetables) with identical find/score logic including the ingredient2 buffer flow. pawn.work_priorities default now includes &"cooking": 3 (matches the 9-category design spec). decision.gd category-list comment updated. WorkPriorityMatrix gains a "Cook" column. MCP runtime verified: pawns now decide `cooking(pri=3) → Craft Veggie meal at Hearth` immediately after vegetables exist; 2 bread items appeared by tick 261 of a fresh boot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
8.4 KiB
GDScript
255 lines
8.4 KiB
GDScript
class_name WorkPriorityMatrix extends CanvasLayer
|
||
## Phase 17 — Per-pawn work-priority matrix bottom-sheet.
|
||
##
|
||
## Opened via TopBar "Work" button (injected by main.gd).
|
||
## Displays one row per pawn × one column per player-configurable work category.
|
||
## Tapping a cell cycles the priority: 1 (Critical) → 2 → 3 → 4 (Low) → 0 (OFF) → 1.
|
||
##
|
||
## Priority semantics (mirrors Pawn.work_priorities and Decision layer 4):
|
||
## 0 = OFF (gray) — provider never picks this pawn for this category.
|
||
## 1 = Critical (red)
|
||
## 2 = High (orange)
|
||
## 3 = Normal (yellow) — default
|
||
## 4 = Low (blue)
|
||
##
|
||
## Category order matches the locked list in pawn.gd / Decision layer 4.
|
||
## &"doctor" is included — players can opt a pawn out of doctor duty even
|
||
## though the needs-driven healing path bypasses the filter at Layer 3.
|
||
##
|
||
## Save / load: NOT session-persistent for MVP — the matrix re-reads
|
||
## pawn.work_priorities live from the Pawn nodes every open, so any save that
|
||
## round-trips pawn.work_priorities (via pawn.to_dict) will reflect correctly.
|
||
|
||
const CATEGORIES: Array[StringName] = [
|
||
&"construction", &"cooking", &"chop", &"plant", &"mine", &"crafting", &"haul", &"clean", &"doctor"
|
||
]
|
||
const CATEGORY_LABELS: Dictionary = {
|
||
&"construction": "Build",
|
||
&"cooking": "Cook",
|
||
&"chop": "Chop",
|
||
&"plant": "Plant",
|
||
&"mine": "Mine",
|
||
&"crafting": "Craft",
|
||
&"haul": "Haul",
|
||
&"clean": "Clean",
|
||
&"doctor": "Doctor",
|
||
}
|
||
|
||
## Color per priority level for the cell buttons.
|
||
const PRIORITY_COLORS: Dictionary = {
|
||
0: Color(0.45, 0.45, 0.45, 1.0), # OFF — gray
|
||
1: Color(0.85, 0.18, 0.18, 1.0), # Critical — red
|
||
2: Color(0.90, 0.55, 0.10, 1.0), # High — orange
|
||
3: Color(0.85, 0.80, 0.10, 1.0), # Normal — yellow
|
||
4: Color(0.20, 0.40, 0.85, 1.0), # Low — blue
|
||
}
|
||
const PRIORITY_DISPLAY: Dictionary = {
|
||
0: "X",
|
||
1: "1",
|
||
2: "2",
|
||
3: "3",
|
||
4: "4",
|
||
}
|
||
|
||
## Panel height in pixels.
|
||
const PANEL_HEIGHT: float = 600.0
|
||
## Row height for each pawn row.
|
||
const ROW_H: float = 48.0
|
||
## Column width for category cells.
|
||
const COL_W: float = 64.0
|
||
## Width of the pawn-name column.
|
||
const NAME_COL_W: float = 100.0
|
||
|
||
var _root: Control = null
|
||
var _scroll: ScrollContainer = null
|
||
var _grid: GridContainer = null
|
||
## Flat list of [pawn, category, button] triples so we can refresh on open.
|
||
var _cells: Array = []
|
||
|
||
|
||
func _ready() -> void:
|
||
layer = 17
|
||
_build_ui()
|
||
# Hide the whole CanvasLayer when closed so the Backdrop ColorRect (which
|
||
# has MOUSE_FILTER_STOP for click-outside-to-close) does not eat mouse
|
||
# events in `_unhandled_input` for the world below. Toggling _root.visible
|
||
# alone leaves the Backdrop alive and intercepting clicks.
|
||
visible = false
|
||
_root.visible = false
|
||
Audit.log("work_priority_ui", "WorkPriorityMatrix ready")
|
||
|
||
|
||
# ── public API ────────────────────────────────────────────────────────────────
|
||
|
||
## Open the matrix panel and rebuild the grid from current pawn state.
|
||
func open() -> void:
|
||
_rebuild_grid()
|
||
visible = true
|
||
_root.visible = true
|
||
EventBus.ui_panel_opened.emit(&"work_matrix")
|
||
Audit.log("work_priority_ui", "opened (pawns=%d)" % World.pawns.size())
|
||
|
||
|
||
## Close the panel.
|
||
func close() -> void:
|
||
visible = false
|
||
_root.visible = false
|
||
Audit.log("work_priority_ui", "closed")
|
||
|
||
|
||
# ── UI construction ───────────────────────────────────────────────────────────
|
||
|
||
func _build_ui() -> void:
|
||
# Full-screen semi-transparent backdrop.
|
||
var backdrop := ColorRect.new()
|
||
backdrop.name = "Backdrop"
|
||
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
backdrop.color = Color(0.0, 0.0, 0.0, 0.45)
|
||
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
backdrop.gui_input.connect(_on_backdrop_input)
|
||
add_child(backdrop)
|
||
|
||
# Bottom-sheet panel anchored to the bottom of the screen.
|
||
_root = Control.new()
|
||
_root.name = "MatrixPanel"
|
||
_root.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||
_root.custom_minimum_size = Vector2(0.0, PANEL_HEIGHT)
|
||
_root.offset_top = -PANEL_HEIGHT
|
||
_root.offset_bottom = 0.0
|
||
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
backdrop.add_child(_root)
|
||
|
||
var bg := PanelContainer.new()
|
||
bg.name = "BG"
|
||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
_root.add_child(bg)
|
||
|
||
var vbox := VBoxContainer.new()
|
||
vbox.add_theme_constant_override("separation", 4)
|
||
bg.add_child(vbox)
|
||
|
||
# Header row.
|
||
var header := HBoxContainer.new()
|
||
header.add_theme_constant_override("separation", 8)
|
||
vbox.add_child(header)
|
||
|
||
var title := Label.new()
|
||
title.text = "Work Priorities"
|
||
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
header.add_child(title)
|
||
|
||
var close_btn := Button.new()
|
||
close_btn.text = "Close"
|
||
close_btn.focus_mode = Control.FOCUS_NONE
|
||
close_btn.custom_minimum_size = Vector2(72.0, 36.0)
|
||
close_btn.pressed.connect(close)
|
||
header.add_child(close_btn)
|
||
|
||
# Scroll area for the grid (handles many pawns).
|
||
_scroll = ScrollContainer.new()
|
||
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
vbox.add_child(_scroll)
|
||
|
||
# GridContainer: 1 + CATEGORIES.size() columns.
|
||
_grid = GridContainer.new()
|
||
_grid.columns = 1 + CATEGORIES.size()
|
||
_grid.add_theme_constant_override("h_separation", 4)
|
||
_grid.add_theme_constant_override("v_separation", 4)
|
||
_scroll.add_child(_grid)
|
||
|
||
|
||
# ── grid rebuild ──────────────────────────────────────────────────────────────
|
||
|
||
func _rebuild_grid() -> void:
|
||
# Clear existing children and cell tracking.
|
||
for child in _grid.get_children():
|
||
child.queue_free()
|
||
_cells.clear()
|
||
|
||
# Header row: "Pawn" + one label per category.
|
||
_add_header_cell("Pawn")
|
||
for cat in CATEGORIES:
|
||
_add_header_cell(CATEGORY_LABELS.get(cat, String(cat)))
|
||
|
||
# One data row per pawn in World.pawns.
|
||
for pawn in World.pawns:
|
||
# Pawn name cell.
|
||
var name_lbl := Label.new()
|
||
name_lbl.text = pawn.pawn_name
|
||
name_lbl.custom_minimum_size = Vector2(NAME_COL_W, ROW_H)
|
||
name_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
name_lbl.clip_text = true
|
||
_grid.add_child(name_lbl)
|
||
|
||
# One button per category.
|
||
for cat in CATEGORIES:
|
||
var lvl: int = int(pawn.work_priorities.get(cat, 3))
|
||
var btn := Button.new()
|
||
btn.focus_mode = Control.FOCUS_NONE
|
||
btn.custom_minimum_size = Vector2(COL_W, ROW_H)
|
||
_apply_cell_style(btn, lvl)
|
||
# Capture pawn + category in the closure.
|
||
var p = pawn
|
||
var c: StringName = cat
|
||
btn.pressed.connect(func() -> void: _on_cell_pressed(p, c, btn))
|
||
_grid.add_child(btn)
|
||
_cells.append([pawn, cat, btn])
|
||
|
||
|
||
func _add_header_cell(text: String) -> void:
|
||
var lbl := Label.new()
|
||
lbl.text = text
|
||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
lbl.custom_minimum_size = Vector2(COL_W, 32.0)
|
||
lbl.add_theme_font_size_override("font_size", 11)
|
||
_grid.add_child(lbl)
|
||
|
||
|
||
func _apply_cell_style(btn: Button, lvl: int) -> void:
|
||
btn.text = PRIORITY_DISPLAY.get(lvl, "?")
|
||
btn.modulate = PRIORITY_COLORS.get(lvl, Color.WHITE)
|
||
|
||
|
||
# ── cell interaction ──────────────────────────────────────────────────────────
|
||
|
||
func _on_cell_pressed(pawn, category: StringName, btn: Button) -> void:
|
||
# Cycle: 1 → 2 → 3 → 4 → 0 → 1
|
||
var current: int = int(pawn.work_priorities.get(category, 3))
|
||
var next: int
|
||
match current:
|
||
0: next = 1
|
||
1: next = 2
|
||
2: next = 3
|
||
3: next = 4
|
||
4: next = 0
|
||
_: next = 3
|
||
|
||
pawn.work_priorities[category] = next
|
||
_apply_cell_style(btn, next)
|
||
|
||
EventBus.pawn_priority_changed.emit(pawn, category, next)
|
||
Audit.log("work_priority_ui", "%s: %s → %d" % [pawn.pawn_name, String(category), next])
|
||
|
||
|
||
func _unhandled_input(event: InputEvent) -> void:
|
||
# P — toggle the work priority matrix.
|
||
if event.is_action_pressed("open_priorities"):
|
||
if _root != null and _root.visible:
|
||
close()
|
||
else:
|
||
open()
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
# Escape — close if open.
|
||
if event.is_action_pressed("cancel") and _root != null and _root.visible:
|
||
close()
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
|
||
|
||
func _on_backdrop_input(event: InputEvent) -> void:
|
||
# Tap on the backdrop (outside the panel) closes the matrix.
|
||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||
close()
|
||
elif event is InputEventScreenTouch and event.pressed:
|
||
close()
|