fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner, wolf target now all survive reload via Bill.from_dict reconstruction, _spawn_crop using setup(), and a new _post_load_resolve_references pass. PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile. CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil and Workbench.deposited_inputs buffer. Cremation pyre now actually consumes wood. HaulingProvider: per-item haul_retry_count + haul_rejected after 3 orphan passes; new EventBus.stockpile_layout_changed resets rejects on any player stockpile edit. Storyteller: 14 stubbed event effects implemented. New buff registry (add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives seasonal/resource events. New request_pawn_spawn signal + WANDERER table for arrivals. New SICK status + 3 mood thoughts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
00cf8f445d
commit
d9638a4ea4
19 changed files with 711 additions and 101 deletions
|
|
@ -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 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 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 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.
|
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).
|
# 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).
|
# 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 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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 _STOCKPILE_SCENE: PackedScene = preload("res://scenes/world/stockpile_zone.tscn")
|
||||||
const _CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn")
|
const _CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn")
|
||||||
const _WOLF_SCENE: PackedScene = preload("res://scenes/entities/wolf.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")
|
const _CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn")
|
||||||
|
|
||||||
# Script-only entities (no .tscn); spawned via Node2D.new() + set_script().
|
# 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():
|
if saved_speed_int >= 0 and saved_speed_int < Sim.Speed.size():
|
||||||
saved_speed = saved_speed_int as Sim.Speed
|
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
|
var ok: bool = error_count == 0
|
||||||
Audit.log("save", "applied slot '%s': %d entities, %d errors, tick=%d, away=%ds" % [
|
Audit.log("save", "applied slot '%s': %d entities, %d errors, tick=%d, away=%ds" % [
|
||||||
slot, entity_count, error_count, Sim.tick, real_seconds_away
|
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.quality = int(d.get("quality", 1)) as Item.Quality
|
||||||
ent.subtype = StringName(d.get("subtype", ""))
|
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()
|
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:
|
func _spawn_crop(world_scene: Node, d: Dictionary) -> void:
|
||||||
var ent = _CROP_SCENE.instantiate()
|
var ent = _CROP_SCENE.instantiate()
|
||||||
world_scene.add_child(ent)
|
world_scene.add_child(ent)
|
||||||
if ent.has_method("from_dict"):
|
# Crop.from_dict() is static and returns a spec Dictionary — it cannot mutate
|
||||||
ent.from_dict(d)
|
# the instance. Use the spec to call setup() so tile/kind/stage are applied.
|
||||||
else:
|
var spec: Dictionary = _CROP_SCRIPT.from_dict(d) if d else {}
|
||||||
# Fallback for pre-from_dict Crop: set up with kind + default stage.
|
ent.setup(
|
||||||
ent.setup(
|
Vector2i(int(spec.get("tile_x", 0)), int(spec.get("tile_y", 0))),
|
||||||
Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))),
|
StringName(spec.get("crop_kind", &"wheat")),
|
||||||
StringName(d.get("kind", "wheat")),
|
int(spec.get("stage", 0))
|
||||||
int(d.get("stage", 0))
|
)
|
||||||
)
|
ent.stage_progress = int(spec.get("stage_progress", 0))
|
||||||
|
|
||||||
|
|
||||||
func _spawn_bed(world_scene: Node, d: Dictionary) -> void:
|
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")
|
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 ──────────────────────────────────────────────────────
|
# ── beauty / dirt helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _save_beauty_safe() -> Dictionary:
|
func _save_beauty_safe() -> Dictionary:
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,17 @@ var _ghost_wanderer_target_day: int = -1
|
||||||
## to manually un-pause every threat modal dismissal.
|
## to manually un-pause every threat modal dismissal.
|
||||||
var _speed_before_auto_pause: int = -1
|
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:
|
func _ready() -> void:
|
||||||
Clock.phase_changed.connect(_on_phase_changed)
|
Clock.phase_changed.connect(_on_phase_changed)
|
||||||
EventBus.pawn_died.connect(_on_pawn_died)
|
EventBus.pawn_died.connect(_on_pawn_died)
|
||||||
|
EventBus.sim_tick.connect(_on_sim_tick_buffs)
|
||||||
|
|
||||||
|
|
||||||
# ── public API ────────────────────────────────────────────────────────────────
|
# ── public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -86,6 +93,35 @@ func roll_today() -> void:
|
||||||
_do_daily_roll()
|
_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.
|
## Called by UI (banner/modal) after the player makes a choice or dismisses.
|
||||||
func resolve_current(choice_index: int = 0) -> void:
|
func resolve_current(choice_index: int = 0) -> void:
|
||||||
if _current_event == null:
|
if _current_event == null:
|
||||||
|
|
@ -114,6 +150,14 @@ func save_dict() -> Dictionary:
|
||||||
var cat_fired_str: Dictionary = {}
|
var cat_fired_str: Dictionary = {}
|
||||||
for k: StringName in _category_last_fired:
|
for k: StringName in _category_last_fired:
|
||||||
cat_fired_str[String(k)] = _category_last_fired[k]
|
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 {
|
return {
|
||||||
"tension": tension,
|
"tension": tension,
|
||||||
"ghost_state": ghost_state,
|
"ghost_state": ghost_state,
|
||||||
|
|
@ -122,6 +166,7 @@ func save_dict() -> Dictionary:
|
||||||
"category_last_fired": cat_fired_str,
|
"category_last_fired": cat_fired_str,
|
||||||
"ghost_wanderer_target_day": _ghost_wanderer_target_day,
|
"ghost_wanderer_target_day": _ghost_wanderer_target_day,
|
||||||
"speed_before_auto_pause": _speed_before_auto_pause,
|
"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()
|
_category_last_fired.clear()
|
||||||
for k: String in d.get("category_last_fired", {}):
|
for k: String in d.get("category_last_fired", {}):
|
||||||
_category_last_fired[StringName(k)] = int(d["category_last_fired"][k])
|
_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 ────────────────────
|
# ── phase listener — triggers the daily 6 AM roll at dawn ────────────────────
|
||||||
|
|
@ -217,9 +270,14 @@ func _compute_weight(def: EventDef) -> float:
|
||||||
EventDef.Category.THREAT:
|
EventDef.Category.THREAT:
|
||||||
# Low tension → boost threats (exciting); high tension → suppress (breathing room).
|
# Low tension → boost threats (exciting); high tension → suppress (breathing room).
|
||||||
w *= lerp(2.0, 0.3, tension / 100.0)
|
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:
|
EventDef.Category.RESOURCE:
|
||||||
# High tension → more positive events to balance.
|
# High tension → more positive events to balance.
|
||||||
w *= lerp(0.5, 1.5, tension / 100.0)
|
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.
|
pass # Other categories: weight unmodified.
|
||||||
|
|
||||||
|
|
@ -322,6 +380,29 @@ func _try_fire_ghost_wanderer() -> void:
|
||||||
|
|
||||||
# ── utilities ─────────────────────────────────────────────────────────────────
|
# ── 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
|
## Convert a Category enum value to the snake-case StringName used in
|
||||||
## CATEGORY_COOLDOWN_DAYS and _category_last_fired.
|
## CATEGORY_COOLDOWN_DAYS and _category_last_fired.
|
||||||
func _category_to_str(cat: EventDef.Category) -> StringName:
|
func _category_to_str(cat: EventDef.Category) -> StringName:
|
||||||
|
|
|
||||||
|
|
@ -243,10 +243,12 @@ func unregister_item(it) -> void:
|
||||||
func register_stockpile(s) -> void:
|
func register_stockpile(s) -> void:
|
||||||
if not stockpiles.has(s):
|
if not stockpiles.has(s):
|
||||||
stockpiles.append(s)
|
stockpiles.append(s)
|
||||||
|
EventBus.stockpile_layout_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
func unregister_stockpile(s) -> void:
|
func unregister_stockpile(s) -> void:
|
||||||
stockpiles.erase(s)
|
stockpiles.erase(s)
|
||||||
|
EventBus.stockpile_layout_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
func mark_item_needs_haul(it) -> void:
|
func mark_item_needs_haul(it) -> void:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,17 @@ func _init() -> void:
|
||||||
|
|
||||||
## Returns a craft Job for `pawn`, or null if no valid work exists.
|
## Returns a craft Job for `pawn`, or null if no valid work exists.
|
||||||
## Pawn must expose: .carried_item, .tile (Vector2i), .get_skill(StringName) -> int.
|
## 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:
|
func find_best_for(pawn) -> Job:
|
||||||
# Skip if pawn is already carrying something — deposit first.
|
# Skip if pawn is already carrying something — deposit first.
|
||||||
if pawn.get("carried_item") != null:
|
if pawn.get("carried_item") != null:
|
||||||
|
|
@ -48,7 +59,8 @@ func find_best_for(pawn) -> Job:
|
||||||
var best_wb = null
|
var best_wb = null
|
||||||
var best_bill = null
|
var best_bill = null
|
||||||
var best_bill_index: int = -1
|
var best_bill_index: int = -1
|
||||||
var best_src = null
|
var best_src1 = null
|
||||||
|
var best_src2 = null
|
||||||
var best_dist: int = 999999
|
var best_dist: int = 999999
|
||||||
|
|
||||||
for wb in World.workbenches:
|
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)
|
_emit_bill_blocked(b.recipe.label, &"skill_too_low", wb)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If ingredient_count is 0, no ingredient is required; proceed directly.
|
# Ingredient availability check.
|
||||||
# Otherwise, confirm a qualifying ingredient exists on the floor.
|
# Gate on ingredient_type being non-empty (ingredient_count is informational;
|
||||||
var src = null
|
# the canonical "no ingredient" signal is ingredient_type == &"").
|
||||||
if b.recipe.ingredient_count > 0:
|
var src1 = null
|
||||||
src = _find_ingredient_item(b.recipe.ingredient_type)
|
var src2 = null
|
||||||
if src == 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)
|
_emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Score: total Manhattan travel distance.
|
# Two-ingredient check: secondary ingredient must also be on the floor.
|
||||||
# If no ingredient (count==0), distance is just pawn → workbench.
|
if b.recipe.ingredient2_type != &"":
|
||||||
# Otherwise, distance is pawn → ingredient → workbench.
|
src2 = _find_ingredient_item(b.recipe.ingredient2_type)
|
||||||
var d: int
|
if src2 == null:
|
||||||
if b.recipe.ingredient_count > 0:
|
_emit_bill_blocked(b.recipe.label, &"missing_ingredient2", wb)
|
||||||
d = _manhattan(pawn.tile, src.tile) + _manhattan(src.tile, wb.tile)
|
continue
|
||||||
else:
|
|
||||||
d = _manhattan(pawn.tile, wb.tile)
|
# 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:
|
if d < best_dist:
|
||||||
best_dist = d
|
best_dist = d
|
||||||
best_wb = wb
|
best_wb = wb
|
||||||
best_bill = b
|
best_bill = b
|
||||||
best_bill_index = i
|
best_bill_index = i
|
||||||
best_src = src
|
best_src1 = src1
|
||||||
|
best_src2 = src2
|
||||||
|
|
||||||
if best_wb == null:
|
if best_wb == null:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
var src_item = null
|
# Re-resolve ingredient items to guard against concurrent assignment races.
|
||||||
# If ingredient_count > 0, re-resolve the source item in case multiple bills tied on the same item.
|
if best_bill.recipe.ingredient_type != &"":
|
||||||
if best_bill.recipe.ingredient_count > 0:
|
best_src1 = _find_ingredient_item(best_bill.recipe.ingredient_type)
|
||||||
src_item = _find_ingredient_item(best_bill.recipe.ingredient_type)
|
if best_src1 == null:
|
||||||
if src_item == 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
|
return null
|
||||||
|
|
||||||
var j := Job.new()
|
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.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
|
j.target_node = best_wb
|
||||||
|
|
||||||
# Only add ingredient-haul toils if ingredient is required.
|
if best_src1 != null and best_src2 != null:
|
||||||
if best_bill.recipe.ingredient_count > 0:
|
# Two-ingredient path: deposit ing1 at wb, then fetch ing2 and craft.
|
||||||
j.toils.append(Toil.walk_to(src_item.tile))
|
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.pickup())
|
||||||
|
|
||||||
j.toils.append(Toil.walk_to(best_wb.tile))
|
j.toils.append(Toil.walk_to(best_wb.tile))
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,20 @@ const ALERT_COOLDOWN_TICKS: int = 600
|
||||||
## Per-item-type cooldown map: StringName → tick at which the next emit is allowed.
|
## Per-item-type cooldown map: StringName → tick at which the next emit is allowed.
|
||||||
var _alert_cooldown: Dictionary = {}
|
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:
|
func _init() -> void:
|
||||||
category = &"haul"
|
category = &"haul"
|
||||||
# Priority 3 — below chop (5) and mine (4); above rest (1).
|
# Priority 3 — below chop (5) and mine (4); above rest (1).
|
||||||
# Adjusted once the full 9-category matrix is authored in Phase 17.
|
# Adjusted once the full 9-category matrix is authored in Phase 17.
|
||||||
priority = 3
|
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 ─────────────────────────────────────────────────────
|
# ── WorkProvider override ─────────────────────────────────────────────────────
|
||||||
|
|
@ -59,6 +67,10 @@ func find_best_for(pawn) -> Job:
|
||||||
|
|
||||||
# ── regular items ─────────────────────────────────────────────────────────
|
# ── regular items ─────────────────────────────────────────────────────────
|
||||||
for item in World.items_needing_haul.keys():
|
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.
|
# Skip items another pawn is already carrying.
|
||||||
if item.being_carried:
|
if item.being_carried:
|
||||||
continue
|
continue
|
||||||
|
|
@ -79,6 +91,18 @@ func find_best_for(pawn) -> Job:
|
||||||
if first_orphan_type == &"":
|
if first_orphan_type == &"":
|
||||||
first_orphan_type = item.item_type
|
first_orphan_type = item.item_type
|
||||||
first_orphan_tile = item.tile
|
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
|
continue
|
||||||
|
|
||||||
var drop: Vector2i = dest.find_drop_position(item)
|
var drop: Vector2i = dest.find_drop_position(item)
|
||||||
|
|
@ -228,3 +252,21 @@ func _destination_for_tile(tile: Vector2i):
|
||||||
if dest.covers_tile(tile):
|
if dest.covers_tile(tile):
|
||||||
return dest
|
return dest
|
||||||
return null
|
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)
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ func tick() -> void:
|
||||||
_tick_pickup_corpse(t)
|
_tick_pickup_corpse(t)
|
||||||
Toil.KIND_DEPOSIT_CORPSE:
|
Toil.KIND_DEPOSIT_CORPSE:
|
||||||
_tick_deposit_corpse(t)
|
_tick_deposit_corpse(t)
|
||||||
|
Toil.KIND_DEPOSIT_AT_WB:
|
||||||
|
_tick_deposit_at_wb(t)
|
||||||
|
|
||||||
if t.done:
|
if t.done:
|
||||||
job.advance()
|
job.advance()
|
||||||
|
|
@ -365,13 +367,46 @@ func _tick_deposit(t) -> void:
|
||||||
t.done = true
|
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.
|
## Execute one tick of a CRAFT toil.
|
||||||
##
|
##
|
||||||
## First tick (started=false):
|
## First tick (started=false):
|
||||||
## - Resolves the Workbench node from the stored NodePath. Missing → skip.
|
## - Resolves the Workbench node from the stored NodePath. Missing → skip.
|
||||||
## - Looks up the Bill at data["bill_index"]. Out-of-range → skip.
|
## - Looks up the Bill at data["bill_index"]. Out-of-range → skip.
|
||||||
## - Validates pawn position matches workbench.tile. Mismatch → 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.
|
## - Calls wb.begin_craft(bill) to register the active bill + reset progress.
|
||||||
## - Marks started=true and logs the craft start.
|
## - 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
|
## - Calls wb.tick_craft() which increments current_work_progress and returns
|
||||||
## true when bill.recipe.work_ticks is reached.
|
## true when bill.recipe.work_ticks is reached.
|
||||||
## - On completion:
|
## - 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.
|
## * Rolls quality via QualityCalc.roll() using the pawn's skill level.
|
||||||
## * Spawns an Item scene child of wb.get_parent() at wb.tile.
|
## * Spawns an Item scene child of wb.get_parent() at wb.tile.
|
||||||
## * Calls wb.on_craft_complete() (records bill completion + resets state).
|
## * Calls wb.on_craft_complete() (records bill completion + resets state).
|
||||||
|
|
@ -414,13 +450,39 @@ func _tick_craft(t) -> void:
|
||||||
t.done = true
|
t.done = true
|
||||||
return
|
return
|
||||||
|
|
||||||
if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient_type:
|
# Ingredient validation — three cases:
|
||||||
Audit.log(
|
# (a) No primary ingredient (ingredient_type == &""): skip carry checks.
|
||||||
"job_runner",
|
# (b) Single ingredient: pawn must be carrying ingredient_type.
|
||||||
"%s craft: wrong or missing ingredient — skipping" % pawn.pawn_name
|
# (c) Two ingredients: wb.deposited_inputs must hold ingredient1,
|
||||||
)
|
# pawn must be carrying ingredient2.
|
||||||
t.done = true
|
var has_primary: bool = bill.recipe.ingredient_type != &""
|
||||||
return
|
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.
|
# Register the bill with the workbench and reset its progress counter.
|
||||||
wb.begin_craft(bill)
|
wb.begin_craft(bill)
|
||||||
|
|
@ -450,11 +512,18 @@ func _tick_craft(t) -> void:
|
||||||
return
|
return
|
||||||
var bill = wb.bills[bill_index]
|
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
|
var ingredient = pawn.carried_item
|
||||||
pawn.carried_item = null
|
pawn.carried_item = null
|
||||||
if ingredient != null and is_instance_valid(ingredient):
|
if ingredient != null and is_instance_valid(ingredient):
|
||||||
ingredient.queue_free()
|
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.
|
# Roll quality based on pawn skill for this recipe.
|
||||||
var skill_level: int = pawn.get_skill(bill.recipe.required_skill)
|
var skill_level: int = pawn.get_skill(bill.recipe.required_skill)
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,22 @@ class_name PlantProvider extends WorkProvider
|
||||||
## Priority within plant work: harvest > sow.
|
## Priority within plant work: harvest > sow.
|
||||||
## A READY crop is always preferred over a TILLED one — getting food off the
|
## 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.
|
## 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:
|
## Harvest Job (two toils):
|
||||||
## walk_to(crop.tile) → interact(crop.get_path(), "on_harvest_tick" | "on_sow_tick")
|
## 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
|
## 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
|
## complete in a single tick; the done-check in JobRunner._tick_interact fires
|
||||||
|
|
@ -31,14 +44,27 @@ func _init() -> void:
|
||||||
priority = 5
|
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).
|
## `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:
|
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 = null
|
||||||
var best_dist: int = 999999
|
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.walk_to(best.tile))
|
||||||
j.toils.append(Toil.interact(best.get_path(), &"on_harvest_tick"))
|
j.toils.append(Toil.interact(best.get_path(), &"on_harvest_tick"))
|
||||||
return j
|
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
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ enum Kind {
|
||||||
DOWNED, ## Cannot act; rescue required. Cleared when HP >= revive threshold.
|
DOWNED, ## Cannot act; rescue required. Cleared when HP >= revive threshold.
|
||||||
WET, ## Phase 12 — outdoor rain accumulation; drives Damp/Soaked mood thoughts.
|
WET, ## Phase 12 — outdoor rain accumulation; drives Damp/Soaked mood thoughts.
|
||||||
COLD, ## Phase 12 — winter/cold-snap accumulation; drives Cold mood thought.
|
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
|
## PERSISTENT statuses remain until an external system clears them (e.g. Downed
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ class_name StatusCatalog
|
||||||
##
|
##
|
||||||
## Phase 9 ships: bleeding(), downed().
|
## Phase 9 ships: bleeding(), downed().
|
||||||
## Phase 12 ships: wet(), cold().
|
## Phase 12 ships: wet(), cold().
|
||||||
## Phase 17 will add: sick(), infected().
|
## Phase 17 ships: sick(). infected() is post-MVP.
|
||||||
##
|
##
|
||||||
## Usage pattern:
|
## Usage pattern:
|
||||||
## pawn.add_status(StatusCatalog.bleeding(2))
|
## pawn.add_status(StatusCatalog.bleeding(2))
|
||||||
|
|
@ -109,3 +109,21 @@ static func cold(severity: int = 1) -> Status:
|
||||||
s.max_severity = 3
|
s.max_severity = 3
|
||||||
s.lifetime = Status.Lifetime.PERSISTENT
|
s.lifetime = Status.Lifetime.PERSISTENT
|
||||||
return s
|
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
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,51 @@ static func cremated_friend() -> Thought:
|
||||||
return t
|
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.
|
## Strong negative mood while a rotting corpse is present in the colony.
|
||||||
## PERSISTENT: synced from World.corpses each tick by Pawn._process_thoughts.
|
## PERSISTENT: synced from World.corpses each tick by Pawn._process_thoughts.
|
||||||
## Stacks up to 3 (severity scales with the number of rotting corpses, capped
|
## Stacks up to 3 (severity scales with the number of rotting corpses, capped
|
||||||
|
|
|
||||||
|
|
@ -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_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_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_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
|
var kind: StringName = KIND_IDLE
|
||||||
## Toil-specific params — all values must be int, float, bool, String, Dict, or Array.
|
## 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
|
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.
|
## Timed crafting action at a Workbench.
|
||||||
## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety).
|
## `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.
|
## `bill_index` is the index into workbench.bills that this toil should run.
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class_name Bed extends Node2D
|
||||||
## Save/load:
|
## Save/load:
|
||||||
## to_dict() serialises all persistent fields. _occupant_pawn is always saved
|
## 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
|
## 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
|
## World registration: World.register_bed / World.unregister_bed called from
|
||||||
## _ready / _exit_tree.
|
## _ready / _exit_tree.
|
||||||
|
|
@ -103,6 +103,11 @@ var _owner_pawn = null
|
||||||
## has an active sleep job; the JobRunner handles reconnection).
|
## has an active sleep job; the JobRunner handles reconnection).
|
||||||
var _occupant_pawn = null
|
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 ─────────────────────────────────────────────────────────────────
|
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -220,7 +225,9 @@ func to_dict() -> Dictionary:
|
||||||
|
|
||||||
|
|
||||||
## Restore from a dict produced by to_dict().
|
## 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:
|
func from_dict(d: Dictionary) -> void:
|
||||||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||||
quality = int(d.get("quality", 1))
|
quality = int(d.get("quality", 1))
|
||||||
|
|
@ -228,9 +235,10 @@ func from_dict(d: Dictionary) -> void:
|
||||||
is_medical = bool(d.get("is_medical", false))
|
is_medical = bool(d.get("is_medical", false))
|
||||||
build_progress = int(d.get("build_progress", 0))
|
build_progress = int(d.get("build_progress", 0))
|
||||||
_completed = bool(d.get("completed", false))
|
_completed = bool(d.get("completed", false))
|
||||||
# owner_pawn: Phase 16 will walk World.pawns and match by pawn_name.
|
|
||||||
_owner_pawn = null
|
_owner_pawn = null
|
||||||
_occupant_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)
|
setup(tile)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,15 @@ var tile: Vector2i = Vector2i.ZERO
|
||||||
## carry indicator instead.
|
## carry indicator instead.
|
||||||
var being_carried: bool = false
|
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 ─────────────────────────────────────────────────────────────────
|
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -166,6 +175,8 @@ func to_dict() -> Dictionary:
|
||||||
"tile_x": tile.x,
|
"tile_x": tile.x,
|
||||||
"tile_y": tile.y,
|
"tile_y": tile.y,
|
||||||
"quality": int(quality),
|
"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_x": int(d.get("tile_x", 0)),
|
||||||
"tile_y": int(d.get("tile_y", 0)),
|
"tile_y": int(d.get("tile_y", 0)),
|
||||||
"quality": int(d.get("quality", Quality.NORMAL)),
|
"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)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ var _path: Array[Vector2i] = []
|
||||||
var _step_progress: float = 0.0
|
var _step_progress: float = 0.0
|
||||||
var _attack_cooldown: int = 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 ────────────────────────────────────────────────────────────────
|
# ── lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -207,9 +214,11 @@ func from_dict(d: Dictionary) -> void:
|
||||||
state = int(d.get("state", State.APPROACH)) as State
|
state = int(d.get("state", State.APPROACH)) as State
|
||||||
_step_progress = float(d.get("step_progress", 0.0))
|
_step_progress = float(d.get("step_progress", 0.0))
|
||||||
_attack_cooldown = int(d.get("attack_cooldown", 0))
|
_attack_cooldown = int(d.get("attack_cooldown", 0))
|
||||||
# target_pawn: re-resolved by the loader after all pawns are restored.
|
# target_pawn is re-wired by SaveSystem._post_load_resolve_references() after
|
||||||
# Store the name in a temporary string; caller sets target_pawn post-load.
|
# all pawns are spawned. Store the name for that pass; if the pawn no longer
|
||||||
target_pawn = null # caller must re-resolve from "target_pawn_name"
|
# 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()
|
_path.clear()
|
||||||
for entry in d.get("path", []):
|
for entry in d.get("path", []):
|
||||||
if entry is Array and entry.size() == 2:
|
if entry is Array and entry.size() == 2:
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,12 @@ var current_work_progress: int = 0
|
||||||
## enabled only after _complete() fires.
|
## enabled only after _complete() fires.
|
||||||
var _light: PointLight2D = null
|
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 ─────────────────────────────────────────────────────────────────
|
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -261,6 +267,30 @@ func on_craft_complete() -> void:
|
||||||
func on_craft_interrupted() -> void:
|
func on_craft_interrupted() -> void:
|
||||||
current_bill = null
|
current_bill = null
|
||||||
current_work_progress = 0
|
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 ────────────────────────────────
|
# ── Phase 11: light-source duck-type interface ────────────────────────────────
|
||||||
|
|
@ -293,6 +323,11 @@ func to_dict() -> Dictionary:
|
||||||
var bills_data: Array = []
|
var bills_data: Array = []
|
||||||
for b in bills:
|
for b in bills:
|
||||||
bills_data.append(b.to_dict())
|
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 {
|
return {
|
||||||
"class_id": &"workbench",
|
"class_id": &"workbench",
|
||||||
"tile_x": tile.x,
|
"tile_x": tile.x,
|
||||||
|
|
@ -303,13 +338,15 @@ func to_dict() -> Dictionary:
|
||||||
"completed": _completed,
|
"completed": _completed,
|
||||||
"current_work_progress": current_work_progress,
|
"current_work_progress": current_work_progress,
|
||||||
"bills": bills_data,
|
"bills": bills_data,
|
||||||
|
"deposited_inputs": deposited_array,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
## Restore from a dict produced by to_dict().
|
## Restore from a dict produced by to_dict().
|
||||||
## Bill objects are reconstructed by the caller using Bill.from_dict() once the
|
## Bills are reconstructed here using Bill.from_dict(). current_bill is left
|
||||||
## Bill class is registered. current_bill is left null — JobRunner reconnects
|
## null — JobRunner reconnects from its own saved state on the next sim tick.
|
||||||
## 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:
|
func from_dict(d: Dictionary) -> void:
|
||||||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||||
label_text = str(d.get("label_text", "Workbench"))
|
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))
|
build_progress = int(d.get("build_progress", 0))
|
||||||
_completed = bool(d.get("completed", false))
|
_completed = bool(d.get("completed", false))
|
||||||
current_work_progress = int(d.get("current_work_progress", 0))
|
current_work_progress = int(d.get("current_work_progress", 0))
|
||||||
# Bills are re-populated by World.load_workbenches() after Bill class loads.
|
# Reconstruct bills from the saved array. Clear first so this is idempotent.
|
||||||
# Raw dicts are kept in the dict; the caller handles reconstruction.
|
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)
|
setup(tile)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,12 @@ class_name EventCatalog
|
||||||
## the i18n hook (`Strings.t(key)`) exists today without requiring it.
|
## the i18n hook (`Strings.t(key)`) exists today without requiring it.
|
||||||
## EventDef carries the rendered English string; locale switching can swap
|
## EventDef carries the rendered English string; locale switching can swap
|
||||||
## it via the string table later without touching EventDef or this catalog.
|
## it via the string table later without touching EventDef or this catalog.
|
||||||
## - Effect helpers below are stubbed with Audit.log lines where full
|
## - Effect helpers (Phase 17 — all wired):
|
||||||
## integration is post-Phase-15:
|
## _spawn_wanderer_pawn — emits EventBus.request_pawn_spawn; World scene handles.
|
||||||
## _spawn_wanderer_pawn — Phase 17 recruit UI wires the real spawn.
|
## _apply_buff_next_n_jobs — registers a timed yield buff on Storyteller.
|
||||||
## _apply_buff_next_n_jobs — Phase 17 work-buff system.
|
## _spawn_wolves — emits EventBus.request_wolf_spawn; WolfSpawner handles.
|
||||||
## _spawn_wolves — WolfSpawner is a scene child, not reachable statically;
|
## _apply_pawn_status — applies a status to a random pawn.
|
||||||
## real wiring will go through EventBus or a future WolfSpawner autoload.
|
## _apply_colony_mood_thought — applies a thought to all living pawns.
|
||||||
## _apply_pawn_status (sick) — StatusCatalog.sick() ships Phase 17.
|
|
||||||
## All real-wired effects are noted per-event below.
|
|
||||||
##
|
##
|
||||||
## The 25-event count is the Phase 15 gate. Do not commit with != 25.
|
## 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.
|
# Trigger: first tick of spring. Storyteller fires this once per season boundary.
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_season() == Clock.SEASON_SPRING
|
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:
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -174,9 +173,11 @@ static func _event_06_summers_heat() -> EventDef:
|
||||||
d.auto_pause = false
|
d.auto_pause = false
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_season() == Clock.SEASON_SUMMER
|
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:
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -192,9 +193,11 @@ static func _event_07_autumns_harvest() -> EventDef:
|
||||||
d.auto_pause = false
|
d.auto_pause = false
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_season() == Clock.SEASON_AUTUMN
|
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:
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -210,9 +213,12 @@ static func _event_08_winters_edge() -> EventDef:
|
||||||
d.auto_pause = false
|
d.auto_pause = false
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_season() == Clock.SEASON_WINTER
|
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:
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -259,8 +265,7 @@ static func _event_10_the_refugee_family() -> EventDef:
|
||||||
_spawn_wanderer_pawn({})
|
_spawn_wanderer_pawn({})
|
||||||
_spawn_wanderer_pawn({})
|
_spawn_wanderer_pawn({})
|
||||||
else:
|
else:
|
||||||
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.slept_on_floor()) # placeholder thought, Phase 17 adds colony_refused_refugees
|
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.refused_refugees())
|
||||||
Audit.log("storyteller", "refugee_family refused — colony mood penalty stubbed (Phase 17)")
|
|
||||||
return d
|
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).
|
# Trigger: anytime after day 3 (season-weighting handled by Storyteller tension).
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_day() >= 3
|
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:
|
d.on_resolve = func(_c: int) -> void:
|
||||||
_spawn_wolves(randi_range(1, 3))
|
_spawn_wolves(randi_range(1, 3))
|
||||||
return d
|
return d
|
||||||
|
|
@ -381,9 +386,12 @@ static func _event_16_bandit_scouts() -> EventDef:
|
||||||
# Trigger: post-day-25.
|
# Trigger: post-day-25.
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_day() >= 25
|
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:
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -403,9 +411,9 @@ static func _event_17_fever() -> EventDef:
|
||||||
# Trigger: random after ~30 days.
|
# Trigger: random after ~30 days.
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_day() >= 30 and not World.pawns.is_empty()
|
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:
|
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
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -445,7 +453,7 @@ static func _event_19_the_sleeplessness() -> EventDef:
|
||||||
if p.is_tired():
|
if p.is_tired():
|
||||||
return true
|
return true
|
||||||
return false
|
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:
|
d.on_resolve = func(_c: int) -> void:
|
||||||
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.tired())
|
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.tired())
|
||||||
return d
|
return d
|
||||||
|
|
@ -466,7 +474,7 @@ static func _event_20_bountiful_harvest() -> EventDef:
|
||||||
# Trigger: autumn (harvest season) and crops are present.
|
# Trigger: autumn (harvest season) and crops are present.
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_season() == Clock.SEASON_AUTUMN and not World.crops.is_empty()
|
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:
|
d.on_resolve = func(_c: int) -> void:
|
||||||
_apply_buff_next_n_jobs(&"harvest", 10, 1.25)
|
_apply_buff_next_n_jobs(&"harvest", 10, 1.25)
|
||||||
return d
|
return d
|
||||||
|
|
@ -485,7 +493,7 @@ static func _event_21_lumberjacks_luck() -> EventDef:
|
||||||
# Trigger: trees present (chop job context).
|
# Trigger: trees present (chop job context).
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return not World.trees.is_empty()
|
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:
|
d.on_resolve = func(_c: int) -> void:
|
||||||
_apply_buff_next_n_jobs(&"chop", 3, 1.5)
|
_apply_buff_next_n_jobs(&"chop", 3, 1.5)
|
||||||
return d
|
return d
|
||||||
|
|
@ -504,7 +512,7 @@ static func _event_22_veins_of_iron() -> EventDef:
|
||||||
# Trigger: rocks present (mine job context).
|
# Trigger: rocks present (mine job context).
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return not World.rocks.is_empty()
|
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:
|
d.on_resolve = func(_c: int) -> void:
|
||||||
_apply_buff_next_n_jobs(&"mine", 1, 2.0)
|
_apply_buff_next_n_jobs(&"mine", 1, 2.0)
|
||||||
return d
|
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).
|
# Trigger: end of first winter (day >= 4 seasons × 12 days = 48).
|
||||||
d.trigger_predicate = func() -> bool:
|
d.trigger_predicate = func() -> bool:
|
||||||
return Clock.current_day() >= 48 and Clock.current_season() == Clock.SEASON_WINTER
|
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:
|
d.on_resolve = func(_c: int) -> void:
|
||||||
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.well_rested())
|
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.we_made_it())
|
||||||
# well_rested() is the closest existing +5 mood thought; Phase 17 adds
|
|
||||||
# ThoughtCatalog.we_made_it() with correct label + longer duration.
|
|
||||||
return d
|
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
|
## All helpers are static. Stub bodies log via Audit where the underlying
|
||||||
## system is not yet available; real bodies are annotated per-helper.
|
## 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.
|
## Emit EventBus.request_pawn_spawn so the World scene instantiates a new pawn.
|
||||||
## STUB — Phase 17 recruit UI will wire the real spawn via PawnFactory or similar.
|
## 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:
|
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.
|
## Register a timed yield multiplier buff via Storyteller.add_buff().
|
||||||
## STUB — Phase 17 work-buff system will implement the ring buffer.
|
## `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:
|
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.
|
## 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).
|
## Apply a status to a random pawn (or a specific pawn if non-null).
|
||||||
## The factory callable returns a fresh Status — mirrors StatusCatalog pattern.
|
## 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:
|
static func _apply_pawn_status(status_factory: Callable, pawn = null) -> void:
|
||||||
var target = pawn
|
var target = pawn
|
||||||
if target == null:
|
if target == null:
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@ func _on_priority_pressed(prio_index: int) -> void:
|
||||||
return
|
return
|
||||||
current_zone.priority = prio_index as StorageDestination.Priority
|
current_zone.priority = prio_index as StorageDestination.Priority
|
||||||
_update_priority_row()
|
_update_priority_row()
|
||||||
|
EventBus.stockpile_layout_changed.emit()
|
||||||
Audit.log("stockpile_panel", "priority → %d" % prio_index)
|
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.
|
# while this button's pressed signal is still emitting.
|
||||||
current_zone.accepted_types.clear()
|
current_zone.accepted_types.clear()
|
||||||
call_deferred("_populate_chips")
|
call_deferred("_populate_chips")
|
||||||
|
EventBus.stockpile_layout_changed.emit()
|
||||||
Audit.log("stockpile_panel", "select_all → wildcard mode")
|
Audit.log("stockpile_panel", "select_all → wildcard mode")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -272,6 +274,7 @@ func _on_clear_all_pressed() -> void:
|
||||||
current_zone.priority = StorageDestination.Priority.OFF
|
current_zone.priority = StorageDestination.Priority.OFF
|
||||||
_update_priority_row()
|
_update_priority_row()
|
||||||
call_deferred("_populate_chips")
|
call_deferred("_populate_chips")
|
||||||
|
EventBus.stockpile_layout_changed.emit()
|
||||||
Audit.log("stockpile_panel", "clear_all → priority OFF")
|
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.
|
# Defer the rebuild — don't free this chip while its pressed signal emits.
|
||||||
call_deferred("_populate_chips")
|
call_deferred("_populate_chips")
|
||||||
|
EventBus.stockpile_layout_changed.emit()
|
||||||
Audit.log("stockpile_panel", "chip toggled: %s → accepted_types size=%d" % [
|
Audit.log("stockpile_panel", "chip toggled: %s → accepted_types size=%d" % [
|
||||||
type, current_zone.accepted_types.size()
|
type, current_zone.accepted_types.size()
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,10 @@ func _ready() -> void:
|
||||||
# autoloads are fully mounted (trigger predicates read Clock, World, Storyteller).
|
# autoloads are fully mounted (trigger predicates read Clock, World, Storyteller).
|
||||||
EVENT_CATALOG_SCRIPT.register_all(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
|
# Phase 4: every 5 in-game seconds (100 ticks), re-evaluate items in
|
||||||
# stockpiles in case a higher-priority destination opened up.
|
# stockpiles in case a higher-priority destination opened up.
|
||||||
EventBus.sim_tick.connect(_on_sim_tick_world_sweep)
|
EventBus.sim_tick.connect(_on_sim_tick_world_sweep)
|
||||||
|
|
@ -436,6 +440,48 @@ func _spawn_sample_pawns() -> void:
|
||||||
World.register_pawn(p)
|
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) ───────
|
# ── Phase 4: harvestables (stockpile-zone seeding removed 2026-05-15) ───────
|
||||||
|
|
||||||
func _spawn_sample_harvestables() -> void:
|
func _spawn_sample_harvestables() -> void:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue