Projects and Specialists
Two systems drive the village's requests of the digger. ProjectManager spawns, tracks, fulfils, and expires villager tasks from data/projects.json; SpecialistManager delivers the four Astrolabe specialists gated by dig progress, rare finds, and population.
Both are autoloads. ProjectManager runs its own _process timer; SpecialistManager reacts to EventBus signals.
ProjectManager
scripts/village/ProjectManager.gd loads task templates once from res://data/projects.json in _ready() into _templates, then spawns concrete tasks over time into _active_projects (an Array[Dictionary]). It emits its own projects_changed signal for the task UI to re-render — the classic journal tab, or the talisman skin's journal drawer and crest bell badge — plus EventBus events for individual task lifecycle moments.
Spawn timing and idle freeze
_process(delta) first does idle detection: if there has been no mouse movement or key press it accumulates _idle_seconds, and once past _IDLE_THRESHOLD (60 s) it returns early — expiry and spawn timers freeze so an idle player isn't punished. Browsing menus, crafting, and building all count as active because it tracks Godot input idle time, not player position.
The spawn cadence is a countdown on _spawn_timer. The base interval is _SPAWN_INTERVAL_BASE (0.5 in-game days, averaging two tasks a day) jittered ±0.2, scaled in _current_interval():
var speed_mult := 1.0 + clampf(depth * _DEPTH_FACTOR_PER_10 / 10.0, 0.0, _DEPTH_FACTOR_CAP - 1.0)
var pile_penalty := 1.0 + _active_projects.size() * _PILE_UP_FACTOR
return _base_interval() / speed_mult * pile_penalty
Deeper digging speeds spawns (up to _DEPTH_FACTOR_CAP = 3×); more open tasks slows them (_PILE_UP_FACTOR = 0.20 per active task). The first task waits _FIRST_SPAWN_DAYS (1.5 days) so it doesn't crowd the opening.
The first-task gate
Before the first task can spawn, _first_task_released() must pass. It releases when any of these is true, then latches into the run blob (current_run["first_task_released"]) so a later metric dip never re-gates:
- All three village supports sit at or above population, or
- the elder chain graduates the player (
current_run["village_chain_done"]), or - day 3 is reached.
Eligibility and weighted pick
_try_spawn() requires population >= 5, then filters _templates by min_depth/max_depth and _objective_spawnable(...). _weighted_pick() biases the choice by each template's spawn_weight (default 1.0), so iron-heavy asks surface proportionally less often without leaving the pool.
_objective_spawnable(tmpl, depth) branches on objective_type:
| Objective type | Spawnable when |
|---|---|
deliver (default) |
Every required material is reachable at current depth (_materials_reachable) — exotic materials require the Astrolabe ritual to have seeded them. |
find_discovery |
Any layer the discovery appears in is reachable. |
mine_material |
The target material (or, for any_new, some undug material) is reachable and not already in _dug_materials. |
reach_depth |
A full depth_delta descent still fits above the world floor (_world_floor_depth()). |
Building a project
_build_project(template_id, depth) rolls the concrete task:
- Materials: each template range is scaled by
depth_scale=1.0 + clampf(depth/100, 0, _DEPTH_SCALE_MAX - 1.0)(_DEPTH_SCALE_MAX=4.0), then a random amount in the scaled range. - Reward: if the template has a weighted
reward_pool, one option is rolled (_pick_reward_from_pool) so the same task pays different rewards across spawns; otherwise the flatreward_type/reward_scaleis used. Rewards are depth-scaled too, except types in_NO_DEPTH_SCALE_REWARDS(reinforced_frame— a single upgrade regardless of depth). Epic tasks may carry areward_bundlegranting several rewards at once. - Safety reward conversion:
_resolve_safety_reward()converts asafety_boostreward intostrange_root(the wall-binding material) so the card, payout, andproject_fulfilledsignal all agree. - Deny penalty:
deny_penalty_pctis rolled from the template range; food/water penalties are scaled byFOOD_WATER_PENALTY_SCALE(0.65) because they now drain a percent of total stores. - Expiry:
_EXPIRY_DAYS[category]gives a day range converted to seconds intoexpires_in.
Objective progress
Event-objective tasks are marked met by signal handlers, not by fulfilment. _on_tile_dug_progress (on EventBus.tile_dug) tracks mine_material and reach_depth tasks and updates _dug_materials; _on_discovery_found_progress (on EventBus.discovery_found) handles find_discovery. _mark_met() sets objective_met, toasts, and re-emits projects_changed. A met objective freezes — it never expires while waiting to be collected.
Fulfil, claim, deny, expire
| Method | Effect |
|---|---|
fulfill(id) |
Delivery tasks only. Requires can_fulfill (all materials present), removes them, grants the reward, increments tasks_fulfilled, emits project_fulfilled. |
claim(id) |
Event tasks whose objective_met is true. No material cost; grants reward and increments tasks_fulfilled. |
deny(id) |
Applies deny_penalty, increments tasks_denied, emits project_denied. |
_expire_project(id) |
Applies the penalty ×_EXPIRE_PENALTY_MULT (1.75) — ignoring a task until it lapses strands the villager, so it costs more than an active deny. |
_grant_reward() grants each reward_bundle entry or the single reward via _apply_reward(), which routes by type: food/water to SurvivalManager, population joins villagers, gold/material types to Inventory, and safety_boost (legacy/in-flight) re-resolves to strange root. _apply_penalty() routes food/water to the drain-percent calls and safety to VillageManager.apply_safety_debuff() with magnitude and duration scaled from the rolled penalty.
Persistence
get_save_data() / load_save_data() serialize _active_projects; the loader drops legacy reach_depth tasks whose target now sits below the dig floor (unwinnable, silently discarded). _dug_materials round-trips via get_dug_materials/set_dug_materials. reset() clears everything and waits a full day before the first task. seed_initial_projects() spawns 1–2 tasks immediately for saves that predate the projects system.
SpecialistManager
scripts/village/SpecialistManager.gd tracks the four specialists who unlock the Terrestrial Astrolabe. Each arrival shows a unique walk-in popup, writes a gold journal entry, emits EventBus.specialist_arrived, and appends to current_run["specialists_arrived"] — so arrival state saves and restores with the rest of the run.
The four specialists and their gates
Arrival order is fixed: archivist → stonewarden → assayer → artificer. Each is triggered by a different EventBus signal handler wired in _ready():
| Specialist | Trigger | Handler / condition |
|---|---|---|
archivist |
First bell rings | _on_village_bell_rang — arrives if not already here. |
stonewarden |
Digging into the quartz layer | _on_tile_dug — DataRegistry.get_layer_id_at_depth(pos.y) == "quartz". |
assayer |
Acquiring any exotic material | _on_inventory_changed — material_id in EXOTIC_IDS (ember_essence, void_iron, ancient_clay, prismatic_shard) with amount > 0. |
artificer |
Population threshold, with the other three present | _on_population_changed — see below. |
The Artificer gate
The Artificer is the slowest gate in the run. _on_population_changed(new_pop) requires all of:
new_pop >= ARTIFICER_POPULATION, and- the archivist, stonewarden, and assayer have all already arrived.
ARTIFICER_POPULATION is read in _ready() from DataRegistry.balance["village"]["artificer_population"], defaulting to 120 (the field default declared on the variable is also 120).
Note: the codebase guide (CLAUDE.md) describes the Artificer as "gated by
village.artificer_population", which matches the balance key read here. The in-code default is120, and the population check does not fire until the three earlier specialists have arrived — the population threshold alone is not sufficient.
Arrival flow
_arrive(id) records the specialist, emits the narrative and specialist_arrived signal, then defers _show_popup by 0.4 s so the journal renders first. _show_popup builds a CanvasLayer (layer 8) with a dimmer, a walking Sprite2D (character art from res://assets/images/characters/<id>_full.png), and a centered PanelContainer styled per specialist. The _process state machine (_WalkState) walks the sprite in, shows the panel on arrival, and walks it out on dismiss before _cleanup_popup() frees the layer.
is_popup_active() returns true from popup build to full cleanup; VillageManager._try_ring_bell() checks it so a bell never lands on top of a specialist arrival, and _cleanup_popup() calls VillageManager.try_ring_pending_bell() so a held bell surfaces on exit. The popup itself defers if the village bell recap is showing, so the two modals never overlap.
Query helpers: has_arrived(id), all_arrived(), get_arrived().
Key files
scripts/village/ProjectManager.gd— task spawn/eligibility/fulfil/deny/expire, reward and penalty routing.scripts/village/SpecialistManager.gd— the four Astrolabe specialists, their gates, and the walk-in popup.data/projects.json— task templates (materials, reward pools/bundles, deny penalties, depth gates, objectives).