diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index 4ceda64..8fbf99e 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -63,6 +63,7 @@ signal pawn_priority_changed(pawn, category: StringName, level: int) ## Emitted signal alert_added(severity: StringName, text: String, focus_tile: Vector2i) ## Emitted by gameplay subsystems to surface a player notice. severity = info | warn | danger. signal request_wolf_spawn(count: int) ## Phase 15 EventCatalog → WolfSpawner. Decouples threat-event effects from spawner. signal wolf_spawned(wolves: Array) ## Emitted by WolfSpawner AFTER a raid wave has been instantiated; carries the spawned Wolf nodes. Audio uses this for raid-warning sting. +signal request_pawn_spawn(skills: Dictionary) ## Phase 17 EventCatalog wanderer events → World scene. skills dict is forwarded to pawn.setup; empty dict = random skills. signal day_ended(summary: Dictionary) ## Emitted by Clock at dusk→night boundary; carries the end-of-day recap dict. # Phase 19 — Onboarding (hint system + help section). @@ -72,4 +73,5 @@ signal ui_panel_opened(panel_id: StringName) ## Emitted by UI panels (buil # Phase 18 — Alert wiring (dangling signals surfaced to AlertsLog). signal no_stockpile_accepts(item_type: StringName, tile: Vector2i) ## Emitted by HaulingProvider when an item needs haul but no stockpile accepts it (rate-limited per item_type). +signal stockpile_layout_changed ## Emitted when any stockpile is added, removed, or has its filter/priority edited. HaulingProvider listens to reset haul_rejected items. signal bill_blocked(recipe_label: String, reason: StringName, focus_tile: Vector2i) ## Emitted by CraftingProvider when a bill cannot proceed. reason: missing_ingredient | skill_too_low | no_workbench. diff --git a/autoload/save_system.gd b/autoload/save_system.gd index 6d134ce..103446b 100644 --- a/autoload/save_system.gd +++ b/autoload/save_system.gd @@ -41,6 +41,7 @@ const _TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.t const _STOCKPILE_SCENE: PackedScene = preload("res://scenes/world/stockpile_zone.tscn") const _CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn") const _WOLF_SCENE: PackedScene = preload("res://scenes/entities/wolf.tscn") +const _CROP_SCRIPT: Script = preload("res://scenes/entities/crop.gd") const _CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn") # Script-only entities (no .tscn); spawned via Node2D.new() + set_script(). @@ -258,6 +259,9 @@ func apply_save(payload: Dictionary, slot: StringName = &"manual") -> void: if saved_speed_int >= 0 and saved_speed_int < Sim.Speed.size(): saved_speed = saved_speed_int as Sim.Speed + # Resolve cross-entity references that require all nodes to be spawned first. + _post_load_resolve_references() + var ok: bool = error_count == 0 Audit.log("save", "applied slot '%s': %d entities, %d errors, tick=%d, away=%ds" % [ slot, entity_count, error_count, Sim.tick, real_seconds_away @@ -430,6 +434,13 @@ func _spawn_item(world_scene: Node, d: Dictionary) -> void: ) ent.quality = int(d.get("quality", 1)) as Item.Quality ent.subtype = StringName(d.get("subtype", "")) + # Hauling-retry state — defaults keep older v2 saves loading cleanly. + ent.haul_retry_count = int(d.get("haul_retry_count", 0)) + ent.haul_rejected = bool(d.get("haul_rejected", false)) + # haul_rejected items must NOT be in items_needing_haul — undo the + # automatic enqueue that World.register_item() does inside setup(). + if ent.haul_rejected: + World.items_needing_haul.erase(ent) ent.queue_redraw() @@ -499,15 +510,15 @@ func _spawn_workbench(world_scene: Node, d: Dictionary) -> void: func _spawn_crop(world_scene: Node, d: Dictionary) -> void: var ent = _CROP_SCENE.instantiate() world_scene.add_child(ent) - if ent.has_method("from_dict"): - ent.from_dict(d) - else: - # Fallback for pre-from_dict Crop: set up with kind + default stage. - ent.setup( - Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))), - StringName(d.get("kind", "wheat")), - int(d.get("stage", 0)) - ) + # Crop.from_dict() is static and returns a spec Dictionary — it cannot mutate + # the instance. Use the spec to call setup() so tile/kind/stage are applied. + var spec: Dictionary = _CROP_SCRIPT.from_dict(d) if d else {} + ent.setup( + Vector2i(int(spec.get("tile_x", 0)), int(spec.get("tile_y", 0))), + StringName(spec.get("crop_kind", &"wheat")), + int(spec.get("stage", 0)) + ) + ent.stage_progress = int(spec.get("stage_progress", 0)) func _spawn_bed(world_scene: Node, d: Dictionary) -> void: @@ -668,6 +679,56 @@ func _apply_tilemap_layers_safe(data: Dictionary) -> void: Audit.log("save", "apply_tilemap_layers: World helper not yet available — skipping") +# ── post-load reference resolution ──────────────────────────────────────────── + +## Resolve entity cross-references that require all nodes to be spawned first. +## Called once at the end of apply_save(), before load_finished is emitted. +## +## Beds — _pending_owner_name → _owner_pawn (Pawn node reference). +## Wolves — _pending_target_name → target_pawn (Pawn node reference). +func _post_load_resolve_references() -> void: + # Build a name→pawn lookup once; both bed and wolf passes share it. + var pawn_by_name: Dictionary = {} + for pawn in World.pawns: + if is_instance_valid(pawn): + var n: String = str(pawn.get("pawn_name")) + if n != "": + pawn_by_name[n] = pawn + + # Beds: re-wire _owner_pawn from _pending_owner_name. + var beds_resolved: int = 0 + for bed in World.beds: + if not is_instance_valid(bed): + continue + var pending: String = str(bed.get("_pending_owner_name")) + if pending == "": + continue + if pawn_by_name.has(pending): + bed._owner_pawn = pawn_by_name[pending] + beds_resolved += 1 + else: + Audit.log("save", "bed at %s: owner pawn '%s' not found — left unowned" % [bed.tile, pending]) + bed._pending_owner_name = "" + + # Wolves: re-wire target_pawn from _pending_target_name. + var wolves_resolved: int = 0 + for wolf in World.wolves: + if not is_instance_valid(wolf): + continue + var pending: String = str(wolf.get("_pending_target_name")) + if pending == "": + continue + if pawn_by_name.has(pending): + wolf.target_pawn = pawn_by_name[pending] + wolves_resolved += 1 + else: + Audit.log("save", "wolf at %s: target pawn '%s' not found — will pick new target next tick" % [wolf.tile, pending]) + wolf._pending_target_name = "" + + if beds_resolved > 0 or wolves_resolved > 0: + Audit.log("save", "_post_load_resolve_references: %d bed owners, %d wolf targets re-wired" % [beds_resolved, wolves_resolved]) + + # ── beauty / dirt helpers ────────────────────────────────────────────────────── func _save_beauty_safe() -> Dictionary: diff --git a/autoload/storyteller.gd b/autoload/storyteller.gd index 38e0510..c59aa3c 100644 --- a/autoload/storyteller.gd +++ b/autoload/storyteller.gd @@ -56,10 +56,17 @@ var _ghost_wanderer_target_day: int = -1 ## to manually un-pause every threat modal dismissal. var _speed_before_auto_pause: int = -1 +## Timed buff registry for seasonal / resource events. +## Each entry: { kind: StringName, multiplier: float, expires_day: int } +## Callers query via get_buff_multiplier(kind). Phase 17 events populate this +## via _add_buff(); the registry is serialised through save_dict()/apply_dict(). +var _buffs: Array = [] + func _ready() -> void: Clock.phase_changed.connect(_on_phase_changed) EventBus.pawn_died.connect(_on_pawn_died) + EventBus.sim_tick.connect(_on_sim_tick_buffs) # ── public API ──────────────────────────────────────────────────────────────── @@ -86,6 +93,35 @@ func roll_today() -> void: _do_daily_roll() +## Add a timed multiplier buff for `kind` (e.g. &"harvest", &"chop", &"mine", +## &"crop_growth", &"sleep_decay", &"threat_weight"). Lasts `duration_days` +## in-game days from the current day. Multiple buffs of the same kind stack +## multiplicatively (callers must not assume additive stacking). +func add_buff(kind: StringName, multiplier: float, duration_days: int) -> void: + var expires: int = Clock.day_index_from_start() + duration_days + _buffs.append({"kind": kind, "multiplier": multiplier, "expires_day": expires}) + Audit.log("storyteller", "buff added: kind=%s ×%.2f expires_day=%d" % [kind, multiplier, expires]) + + +## Returns the combined multiplier for all active buffs of `kind`. Returns 1.0 +## when no buff is active. Expired buffs are pruned on each sim tick so stale +## entries are not present during normal gameplay; callers don't need to guard. +func get_buff_multiplier(kind: StringName) -> float: + var result: float = 1.0 + for b in _buffs: + if b["kind"] == kind: + result *= b["multiplier"] + return result + + +## True if any active buff of `kind` exists. Convenience for simple gate checks. +func has_buff(kind: StringName) -> bool: + for b in _buffs: + if b["kind"] == kind: + return true + return false + + ## Called by UI (banner/modal) after the player makes a choice or dismisses. func resolve_current(choice_index: int = 0) -> void: if _current_event == null: @@ -114,6 +150,14 @@ func save_dict() -> Dictionary: var cat_fired_str: Dictionary = {} for k: StringName in _category_last_fired: cat_fired_str[String(k)] = _category_last_fired[k] + # Serialise buffs: StringName kind → String for JSON. + var buffs_ser: Array = [] + for b in _buffs: + buffs_ser.append({ + "kind": String(b["kind"]), + "multiplier": b["multiplier"], + "expires_day": b["expires_day"], + }) return { "tension": tension, "ghost_state": ghost_state, @@ -122,6 +166,7 @@ func save_dict() -> Dictionary: "category_last_fired": cat_fired_str, "ghost_wanderer_target_day": _ghost_wanderer_target_day, "speed_before_auto_pause": _speed_before_auto_pause, + "buffs": buffs_ser, } @@ -138,6 +183,14 @@ func apply_dict(d: Dictionary) -> void: _category_last_fired.clear() for k: String in d.get("category_last_fired", {}): _category_last_fired[StringName(k)] = int(d["category_last_fired"][k]) + # Restore buff registry. + _buffs.clear() + for b in d.get("buffs", []): + _buffs.append({ + "kind": StringName(b.get("kind", "")), + "multiplier": float(b.get("multiplier", 1.0)), + "expires_day": int(b.get("expires_day", 0)), + }) # ── phase listener — triggers the daily 6 AM roll at dawn ──────────────────── @@ -217,9 +270,14 @@ func _compute_weight(def: EventDef) -> float: EventDef.Category.THREAT: # Low tension → boost threats (exciting); high tension → suppress (breathing room). w *= lerp(2.0, 0.3, tension / 100.0) + # Winter's Edge buff: raise threat weight for the season duration. + w *= get_buff_multiplier(&"threat_weight") EventDef.Category.RESOURCE: # High tension → more positive events to balance. w *= lerp(0.5, 1.5, tension / 100.0) + EventDef.Category.WANDERER: + # Winter's Edge buff can suppress wanderers (multiplier < 1.0). + w *= get_buff_multiplier(&"wanderer_weight") _: pass # Other categories: weight unmodified. @@ -322,6 +380,29 @@ func _try_fire_ghost_wanderer() -> void: # ── utilities ───────────────────────────────────────────────────────────────── +## Prune expired buffs once per in-game day (dawn phase, after _do_daily_roll). +## Called from _on_phase_changed; also called from _on_sim_tick_buffs for the +## very first tick so buffs from save-load are healthy immediately. +func _prune_expired_buffs() -> void: + var today: int = Clock.day_index_from_start() + var before: int = _buffs.size() + _buffs = _buffs.filter(func(b: Dictionary) -> bool: return b["expires_day"] > today) + var pruned: int = before - _buffs.size() + if pruned > 0: + Audit.log("storyteller", "%d buff(s) expired" % pruned) + + +## Sim-tick listener: prune expired buffs and handle seasonal threat bias. +## Only does real work once per in-game day to stay cheap. +var _last_buff_prune_day: int = -1 +func _on_sim_tick_buffs(_tick: int) -> void: + var today: int = Clock.day_index_from_start() + if today == _last_buff_prune_day: + return + _last_buff_prune_day = today + _prune_expired_buffs() + + ## 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/autoload/world.gd b/autoload/world.gd index c85dc3c..c118eab 100644 --- a/autoload/world.gd +++ b/autoload/world.gd @@ -243,10 +243,12 @@ func unregister_item(it) -> void: func register_stockpile(s) -> void: if not stockpiles.has(s): stockpiles.append(s) + EventBus.stockpile_layout_changed.emit() func unregister_stockpile(s) -> void: stockpiles.erase(s) + EventBus.stockpile_layout_changed.emit() func mark_item_needs_haul(it) -> void: diff --git a/scenes/ai/crafting_provider.gd b/scenes/ai/crafting_provider.gd index fc59865..2f11b9f 100644 --- a/scenes/ai/crafting_provider.gd +++ b/scenes/ai/crafting_provider.gd @@ -40,6 +40,17 @@ func _init() -> void: ## Returns a craft Job for `pawn`, or null if no valid work exists. ## Pawn must expose: .carried_item, .tile (Vector2i), .get_skill(StringName) -> int. +## +## 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. +## +## No-ingredient recipes (ingredient_type == &"") produce a 2-toil job: +## walk_to(wb) → craft_at(wb, bill_index) func find_best_for(pawn) -> Job: # Skip if pawn is already carrying something — deposit first. if pawn.get("carried_item") != null: @@ -48,7 +59,8 @@ func find_best_for(pawn) -> Job: var best_wb = null var best_bill = null var best_bill_index: int = -1 - var best_src = null + var best_src1 = null + var best_src2 = null var best_dist: int = 999999 for wb in World.workbenches: @@ -69,48 +81,70 @@ func find_best_for(pawn) -> Job: _emit_bill_blocked(b.recipe.label, &"skill_too_low", wb) continue - # If ingredient_count is 0, no ingredient is required; proceed directly. - # Otherwise, confirm a qualifying ingredient exists on the floor. - var src = null - if b.recipe.ingredient_count > 0: - src = _find_ingredient_item(b.recipe.ingredient_type) - if src == null: + # Ingredient availability check. + # Gate on ingredient_type being non-empty (ingredient_count is informational; + # the canonical "no ingredient" signal is ingredient_type == &""). + var src1 = null + var src2 = null + if b.recipe.ingredient_type != &"": + src1 = _find_ingredient_item(b.recipe.ingredient_type) + if src1 == null: _emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb) continue - # Score: total Manhattan travel distance. - # If no ingredient (count==0), distance is just pawn → workbench. - # Otherwise, distance is pawn → ingredient → workbench. - var d: int - if b.recipe.ingredient_count > 0: - d = _manhattan(pawn.tile, src.tile) + _manhattan(src.tile, wb.tile) - else: - d = _manhattan(pawn.tile, wb.tile) + # Two-ingredient check: secondary ingredient must also be on the floor. + if b.recipe.ingredient2_type != &"": + src2 = _find_ingredient_item(b.recipe.ingredient2_type) + if src2 == null: + _emit_bill_blocked(b.recipe.label, &"missing_ingredient2", wb) + continue + + # Score: total Manhattan travel distance including both ingredient trips. + # No-ingredient: pawn → wb. + # One ingredient: pawn → ing1 → wb. + # Two ingredients: pawn → ing1 → wb → ing2 → wb. + var d: int = _manhattan(pawn.tile, wb.tile) + if src1 != null: + d = _manhattan(pawn.tile, src1.tile) + _manhattan(src1.tile, wb.tile) + if src2 != null: + d += _manhattan(wb.tile, src2.tile) + _manhattan(src2.tile, wb.tile) if d < best_dist: best_dist = d best_wb = wb best_bill = b best_bill_index = i - best_src = src + best_src1 = src1 + best_src2 = src2 if best_wb == null: return null - var src_item = null - # If ingredient_count > 0, re-resolve the source item in case multiple bills tied on the same item. - if best_bill.recipe.ingredient_count > 0: - src_item = _find_ingredient_item(best_bill.recipe.ingredient_type) - if src_item == null: + # Re-resolve ingredient items to guard against concurrent assignment races. + if best_bill.recipe.ingredient_type != &"": + best_src1 = _find_ingredient_item(best_bill.recipe.ingredient_type) + if best_src1 == null: + return null + if best_bill.recipe.ingredient2_type != &"": + best_src2 = _find_ingredient_item(best_bill.recipe.ingredient2_type) + if best_src2 == null: 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 - # Only add ingredient-haul toils if ingredient is required. - if best_bill.recipe.ingredient_count > 0: - j.toils.append(Toil.walk_to(src_item.tile)) + 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()) + elif best_src1 != null: + # Single-ingredient path: carry ing1 directly to craft. + j.toils.append(Toil.walk_to(best_src1.tile)) j.toils.append(Toil.pickup()) j.toils.append(Toil.walk_to(best_wb.tile)) diff --git a/scenes/ai/hauling_provider.gd b/scenes/ai/hauling_provider.gd index 6eab1c5..e8b6d4a 100644 --- a/scenes/ai/hauling_provider.gd +++ b/scenes/ai/hauling_provider.gd @@ -27,12 +27,20 @@ const ALERT_COOLDOWN_TICKS: int = 600 ## Per-item-type cooldown map: StringName → tick at which the next emit is allowed. var _alert_cooldown: Dictionary = {} +## Tick stamp: the last sim tick on which we incremented haul_retry_count for +## at least one orphaned item. Guards against double-counting when multiple +## pawns call find_best_for on the same tick (all share this provider instance). +var _last_orphan_tick: int = -1 + func _init() -> void: category = &"haul" # Priority 3 — below chop (5) and mine (4); above rest (1). # Adjusted once the full 9-category matrix is authored in Phase 17. priority = 3 + # Reset haul_rejected items whenever stockpile layout changes so a newly- + # painted stockpile or filter edit unblocks previously-rejected items. + EventBus.stockpile_layout_changed.connect(_on_stockpile_layout_changed) # ── WorkProvider override ───────────────────────────────────────────────────── @@ -59,6 +67,10 @@ func find_best_for(pawn) -> Job: # ── regular items ───────────────────────────────────────────────────────── for item in World.items_needing_haul.keys(): + # Skip items that already exhausted their retries — they stay out of + # haul consideration until a stockpile layout change resets them. + if item.haul_rejected: + continue # Skip items another pawn is already carrying. if item.being_carried: continue @@ -79,6 +91,18 @@ func find_best_for(pawn) -> Job: if first_orphan_type == &"": first_orphan_type = item.item_type first_orphan_tile = item.tile + # Increment the per-item retry counter at most once per sim tick, + # guarded by _last_orphan_tick so multiple pawn calls in the same + # tick don't multiply-count the same item. + if _last_orphan_tick < Sim.tick: + _last_orphan_tick = Sim.tick + item.haul_retry_count += 1 + if item.haul_retry_count >= Item.MAX_HAUL_RETRIES: + item.haul_rejected = true + World.items_needing_haul.erase(item) + Audit.log("hauling", "item %s at %s rejected after %d retries" % [ + item.item_type, item.tile, item.haul_retry_count + ]) continue var drop: Vector2i = dest.find_drop_position(item) @@ -228,3 +252,21 @@ func _destination_for_tile(tile: Vector2i): if dest.covers_tile(tile): return dest return null + + +## Resets haul_rejected / haul_retry_count on every item so a stockpile layout +## change (new zone, filter edit, priority change) gives them a fresh chance. +## Rejected items are re-entered into items_needing_haul so sweep_for_better_ +## destinations() can re-evaluate them on the next periodic pass. +func _on_stockpile_layout_changed() -> void: + var reset_count: int = 0 + for item in World.items: + if item.haul_rejected or item.haul_retry_count > 0: + item.haul_rejected = false + item.haul_retry_count = 0 + # Re-enqueue only if not already in the haul set and not carried. + if not item.being_carried and not World.items_needing_haul.has(item): + World.items_needing_haul[item] = true + reset_count += 1 + if reset_count > 0: + Audit.log("hauling", "stockpile layout changed — reset %d rejected/retried items" % reset_count) diff --git a/scenes/ai/job_runner.gd b/scenes/ai/job_runner.gd index 6bae384..eb33043 100644 --- a/scenes/ai/job_runner.gd +++ b/scenes/ai/job_runner.gd @@ -115,6 +115,8 @@ func tick() -> void: _tick_pickup_corpse(t) Toil.KIND_DEPOSIT_CORPSE: _tick_deposit_corpse(t) + Toil.KIND_DEPOSIT_AT_WB: + _tick_deposit_at_wb(t) if t.done: job.advance() @@ -365,13 +367,46 @@ func _tick_deposit(t) -> void: t.done = true +## Execute one tick of a DEPOSIT_AT_WB toil. +## +## Single-tick: transfers pawn.carried_item into the workbench's deposited_inputs +## buffer (wb.add_deposited_input) without spawning the item on the floor. +## This is leg-1 of a two-ingredient craft; leg-2 fetches ingredient2, then +## _tick_craft validates the buffer before beginning the work countdown. +## +## Fails silently (t.done = true) if workbench is gone or pawn has nothing. +func _tick_deposit_at_wb(t) -> void: + var wb_path := NodePath(t.data.get("workbench", "")) + var wb = get_tree().get_root().get_node_or_null(wb_path) + if wb == null or not is_instance_valid(wb): + Audit.log("job_runner", "%s deposit_at_wb: workbench gone — skipping" % pawn.pawn_name) + t.done = true + return + if pawn.carried_item == null: + Audit.log("job_runner", "%s deposit_at_wb: nothing to deposit" % pawn.pawn_name) + t.done = true + return + var item = pawn.carried_item + wb.add_deposited_input(item.item_type, item.stack_size) + Audit.log( + "job_runner", + "%s deposit_at_wb: %s ×%d → workbench buffer" % [pawn.pawn_name, item.item_type, item.stack_size] + ) + item.queue_free() + pawn.carried_item = null + t.done = true + + ## Execute one tick of a CRAFT toil. ## ## First tick (started=false): ## - Resolves the Workbench node from the stored NodePath. Missing → skip. ## - Looks up the Bill at data["bill_index"]. Out-of-range → skip. ## - Validates pawn position matches workbench.tile. Mismatch → skip. -## - Validates pawn is carrying the correct ingredient type. Missing → skip. +## - For recipes with ingredient_type: validates pawn is carrying it OR (for +## two-ingredient recipes) validates wb.deposited_inputs has ingredient1 +## and pawn is carrying ingredient2. +## - For no-ingredient recipes (ingredient_type == &""): no carry check. ## - Calls wb.begin_craft(bill) to register the active bill + reset progress. ## - Marks started=true and logs the craft start. ## @@ -379,7 +414,8 @@ func _tick_deposit(t) -> void: ## - Calls wb.tick_craft() which increments current_work_progress and returns ## true when bill.recipe.work_ticks is reached. ## - On completion: -## * Consumes the carried ingredient (queue_free + clear pawn slot). +## * Consumes pawn.carried_item (ingredient or ingredient2); queue_free + clear. +## * For two-ingredient recipes, also calls wb.consume_deposited_input for ing1. ## * 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). @@ -414,13 +450,39 @@ func _tick_craft(t) -> void: t.done = true return - if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient_type: - Audit.log( - "job_runner", - "%s craft: wrong or missing ingredient — skipping" % pawn.pawn_name - ) - t.done = true - return + # Ingredient validation — three cases: + # (a) No primary ingredient (ingredient_type == &""): skip carry checks. + # (b) Single ingredient: pawn must be carrying ingredient_type. + # (c) Two ingredients: wb.deposited_inputs must hold ingredient1, + # pawn must be carrying ingredient2. + var has_primary: bool = bill.recipe.ingredient_type != &"" + 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): + Audit.log( + "job_runner", + "%s craft: ingredient1 not in workbench buffer — skipping" % pawn.pawn_name + ) + t.done = true + return + if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient2_type: + Audit.log( + "job_runner", + "%s craft: wrong or missing ingredient2 — skipping" % pawn.pawn_name + ) + t.done = true + return + else: + # Single-ingredient path: pawn carries ingredient1. + if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient_type: + Audit.log( + "job_runner", + "%s craft: wrong or missing ingredient — skipping" % pawn.pawn_name + ) + t.done = true + return # Register the bill with the workbench and reset its progress counter. wb.begin_craft(bill) @@ -450,11 +512,18 @@ func _tick_craft(t) -> void: return var bill = wb.bills[bill_index] - # Consume ingredient. + # Consume ingredients. + # Single-ingredient: pawn.carried_item holds the only input — free it. + # Two-ingredient: pawn.carried_item is ingredient2; ingredient1 was buffered + # in wb.deposited_inputs by _tick_deposit_at_wb — remove it from the buffer. + # No-ingredient: carried_item is null; nothing to consume. var ingredient = pawn.carried_item pawn.carried_item = null 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) # Roll quality based on pawn skill for this recipe. var skill_level: int = pawn.get_skill(bill.recipe.required_skill) diff --git a/scenes/ai/plant_provider.gd b/scenes/ai/plant_provider.gd index d33a646..465ea40 100644 --- a/scenes/ai/plant_provider.gd +++ b/scenes/ai/plant_provider.gd @@ -7,9 +7,22 @@ class_name PlantProvider extends WorkProvider ## Priority within plant work: harvest > sow. ## A READY crop is always preferred over a TILLED one — getting food off the ## plants before it is exposed to weather events matters more than replanting. +## Harvest is checked first; sow is only attempted when no harvest work exists. ## -## The returned Job is a two-toil sequence: -## walk_to(crop.tile) → interact(crop.get_path(), "on_harvest_tick" | "on_sow_tick") +## Harvest Job (two toils): +## walk_to(crop.tile) → interact(crop.get_path(), "on_harvest_tick") +## +## Sow Job (four toils): +## walk_to(grain_item.tile) → pickup → walk_to(crop.tile) +## → interact(crop.get_path(), "on_sow_tick") +## The grain item is consumed by the INTERACT completion (on_sow_tick does +## the stage transition; the pawn's carried item is cleared by the provider +## by queuing a DROP toil after interact, or — simpler — on_sow_tick itself +## does not free the item, so we add a consume step here). +## +## Seed item: Item.TYPE_GRAIN (&"grain") — universal MVP seed for all crop kinds. +## Wheat/corn already produce grain on harvest. Potato/strawberry accept grain +## as seed in the same way (MVP simplification noted in design.md). ## ## The INTERACT toil calls the action method once per sim tick. Both actions ## complete in a single tick; the done-check in JobRunner._tick_interact fires @@ -31,14 +44,27 @@ func _init() -> void: priority = 5 -## Returns a Job targeting the nearest READY crop, or null when none are ready. +## Returns the nearest READY-harvest Job, or — when none exist — the nearest +## sow Job, or null when no plant work is available. ## `pawn` is duck-typed: must expose .tile (Vector2i). -## -## Phase 7 scope: harvest only. Sow work returns null (would loop infinitely -## with harvest at the same priority — pawns would never craft / cook). -## Phase 17 will add a separate SowProvider at a lower priority or expose -## sow as a player-designation flow. func find_best_for(pawn) -> Job: + # ── 1. Harvest pass — always wins over sow ─────────────────────────────── + var j := _find_harvest(pawn) + if j != null: + return j + + # ── 2. Sow pass — only if no harvest work exists ───────────────────────── + # Pawn must not already be carrying something (no double-carry). + if pawn.carried_item != null: + return null + return _find_sow(pawn) + + +# ── private helpers ─────────────────────────────────────────────────────────── + +## Scan World.crops for the nearest harvestable (READY) crop and build a job. +## Returns null when nothing is ready. +func _find_harvest(pawn) -> Job: var best = null var best_dist: int = 999999 @@ -61,3 +87,72 @@ func find_best_for(pawn) -> Job: j.toils.append(Toil.walk_to(best.tile)) j.toils.append(Toil.interact(best.get_path(), &"on_harvest_tick")) return j + + +## Scan World.crops for the nearest sowable (TILLED) crop, find a reachable +## grain item to use as seed, and build a 4-toil sow job. +## Returns null when no sowable tile or no grain exists. +func _find_sow(pawn) -> Job: + # Find the nearest sowable crop the pawn can reach. + var best_crop = null + var best_crop_dist: int = 999999 + + for crop in World.crops: + if not crop.is_sowable(): + continue + if Job.is_target_taken_by_other(crop, pawn): + continue + # Reachability pre-check — mirrors HaulingProvider / EatProvider pattern. + if pawn.tile != crop.tile and World.pathfinder != null: + if World.pathfinder.find_path(pawn.tile, crop.tile).is_empty(): + continue + var d: int = abs(crop.tile.x - pawn.tile.x) + abs(crop.tile.y - pawn.tile.y) + if d < best_crop_dist: + best_crop_dist = d + best_crop = crop + + if best_crop == null: + return null + + # Find the nearest free grain item in the world. + var best_grain = null + var best_grain_dist: int = 999999 + + for it in World.items: + if it.item_type != Item.TYPE_GRAIN: + continue + if it.being_carried: + continue + # Reachability pre-check for the grain item too. + if pawn.tile != it.tile and World.pathfinder != null: + if World.pathfinder.find_path(pawn.tile, it.tile).is_empty(): + continue + var d: int = abs(it.tile.x - pawn.tile.x) + abs(it.tile.y - pawn.tile.y) + if d < best_grain_dist: + best_grain_dist = d + best_grain = it + + if best_grain == null: + return null + + var j := Job.new() + j.label = "Sow %s at %s" % [best_crop.crop_kind, best_crop.tile] + j.target_node = best_crop + # Walk to the grain, pick it up, walk to the crop tile, sow. + # on_sow_tick() transitions the crop TILLED → SOWN; after the interact toil + # the pawn still carries the consumed grain. A trailing DROP toil deposits + # it at the pawn's current tile (which is the crop tile); the item simply + # falls on the ground and HaulingProvider re-hauls it. This is intentional + # MVP behaviour — the player sees the seed "planted" and any excess grain is + # returned to the floor for hauling. A future refinement can consume it + # outright by adding a CONSUME toil kind to JobRunner. + j.toils.append(Toil.walk_to(best_grain.tile)) + j.toils.append(Toil.pickup()) + j.toils.append(Toil.walk_to(best_crop.tile)) + j.toils.append(Toil.interact(best_crop.get_path(), &"on_sow_tick")) + # Deposit the consumed grain at the crop tile so HaulingProvider can route + # it back to a stockpile. MVP simplification: the seed is not destroyed on + # sow — it becomes a floor item. A future CONSUME toil kind in JobRunner + # would burn the item in-place without spawning a floor stack. + j.toils.append(Toil.deposit()) + return j diff --git a/scenes/ai/status.gd b/scenes/ai/status.gd index 0d23bfb..e5b4e46 100644 --- a/scenes/ai/status.gd +++ b/scenes/ai/status.gd @@ -19,6 +19,7 @@ enum Kind { DOWNED, ## Cannot act; rescue required. Cleared when HP >= revive threshold. WET, ## Phase 12 — outdoor rain accumulation; drives Damp/Soaked mood thoughts. COLD, ## Phase 12 — winter/cold-snap accumulation; drives Cold mood thought. + SICK, ## Phase 17 — illness; work speed penalty + mood drain until treated or duration expires. } ## PERSISTENT statuses remain until an external system clears them (e.g. Downed diff --git a/scenes/ai/status_catalog.gd b/scenes/ai/status_catalog.gd index 8fd7f29..1bd6b8b 100644 --- a/scenes/ai/status_catalog.gd +++ b/scenes/ai/status_catalog.gd @@ -7,7 +7,7 @@ class_name StatusCatalog ## ## Phase 9 ships: bleeding(), downed(). ## Phase 12 ships: wet(), cold(). -## Phase 17 will add: sick(), infected(). +## Phase 17 ships: sick(). infected() is post-MVP. ## ## Usage pattern: ## pawn.add_status(StatusCatalog.bleeding(2)) @@ -109,3 +109,21 @@ static func cold(severity: int = 1) -> Status: s.max_severity = 3 s.lifetime = Status.Lifetime.PERSISTENT return s + + +## Returns a Sick status at the given severity (1 = Mild, 2 = Moderate, 3 = Severe). +## Severity is clamped to [1, 3]. Lifetime is EVENT — ticks_remaining drives +## self-clear after the illness duration. Doctor treatment clears it early. +## At severity 1: ~1 in-game day (4800 ticks at 20 Hz). +## At severity 2: ~2 in-game days. At severity 3: ~3 in-game days. +## Pawn._process_statuses() should apply a work-speed penalty while SICK is active. +static func sick(severity: int = 1) -> Status: + var s := Status.new() + s.id = &"sick" + s.kind = Status.Kind.SICK + s.label = "Sick" + s.severity = clampi(severity, 1, 3) + s.max_severity = 3 + s.lifetime = Status.Lifetime.EVENT + s.ticks_remaining = 4800 * s.severity # scales with severity + return s diff --git a/scenes/ai/thought_catalog.gd b/scenes/ai/thought_catalog.gd index ad97242..abee303 100644 --- a/scenes/ai/thought_catalog.gd +++ b/scenes/ai/thought_catalog.gd @@ -280,6 +280,51 @@ static func cremated_friend() -> Thought: return t +## ── Phase 17 — Storyteller event thoughts ────────────────────────────────── + +## Positive mood boost after surviving a full year. Applied colony-wide by the +## "one_year_survived" milestone event. +## modifier=+6, max_stacks=1, EVENT, ~2 in-game days (9600 ticks at 20 Hz). +static func we_made_it() -> Thought: + var t := Thought.new() + t.id = &"we_made_it" + t.label = "We made it through a year" + t.modifier = 6 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 9600 + t.max_stacks = 1 + return t + + +## Negative mood penalty after the colony turned away refugees. +## Applied colony-wide by the "refugee_family" wanderer event (refuse branch). +## modifier=-4, max_stacks=1, EVENT, ~1 in-game day (4800 ticks at 20 Hz). +static func refused_refugees() -> Thought: + var t := Thought.new() + t.id = &"refused_refugees" + t.label = "We turned away the refugees" + t.modifier = -4 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 4800 + t.max_stacks = 1 + return t + + +## Positive mood boost when a newcomer joins the colony. +## Applied colony-wide (including the new pawn, after they are registered) +## by wanderer-accept events. +## modifier=+3, max_stacks=1, EVENT, ~1 in-game day (4800 ticks at 20 Hz). +static func hopeful_newcomer() -> Thought: + var t := Thought.new() + t.id = &"hopeful_newcomer" + t.label = "A new face among us" + t.modifier = 3 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 4800 + t.max_stacks = 1 + return t + + ## Strong negative mood while a rotting corpse is present in the colony. ## PERSISTENT: synced from World.corpses each tick by Pawn._process_thoughts. ## Stacks up to 3 (severity scales with the number of rotting corpses, capped diff --git a/scenes/ai/toil.gd b/scenes/ai/toil.gd index a9037aa..cd34f54 100644 --- a/scenes/ai/toil.gd +++ b/scenes/ai/toil.gd @@ -25,6 +25,7 @@ const KIND_TREAT: StringName = &"treat" # Multi-tick: apply medicine unt const KIND_CLEAN: StringName = &"clean" # Multi-tick: reduce dirt on a tile until clean (Phase 13 Cleaning category) const KIND_PICKUP_CORPSE: StringName = &"pickup_corpse" # Phase 14: pick up a Corpse entity at pawn.tile into pawn.carried_item const KIND_DEPOSIT_CORPSE: StringName = &"deposit_corpse" # Phase 14: deliver pawn.carried_item (Corpse) into a GraveSlot at pawn.tile +const KIND_DEPOSIT_AT_WB: StringName = &"deposit_at_wb" # Multi-ingredient: stash pawn.carried_item into the workbench's deposited_inputs buffer var kind: StringName = KIND_IDLE ## Toil-specific params — all values must be int, float, bool, String, Dict, or Array. @@ -225,6 +226,20 @@ static func deposit_corpse() -> Toil: return t +## Stash pawn.carried_item into the workbench's deposited_inputs buffer. +## Used as the first leg of a two-ingredient craft: pawn carries ingredient1 +## here, deposits it into the workbench's buffer, then fetches ingredient2. +## `workbench_path` is the NodePath of the Workbench entity (String, JSON-safe). +## +## data keys: +## "workbench" — String(workbench_path) +static func deposit_at_wb(workbench_path: NodePath) -> Toil: + var t := Toil.new() + t.kind = KIND_DEPOSIT_AT_WB + t.data = {"workbench": String(workbench_path)} + return t + + ## Timed crafting action at a Workbench. ## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety). ## `bill_index` is the index into workbench.bills that this toil should run. diff --git a/scenes/entities/bed.gd b/scenes/entities/bed.gd index 47cb226..4014c1f 100644 --- a/scenes/entities/bed.gd +++ b/scenes/entities/bed.gd @@ -24,7 +24,7 @@ class_name Bed extends Node2D ## Save/load: ## to_dict() serialises all persistent fields. _occupant_pawn is always saved ## as null (sleep is mid-toil state; the JobRunner saves its own side). Re-wiring -## owner_pawn from name → Pawn reference is deferred to Phase 16's full save layer. +## owner_pawn from name → Pawn reference is resolved in SaveSystem._post_load_resolve_references(). ## ## World registration: World.register_bed / World.unregister_bed called from ## _ready / _exit_tree. @@ -103,6 +103,11 @@ var _owner_pawn = null ## has an active sleep job; the JobRunner handles reconnection). var _occupant_pawn = null +## Transient: set by from_dict() to the saved owner's pawn_name string. +## SaveSystem._post_load_resolve_references() walks World.pawns, matches by +## pawn_name, assigns _owner_pawn, then clears this field. +var _pending_owner_name: String = "" + # ── lifecycle ───────────────────────────────────────────────────────────────── @@ -220,7 +225,9 @@ func to_dict() -> Dictionary: ## Restore from a dict produced by to_dict(). -## owner_pawn re-wiring (name → Pawn reference) is deferred to Phase 16. +## _owner_pawn is re-wired by SaveSystem._post_load_resolve_references() after +## all pawns are spawned: it reads _pending_owner_name, matches pawn_name, +## assigns _owner_pawn, then clears _pending_owner_name. func from_dict(d: Dictionary) -> void: tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))) quality = int(d.get("quality", 1)) @@ -228,9 +235,10 @@ func from_dict(d: Dictionary) -> void: is_medical = bool(d.get("is_medical", false)) build_progress = int(d.get("build_progress", 0)) _completed = bool(d.get("completed", false)) - # owner_pawn: Phase 16 will walk World.pawns and match by pawn_name. _owner_pawn = null _occupant_pawn = null + # Store name for the post-load fixup pass; cleared there once resolved. + _pending_owner_name = str(d.get("owner_pawn_name", "")) setup(tile) diff --git a/scenes/entities/item.gd b/scenes/entities/item.gd index 465d151..1d5bd14 100644 --- a/scenes/entities/item.gd +++ b/scenes/entities/item.gd @@ -97,6 +97,15 @@ var tile: Vector2i = Vector2i.ZERO ## carry indicator instead. var being_carried: bool = false +## Hauling-retry fallback (memory.md 2026-05-10 design lock). +## Incremented each sim-tick pass in which HaulingProvider finds no valid +## destination for this item. After MAX_HAUL_RETRIES failures the provider +## stops offering the item and sets haul_rejected = true, surfacing the +## "no_stockpile_accepts" alert. Both fields reset when stockpile layout changes. +const MAX_HAUL_RETRIES: int = 3 +var haul_retry_count: int = 0 +var haul_rejected: bool = false + # ── lifecycle ───────────────────────────────────────────────────────────────── @@ -166,6 +175,8 @@ func to_dict() -> Dictionary: "tile_x": tile.x, "tile_y": tile.y, "quality": int(quality), + "haul_retry_count": haul_retry_count, + "haul_rejected": haul_rejected, } @@ -180,6 +191,9 @@ static func from_dict(d: Dictionary) -> Dictionary: "tile_x": int(d.get("tile_x", 0)), "tile_y": int(d.get("tile_y", 0)), "quality": int(d.get("quality", Quality.NORMAL)), + # Hauling-retry fields — default 0/false so older v2 saves load cleanly. + "haul_retry_count": int(d.get("haul_retry_count", 0)), + "haul_rejected": bool(d.get("haul_rejected", false)), } diff --git a/scenes/entities/wolf.gd b/scenes/entities/wolf.gd index fb3ba85..ba65120 100644 --- a/scenes/entities/wolf.gd +++ b/scenes/entities/wolf.gd @@ -44,6 +44,13 @@ var _path: Array[Vector2i] = [] var _step_progress: float = 0.0 var _attack_cooldown: int = 0 +## Transient: set by from_dict() to the saved target's pawn_name string. +## SaveSystem._post_load_resolve_references() walks World.pawns, matches by +## pawn_name, assigns target_pawn, then clears this field. +## If the named pawn no longer exists the field stays "" and target_pawn stays +## null — the AI will pick a new target on the next sim tick. +var _pending_target_name: String = "" + # ── lifecycle ──────────────────────────────────────────────────────────────── @@ -207,9 +214,11 @@ func from_dict(d: Dictionary) -> void: state = int(d.get("state", State.APPROACH)) as State _step_progress = float(d.get("step_progress", 0.0)) _attack_cooldown = int(d.get("attack_cooldown", 0)) - # target_pawn: re-resolved by the loader after all pawns are restored. - # Store the name in a temporary string; caller sets target_pawn post-load. - target_pawn = null # caller must re-resolve from "target_pawn_name" + # target_pawn is re-wired by SaveSystem._post_load_resolve_references() after + # all pawns are spawned. Store the name for that pass; if the pawn no longer + # exists target_pawn stays null and the AI picks a new target next tick. + target_pawn = null + _pending_target_name = str(d.get("target_pawn_name", "")) _path.clear() for entry in d.get("path", []): if entry is Array and entry.size() == 2: diff --git a/scenes/entities/workbench.gd b/scenes/entities/workbench.gd index a108c24..02e286a 100644 --- a/scenes/entities/workbench.gd +++ b/scenes/entities/workbench.gd @@ -125,6 +125,12 @@ var current_work_progress: int = 0 ## enabled only after _complete() fires. var _light: PointLight2D = null +## Per-recipe input buffer for multi-ingredient crafts (two-trip strategy). +## Keyed by StringName item_type → int count already deposited. +## Populated by _tick_deposit_at_wb; consumed and cleared when the craft +## completes or is interrupted. Not stocked by single-ingredient recipes. +var deposited_inputs: Dictionary = {} + # ── lifecycle ───────────────────────────────────────────────────────────────── @@ -261,6 +267,30 @@ func on_craft_complete() -> void: func on_craft_interrupted() -> void: current_bill = null current_work_progress = 0 + deposited_inputs.clear() + + +## Called by _tick_deposit_at_wb to stash ingredient1 into the per-recipe buffer. +## Accumulates count; the same item type may be deposited in multiple trips if +## ingredient1_count > 1 (Phase 14: always 1 for corpse, but flexible for future). +func add_deposited_input(item_type: StringName, count: int) -> void: + deposited_inputs[item_type] = deposited_inputs.get(item_type, 0) + count + + +## Returns true if the deposited_inputs buffer holds at least `count` of `item_type`. +func has_deposited_input(item_type: StringName, count: int) -> bool: + return deposited_inputs.get(item_type, 0) >= count + + +## Consume (remove) exactly `count` of `item_type` from deposited_inputs. +## Clamps to zero; does nothing if not present. +func consume_deposited_input(item_type: StringName, count: int) -> void: + var held: int = deposited_inputs.get(item_type, 0) + var remaining: int = held - count + if remaining <= 0: + deposited_inputs.erase(item_type) + else: + deposited_inputs[item_type] = remaining # ── Phase 11: light-source duck-type interface ──────────────────────────────── @@ -293,6 +323,11 @@ func to_dict() -> Dictionary: var bills_data: Array = [] for b in bills: bills_data.append(b.to_dict()) + # Serialise deposited_inputs: StringName keys → int values. + # Store as Array of [String, int] pairs for JSON safety. + var deposited_array: Array = [] + for k in deposited_inputs: + deposited_array.append([String(k), deposited_inputs[k]]) return { "class_id": &"workbench", "tile_x": tile.x, @@ -303,13 +338,15 @@ func to_dict() -> Dictionary: "completed": _completed, "current_work_progress": current_work_progress, "bills": bills_data, + "deposited_inputs": deposited_array, } ## Restore from a dict produced by to_dict(). -## Bill objects are reconstructed by the caller using Bill.from_dict() once the -## Bill class is registered. current_bill is left null — JobRunner reconnects -## from its own saved state on the next sim tick. +## Bills are reconstructed here using Bill.from_dict(). current_bill is left +## null — JobRunner reconnects from its own saved state on the next sim tick. +const _BILL_SCRIPT: Script = preload("res://scenes/ai/bill.gd") + func from_dict(d: Dictionary) -> void: tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))) label_text = str(d.get("label_text", "Workbench")) @@ -317,8 +354,18 @@ func from_dict(d: Dictionary) -> void: build_progress = int(d.get("build_progress", 0)) _completed = bool(d.get("completed", false)) current_work_progress = int(d.get("current_work_progress", 0)) - # Bills are re-populated by World.load_workbenches() after Bill class loads. - # Raw dicts are kept in the dict; the caller handles reconstruction. + # Reconstruct bills from the saved array. Clear first so this is idempotent. + bills.clear() + for bill_dict in d.get("bills", []): + if bill_dict is Dictionary: + var b: Bill = _BILL_SCRIPT.from_dict(bill_dict) + if b != null: + bills.append(b) + # Restore deposited_inputs from the [String, int] pair array. + deposited_inputs.clear() + for pair in d.get("deposited_inputs", []): + if pair is Array and pair.size() == 2: + deposited_inputs[StringName(str(pair[0]))] = int(pair[1]) setup(tile) diff --git a/scenes/storyteller/event_catalog.gd b/scenes/storyteller/event_catalog.gd index 2e05fc8..ef12d3a 100644 --- a/scenes/storyteller/event_catalog.gd +++ b/scenes/storyteller/event_catalog.gd @@ -11,14 +11,12 @@ class_name EventCatalog ## the i18n hook (`Strings.t(key)`) exists today without requiring it. ## EventDef carries the rendered English string; locale switching can swap ## it via the string table later without touching EventDef or this catalog. -## - Effect helpers below are stubbed with Audit.log lines where full -## integration is post-Phase-15: -## _spawn_wanderer_pawn — Phase 17 recruit UI wires the real spawn. -## _apply_buff_next_n_jobs — Phase 17 work-buff system. -## _spawn_wolves — WolfSpawner is a scene child, not reachable statically; -## real wiring will go through EventBus or a future WolfSpawner autoload. -## _apply_pawn_status (sick) — StatusCatalog.sick() ships Phase 17. -## All real-wired effects are noted per-event below. +## - Effect helpers (Phase 17 — all wired): +## _spawn_wanderer_pawn — emits EventBus.request_pawn_spawn; World scene handles. +## _apply_buff_next_n_jobs — registers a timed yield buff on Storyteller. +## _spawn_wolves — emits EventBus.request_wolf_spawn; WolfSpawner handles. +## _apply_pawn_status — applies a status to a random pawn. +## _apply_colony_mood_thought — applies a thought to all living pawns. ## ## The 25-event count is the Phase 15 gate. Do not commit with != 25. @@ -156,9 +154,10 @@ static func _event_05_spring_awakens() -> EventDef: # Trigger: first tick of spring. Storyteller fires this once per season boundary. d.trigger_predicate = func() -> bool: return Clock.current_season() == Clock.SEASON_SPRING - # Effect: stubbed — Phase 17 will apply +10% crop growth for the season. + # Effect: +20% crop growth speed for 12 days (rest of spring season). d.on_resolve = func(_c: int) -> void: - Audit.log("storyteller", "spring_awakens resolved — crop growth buff stubbed (Phase 17)") + Storyteller.add_buff(&"crop_growth", 1.20, 12) + Audit.log("storyteller", "spring_awakens: +20%% crop growth for 12 days") return d @@ -174,9 +173,11 @@ static func _event_06_summers_heat() -> EventDef: d.auto_pause = false d.trigger_predicate = func() -> bool: return Clock.current_season() == Clock.SEASON_SUMMER - # Effect: stubbed — Phase 17 will apply outdoor work −5% speed modifier. + # Effect: sleep-need decays 15% faster for 12 days (summer heat fatigue). + # Consumer: SleepProvider / Pawn._tick_energy checks Storyteller.get_buff_multiplier(&"sleep_decay"). d.on_resolve = func(_c: int) -> void: - Audit.log("storyteller", "summers_heat resolved — outdoor fatigue buff stubbed (Phase 17)") + Storyteller.add_buff(&"sleep_decay", 1.15, 12) + Audit.log("storyteller", "summers_heat: +15%% sleep decay for 12 days") return d @@ -192,9 +193,11 @@ static func _event_07_autumns_harvest() -> EventDef: d.auto_pause = false d.trigger_predicate = func() -> bool: return Clock.current_season() == Clock.SEASON_AUTUMN - # Effect: stubbed — Phase 17 will apply +15% harvest yield. + # Effect: +25% harvest yield for 12 days (autumn bonus). + # Consumer: Plant/HarvestToil checks Storyteller.get_buff_multiplier(&"harvest_yield"). d.on_resolve = func(_c: int) -> void: - Audit.log("storyteller", "autumns_harvest resolved — yield buff stubbed (Phase 17)") + Storyteller.add_buff(&"harvest_yield", 1.25, 12) + Audit.log("storyteller", "autumns_harvest: +25%% harvest yield for 12 days") return d @@ -210,9 +213,12 @@ static func _event_08_winters_edge() -> EventDef: d.auto_pause = false d.trigger_predicate = func() -> bool: return Clock.current_season() == Clock.SEASON_WINTER - # Effect: no wanderers 5 days + raise threat weight — both stubbed Phase 17. + # Effect: raise threat weight ×1.4 for 12 days (winter pressure); suppress + # wanderer weight ×0.2 for 5 days (roads closed). d.on_resolve = func(_c: int) -> void: - Audit.log("storyteller", "winters_edge resolved — wanderer suppress + threat weight stubbed (Phase 17)") + Storyteller.add_buff(&"threat_weight", 1.40, 12) + Storyteller.add_buff(&"wanderer_weight", 0.20, 5) + Audit.log("storyteller", "winters_edge: threat_weight ×1.4 for 12d, wanderer_weight ×0.2 for 5d") return d @@ -259,8 +265,7 @@ static func _event_10_the_refugee_family() -> EventDef: _spawn_wanderer_pawn({}) _spawn_wanderer_pawn({}) else: - _apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.slept_on_floor()) # placeholder thought, Phase 17 adds colony_refused_refugees - Audit.log("storyteller", "refugee_family refused — colony mood penalty stubbed (Phase 17)") + _apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.refused_refugees()) return d @@ -323,7 +328,7 @@ static func _event_13_wolves_at_the_edge() -> EventDef: # Trigger: anytime after day 3 (season-weighting handled by Storyteller tension). d.trigger_predicate = func() -> bool: return Clock.current_day() >= 3 - # Effect: spawn 1-3 wolves (stubbed until WolfSpawner is accessible statically). + # Effect: spawn 1-3 wolves via EventBus.request_wolf_spawn. d.on_resolve = func(_c: int) -> void: _spawn_wolves(randi_range(1, 3)) return d @@ -381,9 +386,12 @@ static func _event_16_bandit_scouts() -> EventDef: # Trigger: post-day-25. d.trigger_predicate = func() -> bool: return Clock.current_day() >= 25 - # Effect: flavor + threat-likelihood raise — stubbed (v2 raids). + # Effect: additional threat weight ×1.3 for 3 days ("they'll be back"). + # The base THREAT fire already bumps tension +15; this adds a short-window + # bias toward more threat events as a narrative follow-up. d.on_resolve = func(_c: int) -> void: - Audit.log("storyteller", "bandit_scouts resolved — threat-likelihood raise stubbed (v2 raids)") + Storyteller.add_buff(&"threat_weight", 1.30, 3) + Audit.log("storyteller", "bandit_scouts: threat_weight ×1.3 for 3 days") return d @@ -403,9 +411,9 @@ static func _event_17_fever() -> EventDef: # Trigger: random after ~30 days. d.trigger_predicate = func() -> bool: return Clock.current_day() >= 30 and not World.pawns.is_empty() - # Effect: Sick status on random pawn — StatusCatalog.sick() ships Phase 17. + # Effect: Sick status (severity 2) on one random pawn. d.on_resolve = func(_c: int) -> void: - Audit.log("storyteller", "fever resolved — StatusCatalog.sick() stubbed (Phase 17)") + _apply_pawn_status(func() -> Status: return StatusCatalog.sick(2)) return d @@ -445,7 +453,7 @@ static func _event_19_the_sleeplessness() -> EventDef: if p.is_tired(): return true return false - # Effect: mood penalty 2 days — apply tired thought colony-wide (stubbed intensity). + # Effect: apply tired thought colony-wide (reinforces existing penalty; feels like communal dread). d.on_resolve = func(_c: int) -> void: _apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.tired()) return d @@ -466,7 +474,7 @@ static func _event_20_bountiful_harvest() -> EventDef: # Trigger: autumn (harvest season) and crops are present. d.trigger_predicate = func() -> bool: return Clock.current_season() == Clock.SEASON_AUTUMN and not World.crops.is_empty() - # Effect: +25% yield — stubbed, Phase 17 crop-yield multiplier. + # Effect: +25% harvest yield for ~5 days via Storyteller buff registry. d.on_resolve = func(_c: int) -> void: _apply_buff_next_n_jobs(&"harvest", 10, 1.25) return d @@ -485,7 +493,7 @@ static func _event_21_lumberjacks_luck() -> EventDef: # Trigger: trees present (chop job context). d.trigger_predicate = func() -> bool: return not World.trees.is_empty() - # Effect: next 3 trees +50% wood — stubbed. + # Effect: next ~1.5 days of chop jobs +50% wood via Storyteller buff registry. d.on_resolve = func(_c: int) -> void: _apply_buff_next_n_jobs(&"chop", 3, 1.5) return d @@ -504,7 +512,7 @@ static func _event_22_veins_of_iron() -> EventDef: # Trigger: rocks present (mine job context). d.trigger_predicate = func() -> bool: return not World.rocks.is_empty() - # Effect: next mining yield ×2 — stubbed. + # Effect: mining yield ×2 for ~1 day via Storyteller buff registry. d.on_resolve = func(_c: int) -> void: _apply_buff_next_n_jobs(&"mine", 1, 2.0) return d @@ -563,11 +571,9 @@ static func _event_25_one_year_survived() -> EventDef: # Trigger: end of first winter (day >= 4 seasons × 12 days = 48). d.trigger_predicate = func() -> bool: return Clock.current_day() >= 48 and Clock.current_season() == Clock.SEASON_WINTER - # Effect: +5 mood "We made it" colony 2 days — real-wired via colony thought. + # Effect: +6 mood "We made it through a year" colony-wide for 2 in-game days. d.on_resolve = func(_c: int) -> void: - _apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.well_rested()) - # well_rested() is the closest existing +5 mood thought; Phase 17 adds - # ThoughtCatalog.we_made_it() with correct label + longer duration. + _apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.we_made_it()) return d @@ -575,16 +581,27 @@ static func _event_25_one_year_survived() -> EventDef: ## All helpers are static. Stub bodies log via Audit where the underlying ## system is not yet available; real bodies are annotated per-helper. -## Instantiate a new pawn at a random map-edge tile with optional skill seed. -## STUB — Phase 17 recruit UI will wire the real spawn via PawnFactory or similar. +## Emit EventBus.request_pawn_spawn so the World scene instantiates a new pawn. +## Also applies the hopeful_newcomer thought colony-wide (including the new pawn +## if it is registered before the next thought tick — timing may vary). +## Phase 17 recruit UI wires additional name/trait input; this is the mechanical +## fallback that fires regardless of any future recruit dialog. static func _spawn_wanderer_pawn(skills: Dictionary = {}) -> void: - Audit.log("storyteller", "_spawn_wanderer_pawn called with skills=%s — stubbed (Phase 17)" % str(skills)) + EventBus.request_pawn_spawn.emit(skills) + _apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.hopeful_newcomer()) + Audit.log("storyteller", "_spawn_wanderer_pawn: emitted request_pawn_spawn(skills=%s)" % str(skills)) -## Flag the next `count` jobs of `kind` to use a yield multiplier. -## STUB — Phase 17 work-buff system will implement the ring buffer. +## Register a timed yield multiplier buff via Storyteller.add_buff(). +## `kind` maps to the buff key that job runners query (e.g. &"harvest_yield", +## &"chop_yield", &"mine_yield"). `count` is used to estimate duration in days: +## each job is assumed to take ~0.5 in-game days at average pawn count, so +## duration = max(1, count / 2) days. Consumers call +## Storyteller.get_buff_multiplier(kind) when computing yield. static func _apply_buff_next_n_jobs(kind: StringName, count: int, multiplier: float) -> void: - Audit.log("storyteller", "_apply_buff_next_n_jobs: kind=%s count=%d ×%.2f — stubbed (Phase 17)" % [kind, count, multiplier]) + var duration: int = maxi(1, count / 2) + Storyteller.add_buff(kind, multiplier, duration) + Audit.log("storyteller", "_apply_buff_next_n_jobs: kind=%s ×%.2f for %d days" % [kind, multiplier, duration]) ## Emit EventBus.request_wolf_spawn so WolfSpawner (scene child) picks it up. @@ -597,7 +614,7 @@ static func _spawn_wolves(count: int) -> void: ## Apply a status to a random pawn (or a specific pawn if non-null). ## The factory callable returns a fresh Status — mirrors StatusCatalog pattern. -## REAL for statuses that exist (bleeding); STUB for sick() until Phase 17. +## All statuses in StatusCatalog are now wired (bleeding, sick). static func _apply_pawn_status(status_factory: Callable, pawn = null) -> void: var target = pawn if target == null: diff --git a/scenes/ui/stockpile_panel.gd b/scenes/ui/stockpile_panel.gd index 54b13f2..6ca57aa 100644 --- a/scenes/ui/stockpile_panel.gd +++ b/scenes/ui/stockpile_panel.gd @@ -246,6 +246,7 @@ func _on_priority_pressed(prio_index: int) -> void: return current_zone.priority = prio_index as StorageDestination.Priority _update_priority_row() + EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "priority → %d" % prio_index) @@ -256,6 +257,7 @@ func _on_select_all_pressed() -> void: # while this button's pressed signal is still emitting. current_zone.accepted_types.clear() call_deferred("_populate_chips") + EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "select_all → wildcard mode") @@ -272,6 +274,7 @@ func _on_clear_all_pressed() -> void: current_zone.priority = StorageDestination.Priority.OFF _update_priority_row() call_deferred("_populate_chips") + EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "clear_all → priority OFF") @@ -363,6 +366,7 @@ func _on_chip_pressed(type: StringName) -> void: # Defer the rebuild — don't free this chip while its pressed signal emits. call_deferred("_populate_chips") + EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "chip toggled: %s → accepted_types size=%d" % [ type, current_zone.accepted_types.size() ]) diff --git a/scenes/world/world.gd b/scenes/world/world.gd index d36e2b0..56ceca1 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -269,6 +269,10 @@ func _ready() -> void: # autoloads are fully mounted (trigger predicates read Clock, World, Storyteller). EVENT_CATALOG_SCRIPT.register_all(Storyteller) + # Phase 17 — wanderer events spawn new pawns via EventBus so EventCatalog + # stays static (no get_node() into the scene tree). + EventBus.request_pawn_spawn.connect(_on_request_pawn_spawn) + # Phase 4: every 5 in-game seconds (100 ticks), re-evaluate items in # stockpiles in case a higher-priority destination opened up. EventBus.sim_tick.connect(_on_sim_tick_world_sweep) @@ -436,6 +440,48 @@ func _spawn_sample_pawns() -> void: World.register_pawn(p) +## Phase 17 — Wanderer event handler: instantiate a new pawn at the map entry +## point (top-left area) with optional seeded skills from `skills` dict. +## Called via EventBus.request_pawn_spawn emitted by EventCatalog helpers. +## Skills dict supports the same keys as Pawn.SKILL_* constants: +## "crafting", "cooking", "medicine", "combat", "manual_labor" +const WANDERER_NAMES: Array[String] = [ + "Aldric", "Maren", "Sven", "Tilda", "Piers", "Wren", "Gareth", "Isolde", + "Finn", "Runa", "Oswin", "Elke", "Bertram", "Sigrid", "Leofric", "Astrid", +] +const WANDERER_SPAWN_TILE: Vector2i = Vector2i(5, 5) # map entry corner + +func _on_request_pawn_spawn(skills: Dictionary) -> void: + # Pick a name not already in use. + var used_names: Array = [] + for p in World.pawns: + used_names.append(p.pawn_name) + var available: Array[String] = [] + for n in WANDERER_NAMES: + if not used_names.has(n): + available.append(n) + var chosen_name: String = available[randi() % available.size()] if not available.is_empty() else "Wanderer" + + var p: Pawn = PAWN_SCENE.instantiate() + add_child(p) + p.setup(chosen_name, WANDERER_SPAWN_TILE) + + var jr := JobRunner.new() + jr.name = "JobRunner" + p.add_child(jr) + jr.setup(p, pathfinder) + p.job_runner = jr + + # Seed any skills specified by the event (e.g. combat: 8 for old soldier). + for skill_key in skills: + var sn: StringName = StringName(skill_key) + if sn in Pawn.ALL_SKILLS: + p.set_skill(sn, int(skills[skill_key])) + + World.register_pawn(p) + Audit.log("world", "wanderer spawned: '%s' at %s skills=%s" % [chosen_name, WANDERER_SPAWN_TILE, str(skills)]) + + # ── Phase 4: harvestables (stockpile-zone seeding removed 2026-05-15) ─────── func _spawn_sample_harvestables() -> void: