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:

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:

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_dugDataRegistry.get_layer_id_at_depth(pos.y) == "quartz".
assayer Acquiring any exotic material _on_inventory_changedmaterial_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:

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 is 120, 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