rimlike/scenes/ui/work_priority_matrix.gd
megaproxy 87a7beb22b split CookingProvider out of CraftingProvider — fixes starvation
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>
2026-05-16 21:18:26 +01:00

255 lines
8.4 KiB
GDScript
Raw Permalink 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 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()