wire buff consumers, sick penalty, multi-count cremation

A: Storyteller.multiply_drops() stochastic-rounding helper drives
crop_growth, harvest_yield, chop, mine consumption sites. sleep_decay
multiplied in Pawn sleep tick.

B: Pawn._sick_speed_penalty() (0% healthy → 75% severity 3, clamped to
25% min speed). JobRunner._work_speed_mult coin-flips per-tick progress
on INTERACT/BUILD/CRAFT toils. Sleep/eat/treat unaffected.

C: CraftingProvider builds N deposit trips for ingredient2_count > 1.
JobRunner._tick_craft validates+consumes the full count from buffer.
Cremation now actually requires and consumes 5 wood.

crop._stage_accum round-trips through save/load to preserve buff-
accumulated fractional growth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-16 18:17:29 +01:00
parent d9638a4ea4
commit 2afca16299
9 changed files with 156 additions and 46 deletions

View file

@ -44,10 +44,14 @@ func _init() -> void:
## Single-ingredient recipes produce a 4-toil job:
## walk_to(ing1) → pickup → walk_to(wb) → craft_at(wb, bill_index)
##
## Two-ingredient recipes (ingredient2_type != &"") produce a 7-toil job:
## walk_to(ing1) → pickup → walk_to(wb) → deposit_at_wb(wb)
## → walk_to(ing2) → pickup → craft_at(wb, bill_index)
## The first ingredient is stashed in wb.deposited_inputs; _tick_craft consumes both.
## Two-ingredient recipes (ingredient2_type != &"") build the toil sequence:
## For each item needed to satisfy ingredient2_count (one trip per item):
## walk_to(ing2_item) → pickup → walk_to(wb) → deposit_at_wb(wb)
## Then: walk_to(ing1) → pickup → craft_at(wb, bill_index)
## Stack-aware: if a single wood item has stack_size >= ingredient2_count, only
## one deposit trip is needed. Multiple smaller stacks each get their own trip.
## The primary ingredient (ingredient_type / corpse) is always the last pickup
## before crafting; ingredient2 is deposited in the buffer beforehand.
##
## No-ingredient recipes (ingredient_type == &"") produce a 2-toil job:
## walk_to(wb) → craft_at(wb, bill_index)
@ -92,12 +96,15 @@ func find_best_for(pawn) -> Job:
_emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb)
continue
# Two-ingredient check: secondary ingredient must also be on the floor.
# Two-ingredient check: enough secondary ingredient must be on the floor.
# ingredient2_count items (or fewer stacks totalling that count) required.
if b.recipe.ingredient2_type != &"":
src2 = _find_ingredient_item(b.recipe.ingredient2_type)
if src2 == null:
var ing2_items := _find_ingredient_items_for_count(b.recipe.ingredient2_type, b.recipe.ingredient2_count)
if ing2_items.is_empty():
_emit_bill_blocked(b.recipe.label, &"missing_ingredient2", wb)
continue
# Use the first item as src2 for the scoring distance heuristic.
src2 = ing2_items[0]
# Score: total Manhattan travel distance including both ingredient trips.
# No-ingredient: pawn → wb.
@ -125,23 +132,29 @@ func find_best_for(pawn) -> Job:
best_src1 = _find_ingredient_item(best_bill.recipe.ingredient_type)
if best_src1 == null:
return null
var ing2_items: Array = []
if best_bill.recipe.ingredient2_type != &"":
best_src2 = _find_ingredient_item(best_bill.recipe.ingredient2_type)
if best_src2 == null:
ing2_items = _find_ingredient_items_for_count(best_bill.recipe.ingredient2_type, best_bill.recipe.ingredient2_count)
if ing2_items.is_empty():
return null
var j := Job.new()
j.label = "Craft %s at %s" % [best_bill.recipe.label, best_wb.get("label_text") if best_wb.get("label_text") != null else "workbench"]
j.target_node = best_wb
if best_src1 != null and best_src2 != null:
# Two-ingredient path: deposit ing1 at wb, then fetch ing2 and craft.
j.toils.append(Toil.walk_to(best_src1.tile))
j.toils.append(Toil.pickup())
j.toils.append(Toil.walk_to(best_wb.tile))
j.toils.append(Toil.deposit_at_wb(best_wb.get_path()))
j.toils.append(Toil.walk_to(best_src2.tile))
j.toils.append(Toil.pickup())
if not ing2_items.is_empty():
# Two-ingredient path: deposit ingredient2 item(s) at workbench buffer,
# then fetch the primary ingredient and craft.
# One deposit trip per item in ing2_items (stack-aware: fewer trips when
# item.stack_size covers multiple units, e.g. a 5-stack of wood = 1 trip).
for ing2 in ing2_items:
j.toils.append(Toil.walk_to(ing2.tile))
j.toils.append(Toil.pickup())
j.toils.append(Toil.walk_to(best_wb.tile))
j.toils.append(Toil.deposit_at_wb(best_wb.get_path()))
if best_src1 != null:
j.toils.append(Toil.walk_to(best_src1.tile))
j.toils.append(Toil.pickup())
elif best_src1 != null:
# Single-ingredient path: carry ing1 directly to craft.
j.toils.append(Toil.walk_to(best_src1.tile))
@ -166,6 +179,27 @@ func _find_ingredient_item(item_type: StringName):
return null
## Returns a list of on-floor Items of `item_type` whose combined stack_size
## totals at least `needed_count`. Returns an empty array if not enough exist.
## Stack-aware: a single item with stack_size >= needed_count yields one entry.
## Multiple smaller stacks are collected until the total is met.
## This powers the multi-trip deposit loop for ingredient2_count > 1 recipes.
func _find_ingredient_items_for_count(item_type: StringName, needed_count: int) -> Array:
var result: Array = []
var accumulated: int = 0
for it in World.items:
if it.being_carried:
continue
if it.item_type != item_type:
continue
result.append(it)
accumulated += int(it.get("stack_size") if it.get("stack_size") != null else 1)
if accumulated >= needed_count:
return result
# Not enough items found.
return []
## Manhattan distance between two Vector2i tile coordinates.
func _manhattan(a: Vector2i, b: Vector2i) -> int:
return abs(a.x - b.x) + abs(a.y - b.y)

View file

@ -237,7 +237,10 @@ func _tick_interact(t) -> void:
t.done = true
return
target.call(StringName(t.data.get("tick_method", "")))
# Sick pawn coin-flip: skip this tick's work at a rate equal to the penalty.
# Expected-value progress is unchanged when healthy (mult == 1.0).
if randf() < _work_speed_mult():
target.call(StringName(t.data.get("tick_method", "")))
# Re-check validity after the call (the call may have freed the node).
if target == null or not is_instance_valid(target):
@ -292,7 +295,9 @@ func _tick_build(t) -> void:
t.done = true
return
target.on_build_tick()
# Sick pawn coin-flip: same slow-work mechanic as _tick_interact.
if randf() < _work_speed_mult():
target.on_build_tick()
# Re-check after the call (on_build_tick may complete + free the ghost state).
if target == null or not is_instance_valid(target):
@ -415,7 +420,7 @@ func _tick_deposit_at_wb(t) -> void:
## true when bill.recipe.work_ticks is reached.
## - On completion:
## * Consumes pawn.carried_item (ingredient or ingredient2); queue_free + clear.
## * For two-ingredient recipes, also calls wb.consume_deposited_input for ing1.
## * For two-ingredient recipes, also calls wb.consume_deposited_input for ing2.
## * Rolls quality via QualityCalc.roll() using the pawn's skill level.
## * Spawns an Item scene child of wb.get_parent() at wb.tile.
## * Calls wb.on_craft_complete() (records bill completion + resets state).
@ -459,18 +464,20 @@ func _tick_craft(t) -> void:
var has_secondary: bool = bill.recipe.ingredient2_type != &""
if has_primary:
if has_secondary:
# Two-ingredient path: check buffer (ing1) + carry (ing2).
if not wb.has_deposited_input(bill.recipe.ingredient_type, 1):
# Two-ingredient path: check buffer (ing2) + carry (ing1 / primary).
# Validate that the full ingredient2_count has been deposited.
var ing2_count: int = int(bill.recipe.ingredient2_count) if int(bill.recipe.ingredient2_count) > 0 else 1
if not wb.has_deposited_input(bill.recipe.ingredient2_type, ing2_count):
Audit.log(
"job_runner",
"%s craft: ingredient1 not in workbench buffer — skipping" % pawn.pawn_name
"%s craft: ingredient2 buffer has %d/%d — skipping" % [pawn.pawn_name, wb.deposited_inputs.get(bill.recipe.ingredient2_type, 0), ing2_count]
)
t.done = true
return
if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient2_type:
if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient_type:
Audit.log(
"job_runner",
"%s craft: wrong or missing ingredient2 — skipping" % pawn.pawn_name
"%s craft: wrong or missing primary ingredient (expect %s) — skipping" % [pawn.pawn_name, bill.recipe.ingredient_type]
)
t.done = true
return
@ -497,7 +504,10 @@ func _tick_craft(t) -> void:
return
# tick_craft() advances the progress counter and returns true when done.
var craft_done: bool = wb.tick_craft()
# Sick pawn coin-flip: skip this tick's progress at a rate equal to the penalty.
var craft_done: bool = false
if randf() < _work_speed_mult():
craft_done = wb.tick_craft()
if not craft_done:
return # Still working — toil remains active.
@ -522,8 +532,11 @@ func _tick_craft(t) -> void:
if ingredient != null and is_instance_valid(ingredient):
ingredient.queue_free()
if bill.recipe.ingredient2_type != &"":
# ingredient2 was the carried item (consumed above); ingredient1 is in the buffer.
wb.consume_deposited_input(bill.recipe.ingredient_type, 1)
# New two-ingredient convention: ingredient2 was deposited in the buffer
# (potentially multiple stacks for ingredient2_count > 1), ingredient1
# (primary) was the carried item (consumed above via queue_free).
var ing2_count: int = int(bill.recipe.ingredient2_count) if int(bill.recipe.ingredient2_count) > 0 else 1
wb.consume_deposited_input(bill.recipe.ingredient2_type, ing2_count)
# Roll quality based on pawn skill for this recipe.
var skill_level: int = pawn.get_skill(bill.recipe.required_skill)
@ -974,6 +987,19 @@ func _resolve_patient(t):
# ── helpers ──────────────────────────────────────────────────────────────────
## Returns the effective work-speed multiplier for the current pawn.
## Currently only the SICK status reduces it. 1.0 = full speed; < 1.0 = slow.
## Used as a coin-flip gate: skip the per-tick work call at a rate proportional
## to the penalty so expected-value progress matches the multiplier exactly
## (no progress accumulator needed — the tick stream is already stochastic).
func _work_speed_mult() -> float:
if pawn == null:
return 1.0
if not pawn.has_method("_sick_speed_penalty"):
return 1.0
return clampf(1.0 - pawn._sick_speed_penalty(), 0.25, 1.0)
## Emit job_completed, log, and clear the job reference.
func _emit_complete() -> void:
var completed = job