diff --git a/autoload/save_system.gd b/autoload/save_system.gd index 103446b..57cb48d 100644 --- a/autoload/save_system.gd +++ b/autoload/save_system.gd @@ -519,6 +519,7 @@ func _spawn_crop(world_scene: Node, d: Dictionary) -> void: int(spec.get("stage", 0)) ) ent.stage_progress = int(spec.get("stage_progress", 0)) + ent._stage_accum = float(spec.get("stage_accum", 0.0)) func _spawn_bed(world_scene: Node, d: Dictionary) -> void: diff --git a/autoload/storyteller.gd b/autoload/storyteller.gd index c59aa3c..1e869d2 100644 --- a/autoload/storyteller.gd +++ b/autoload/storyteller.gd @@ -403,6 +403,18 @@ func _on_sim_tick_buffs(_tick: int) -> void: _prune_expired_buffs() +## Stochastic rounding for fractional drop counts. +## Converts a float drop amount to an int: floor(amt) drops are guaranteed; +## the fractional remainder is a probability of one extra drop. This way a +## multiplier of 1.25 on a base of 4 yields 5 drops 25% of the time and 4 +## drops 75% of the time — expected value exactly matches the multiplier. +static func multiply_drops(base: int, mult: float) -> int: + var raw: float = float(base) * mult + var guaranteed: int = int(floor(raw)) + var frac: float = raw - float(guaranteed) + return guaranteed + (1 if randf() < frac else 0) + + ## Convert a Category enum value to the snake-case StringName used in ## CATEGORY_COOLDOWN_DAYS and _category_last_fired. func _category_to_str(cat: EventDef.Category) -> StringName: diff --git a/scenes/ai/crafting_provider.gd b/scenes/ai/crafting_provider.gd index 2f11b9f..5a752e8 100644 --- a/scenes/ai/crafting_provider.gd +++ b/scenes/ai/crafting_provider.gd @@ -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) diff --git a/scenes/ai/job_runner.gd b/scenes/ai/job_runner.gd index eb33043..ea69c70 100644 --- a/scenes/ai/job_runner.gd +++ b/scenes/ai/job_runner.gd @@ -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 diff --git a/scenes/entities/big_rock.gd b/scenes/entities/big_rock.gd index 93123c6..2748b90 100644 --- a/scenes/entities/big_rock.gd +++ b/scenes/entities/big_rock.gd @@ -142,11 +142,17 @@ func on_mine_tick() -> void: ## Drop four stone Items (one per footprint tile) and free this node. Called ## by on_mine_tick() automatically; can also be called for scripted removal. func mined() -> void: - for ft in footprint_tiles(): + # Apply mine buff (veins_of_iron): multiply total stone drops with stochastic rounding, + # then distribute one item per footprint tile (first N tiles get 1 each; extras stack + # on the origin tile for simple overflow handling). + var total_drops: int = Storyteller.multiply_drops(STONE_DROPS_ON_MINE, Storyteller.get_buff_multiplier(&"mine")) + var tiles := footprint_tiles() + for i in total_drops: + var drop_tile: Vector2i = tiles[mini(i, tiles.size() - 1)] var item: Item = ITEM_SCENE.instantiate() get_parent().add_child(item) - item.setup(Item.TYPE_STONE, 1, ft) - Audit.log("big_rock", "mined 2×2 at %s; %d stone drops" % [origin_tile, STONE_DROPS_ON_MINE]) + item.setup(Item.TYPE_STONE, 1, drop_tile) + Audit.log("big_rock", "mined 2×2 at %s; %d stone drops (buff mult=%.2f)" % [origin_tile, total_drops, Storyteller.get_buff_multiplier(&"mine")]) if Audio != null: Audio.play_sfx(&"mine_tick") # BigRocks span 2×2 — clear the designation stamp from every footprint tile. diff --git a/scenes/entities/crop.gd b/scenes/entities/crop.gd index 494d585..c9cc17d 100644 --- a/scenes/entities/crop.gd +++ b/scenes/entities/crop.gd @@ -49,6 +49,10 @@ const _STAGE_H: int = 32 var stage: Stage = Stage.SOWN ## Progress within the current growth stage; 0..STAGE_TICKS. var stage_progress: int = 0 +## Float accumulator for sub-tick growth when crop_growth buff is active. +## Carries fractional progress between ticks so the buff has the correct +## expected-value effect even though stage_progress is stored as int. +var _stage_accum: float = 0.0 # Phase 13 — "no growth indoors" rule. True once we've logged the first # indoor detection for this crop instance so we don't flood the audit log. @@ -106,9 +110,11 @@ func on_harvest_tick() -> void: if not is_harvestable(): return var item_type := _harvest_output_for(crop_kind) + var base_qty: int = 1 + var drop_qty: int = Storyteller.multiply_drops(base_qty, Storyteller.get_buff_multiplier(&"harvest_yield")) var it: Item = ITEM_SCENE.instantiate() get_parent().add_child(it) - it.setup(item_type, 1, tile) + it.setup(item_type, drop_qty, tile) # Carry the crop_kind through as a visual subtype so wheat / corn / # potato / strawberry each render distinctly, while item_type stays # generic (TYPE_GRAIN / TYPE_VEGETABLE) for stockpile filter purposes. @@ -149,7 +155,13 @@ func _on_sim_tick(_n: int) -> void: # Crop has moved outdoors or was never indoors — reset the log flag so a # future re-roofing produces another audit line. _logged_indoor = false - stage_progress += 1 + # Apply crop_growth buff: accumulate fractional progress each tick so the + # expected-value growth rate exactly matches the multiplier. + var growth_mult: float = Storyteller.get_buff_multiplier(&"crop_growth") + _stage_accum += growth_mult + var whole: int = int(floor(_stage_accum)) + _stage_accum -= float(whole) + stage_progress += whole if stage_progress >= STAGE_TICKS: stage_progress = 0 stage = (int(stage) + 1) as Stage @@ -169,6 +181,7 @@ func to_dict() -> Dictionary: "crop_kind": String(crop_kind), "stage": int(stage), "stage_progress": stage_progress, + "stage_accum": _stage_accum, } @@ -182,6 +195,7 @@ static func from_dict(d: Dictionary) -> Dictionary: "crop_kind": StringName(d.get("crop_kind", "wheat")), "stage": int(d.get("stage", Stage.SOWN)), "stage_progress": int(d.get("stage_progress", 0)), + "stage_accum": float(d.get("stage_accum", 0.0)), } diff --git a/scenes/entities/rock.gd b/scenes/entities/rock.gd index 3dc4ea6..e9bab92 100644 --- a/scenes/entities/rock.gd +++ b/scenes/entities/rock.gd @@ -117,11 +117,12 @@ func on_mine_tick() -> void: ## Drop stone Item(s) and free this node. Called automatically by on_mine_tick() ## but also accessible for scripted removal (debug, storyteller events). func mined() -> void: - # Single drop lands on the rock's own tile. + # Apply mine buff (veins_of_iron): multiply stone drops with stochastic rounding. + var drop_count: int = Storyteller.multiply_drops(STONE_DROPS_ON_MINE, Storyteller.get_buff_multiplier(&"mine")) var item: Item = ITEM_SCENE.instantiate() get_parent().add_child(item) - item.setup(Item.TYPE_STONE, 1, tile) - Audit.log("rock", "mined at %s; %d stone drop" % [tile, STONE_DROPS_ON_MINE]) + item.setup(Item.TYPE_STONE, drop_count, tile) + Audit.log("rock", "mined at %s; %d stone drop(s) (buff mult=%.2f)" % [tile, drop_count, Storyteller.get_buff_multiplier(&"mine")]) if Audio != null: Audio.play_sfx(&"mine_tick") World.clear_designation_at(tile) diff --git a/scenes/entities/tree.gd b/scenes/entities/tree.gd index 8d5919f..b6c4115 100644 --- a/scenes/entities/tree.gd +++ b/scenes/entities/tree.gd @@ -241,14 +241,16 @@ func on_chop_tick() -> void: ## Drop wood Items and free this node. Called by on_chop_tick() automatically, ## but also accessible for scripted felling (debug, storyteller events). func fell() -> void: - var drop_tiles := _pick_drop_tiles() + # Apply chop buff (lumberjacks_luck): multiply total wood drops with stochastic rounding. + var total_drops: int = Storyteller.multiply_drops(WOOD_DROPS_ON_FELL, Storyteller.get_buff_multiplier(&"chop")) + var drop_tiles := _pick_drop_tiles_count(total_drops) var drops_count := 0 for drop_tile in drop_tiles: var item: Item = ITEM_SCENE.instantiate() get_parent().add_child(item) item.setup(Item.TYPE_WOOD, STACK_SIZE_PER_DROP, drop_tile) drops_count += 1 - Audit.log("tree", "felled at %s; %d wood drops" % [tile, drops_count]) + Audit.log("tree", "felled at %s; %d wood drops (buff mult=%.2f)" % [tile, drops_count, Storyteller.get_buff_multiplier(&"chop")]) if Audio != null: Audio.play_sfx(&"tree_fell") World.clear_designation_at(tile) @@ -310,10 +312,12 @@ func _draw() -> void: # ── helpers ─────────────────────────────────────────────────────────────────── -## Returns up to WOOD_DROPS_ON_FELL tile positions for wood drops. -## Prefers the tree's own tile then walkable 4-neighbours; falls back to the -## tree tile for any remaining drops when neighbours are scarce. -func _pick_drop_tiles() -> Array[Vector2i]: +## Returns `count` tile positions for wood drops. +## Prefers the tree's own tile then walkable 4-neighbours; fills any remaining +## slots with the tree tile if neighbours are scarce. +## Previously took an implicit count of WOOD_DROPS_ON_FELL; now accepts an +## explicit count so the chop buff can request more drops. +func _pick_drop_tiles_count(count: int) -> Array[Vector2i]: var chosen: Array[Vector2i] = [] # First drop always goes on the tree's tile itself. @@ -322,14 +326,14 @@ func _pick_drop_tiles() -> Array[Vector2i]: # Remaining drops prefer walkable neighbours. var offsets: Array[Vector2i] = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] for offset in offsets: - if chosen.size() >= WOOD_DROPS_ON_FELL: + if chosen.size() >= count: break var candidate: Vector2i = tile + offset if World.pathfinder != null and World.pathfinder.is_walkable(candidate): chosen.append(candidate) - # Fill any remaining slots with the tree tile (all 3 land there if boxed in). - while chosen.size() < WOOD_DROPS_ON_FELL: + # Fill any remaining slots with the tree tile (all land there if boxed in). + while chosen.size() < count: chosen.append(tile) return chosen diff --git a/scenes/pawn/pawn.gd b/scenes/pawn/pawn.gd index c3a838a..c3cad8d 100644 --- a/scenes/pawn/pawn.gd +++ b/scenes/pawn/pawn.gd @@ -467,6 +467,17 @@ func _check_death() -> void: queue_free() +## Returns the work-speed penalty fraction from the SICK status (0.0 if not sick). +## severity 1 → 0.25 penalty (75% speed), severity 2 → 0.50, severity 3 → 0.75. +## The resulting multiplier is clamped to at least 0.25 so pawns never stop +## working entirely from illness alone (design.md — sick pawns work slowly, not stop). +func _sick_speed_penalty() -> float: + for s in statuses: + if s.kind == Status.Kind.SICK: + return clampf(0.25 * float(s.severity), 0.0, 0.75) + return 0.0 + + ## Returns the pawn's current level (0–10) for the given skill. ## Returns 0 for unknown skills so callers need no nil-guard. func get_skill(skill: StringName) -> int: @@ -1069,7 +1080,8 @@ func _on_sim_tick(_tick_number: int) -> void: hunger = maxf(0.0, hunger - HUNGER_DECAY_PER_TICK) # Phase 8 — decay sleep before orchestration so the AI sees the updated value # this tick and can immediately seek a bed once sleep < 30. - sleep = maxf(0.0, sleep - SLEEP_DECAY_PER_TICK) + # sleep_decay buff (summer): multiplier > 1.0 → faster depletion. + sleep = maxf(0.0, sleep - SLEEP_DECAY_PER_TICK * Storyteller.get_buff_multiplier(&"sleep_decay")) # Phase 8 — process thoughts AFTER hunger/sleep decay so is_hungry() / is_tired() # reflect the freshly-decayed values when _sync_persistent_thought fires. _process_thoughts()