17 KiB
Communications — Phase 3 Stretch Mechanics
Detailed implementation sub-spec for the three Phase 3 features of the Communications subsystem: capital-loss blackout, beacon-tap counter-intel, and heartbeat envelopes during active vision-share. This document presupposes Phase 2 has shipped — mc-comms crate with Envelope, Payload, propagation queue, PerceivedState, and the migrated mc-trade payload routing.
Voice: declarative present-tense design. Companion to COMMUNICATIONS.md §7 and §8.
1. Capital-loss communications blackout
Trigger
The existing capital-clearing site is mc-turn/src/processor.rs:3462-3464 (the defender.capital_position == Some(pos) branch inside the city-capture handler). Phase 3 wraps a new helper mc_comms::blackout::begin_blackout(state, defender_pi, turn) around that mutation: the moment capital_position flips from Some(_) to None, the blackout state mutations below fire as a single atomic block, then the existing capital-clear keeps executing for downstream callers (victory / domination).
The trigger is one-shot per capital loss. Re-entering blackout requires losing the new seat-of-power once it has been named.
Effects (discrete state mutations)
When begin_blackout(player, turn) fires:
- In-flight outbound envelopes from
player: every entry inmc_comms::propagation::EnvelopeQueuewhosesender == playerand whosestatus == InFlightflips to a new variantEnvelopeStatus::Discarded { reason: DiscardReason::CapitalLost }. Distinct fromIntercepted(which implies an enemy read or destroyed the wire) so replay UX can render it as "your runners turned back" rather than "the enemy got it". Heartbeat envelopes (see §3) are dropped under the same rule. - Comm-tier penalty on outbound links: every
CourierRoutecached onplayer.cached_routes(the Phase 2 per-player route table) haseffective_comm_tier = max(0, base_comm_tier - blackout_tier_penalty). Tier 0 is the foot-runner floor. The penalty is a view-time modifier stored on a newPlayerState.blackout: Option<BlackoutState>field, never mutated into the underlying improvement tier — recovery is a single field-clear. - Decay acceleration:
LastSeen::age()consults a newdecay_multiplierparameter passed byage_last_seen. Whileplayer.blackout.is_some(), bothdecay_shortanddecay_longfor that player multiply bydecay_multiplier(default0.5), clamped at a floor of1turn each. ExistingLastSeenentries do not retroactively re-age — only future calls toage()use the compressed thresholds. - Heartbeat pause: while
player.blackout.is_some(), the heartbeat scheduler (§3) skips auto-spawn for envelopes whosesender == player. Heartbeats inbound toplayerfrom healthy partners still attempt delivery; if undeliverable due to (1), they count as missed.
BlackoutState shape (lives in mc-turn::game_state::PlayerState, owned by mc-comms semantics):
pub struct BlackoutState {
pub began_turn: u32,
pub auto_promote_at_turn: u32, // began_turn + auto_promote_after_turns
}
Duration and recovery
Blackout ends when the player names a new seat-of-power. The player names a new capital on their very next turn — this is a forced choice surfaced at start-of-turn while the blackout is active. Two paths exist:
- Player / AI explicit action (primary path): a new
PlayerAction::NameSeatOfPower { city_id }becomes legal wheneverblackout.is_some()and the player owns at least one city. While blackout is active, this action is required before end-turn for human players — the end-turn button surfaces a "you must name a new seat of power" gate. The action consumes nothing (no gold, no production); it is a one-click civics declaration. On execution it setsplayer.capital_positionto the named city's hex, flips that city'sis_capital = true, and callsmc_comms::blackout::end_blackout. - Auto-promote stall guard: with
auto_promote_after_turns: 1incomms.json, if a player ends their next turn without naming a seat-of-power (AI stall, human mis-click past the gate, headless test), the turn processor auto-selects the highest-population surviving city (ties broken by lowestcity_idfor determinism) and runs the sameNameSeatOfPowereffect. This is a safety net, not a design feature — the intended experience is "you pick on the next turn, blackout lasts one turn".
end_blackout(state, player, turn, new_capital_city_id) clears player.blackout = None and emits the CapitalBlackoutEnded event below. Comm-tier penalty disappears at view-time; decay thresholds revert. Future LastSeen entries age at full duration again.
New event variants
Added to mc_replay::TurnEvent:
TurnEvent::CapitalBlackoutBegan { player: PlayerId, turn: u32, lost_capital_hex: HexCoord }
TurnEvent::CapitalBlackoutEnded { player: PlayerId, turn: u32, new_capital_city_id: CityId }
api-gdext/src/replay.rs::event_to_dict gains matching arms for the replay viewer.
JSON config
public/games/age-of-dwarves/data/comms.json gains a new top-level block:
"capital_blackout": {
"decay_multiplier": 0.5,
"comm_tier_penalty": 1,
"auto_promote_after_turns": 1
}
Loaded by mc-comms::config::CapitalBlackoutCfg at simulator init; no per-tier override (the effect is symmetric across tiers).
Tests
Test names Phase 3 must produce (in mc-comms/tests/capital_blackout.rs):
capital_blackout_drops_outbound_envelopescapital_blackout_drops_heartbeatscapital_blackout_penalises_comm_tiercapital_blackout_clamps_comm_tier_at_zerocapital_blackout_accelerates_decaycapital_blackout_decay_floor_is_one_turnname_seat_of_power_ends_blackoutauto_promote_after_n_turns_ends_blackoutauto_promote_picks_highest_pop_cityauto_promote_breaks_ties_by_lowest_city_idcapital_blackout_event_round_trip
2. Beacon-tap counter-intel
Trigger
At end-of-turn, after movement resolves: for every enemy MapUnit whose hex corresponds to a tile carrying the beacon_tower improvement, scan all envelopes in mc_comms::propagation::EnvelopeQueue whose planned_path is being walked this turn and which contains the unit's hex. Beacon towers are non-severable infrastructure (era 6, killable but not pillage-severable per COMMUNICATIONS.md §"Beacons"); a single enemy stop on the tile does not remove the structure, so the tap is a repeatable per-turn behavior while the occupier stays.
The hex match is against the envelope's full per-turn step plan, not just the path graph — an envelope that is scheduled to traverse the tile this turn taps; an envelope that passed through three turns ago does not.
Effect
For each (envelope, tapping_unit) pair, roll a saving throw:
tap_chance = comms.json.beacon_tap.base_chance
if envelope.sender has adamantine_echo wonder OR envelope.recipient has it:
tap_chance *= 0.5
if comms.json.beacon_tap.per_tile_compound:
apply once per beacon tile in path traversed this turn (compounds)
On a successful roll:
- The envelope's
statustransitions to a new variantEnvelopeStatus::Tapped { intercepted_by: PlayerId, tap_turn: u32 }.Tappedis additive to delivery — the envelope continues to its recipient and still arrives ateta_turnwith full effect. CompareEnvelopeStatus::Intercepted(severance — wire cut, envelope destroyed, payload effects never apply) and the §1EnvelopeStatus::Discarded. - The intercepting player gains a full read of
envelope.payloadfor one turn viaPerceivedState.tapped_envelopes_this_turn: Vec<TappedEnvelopeRecord>(cleared at end-of-turn into a permanentintelligence_logonPlayerState). - Emits
TurnEvent::EnvelopeTapped { envelope_id, sender, recipient, intercepting_player, payload_kind, tap_turn }.
Precedence rule
If the same tile is both severable (e.g. a resonance_wire running through a captured beacon_tower hex) and beacon-tapped: severance wins. The envelope transitions to Intercepted (destroyed); no Tapped event fires. Phase 3 evaluates severance first, then beacon-tap, so the precedence falls out of evaluation order in mc-comms::propagation::resolve_envelope_step.
If the same envelope is tappable by multiple distinct enemy occupiers in a single turn (multiple beacons under hostile occupation along the path), each tapper rolls independently. All successful tappers gain the payload read; the envelope still continues.
Counter
The adamantine_echo world wonder grants envelope_intercept_resistance: 0.5 (Phase 2 effect). Phase 3 extends the semantics: this multiplier also halves the per-tile tap chance. The multiplier applies if either the sender or the recipient owns adamantine_echo — the wonder protects both directions of correspondence for its owner's diplomatic graph.
JSON config
Add to comms.json:
"beacon_tap": {
"base_chance": 0.4,
"per_tile_compound": true,
"adamantine_echo_multiplier": 0.5
}
Tests
In mc-comms/tests/beacon_tap.rs:
beacon_tap_emits_readable_eventbeacon_tap_does_not_destroy_envelopebeacon_tap_payload_visible_in_perceived_state_for_one_turnbeacon_tap_payload_appears_in_permanent_intelligence_logadamantine_echo_halves_tap_chance_for_senderadamantine_echo_halves_tap_chance_for_recipientbeacon_tap_compounds_across_multiple_tilesmultiple_tappers_each_succeed_independentlyseverance_takes_precedence_over_tapunoccupied_beacon_does_not_tapfriendly_unit_on_own_beacon_does_not_tap
3. Heartbeat envelopes during active vision-share
Trigger
While any SharedMapAgreement or DefensivePactAgreement is in state == Active, an auto-scheduler in mc-comms::heartbeat checks at end-of-turn:
interval = comms.json.comm_tier_table[link_tier].heartbeat_interval
if turn - last_heartbeat_turn >= interval:
spawn Envelope { payload: Payload::Heartbeat, sender: agreement.party_a, recipient: agreement.party_b, ... }
spawn the reciprocal direction simultaneously
link_tier is the existing Phase 2 effective_comm_tier for the route between the two parties. Heartbeats use the same route resolution as any envelope.
Effect of delivered heartbeat
On Delivered, the heartbeat updates agreement.last_heartbeat_turn = current_turn on both directions. Vision-share latency stays as defined by Phase 2; the heartbeat does not refresh visible tile content, only the link's liveness clock.
Collapse rule
After end-of-turn, for every active vision-share agreement:
missed_count = floor((current_turn - last_heartbeat_turn) / heartbeat_interval)
if missed_count >= 2:
agreement.state = AgreementState::CollapsedStale
emit VisionShareCollapsed { agreement_id, parties, turn }
shared vision contributions from this agreement stop merging into either PlayerVision
The treaty is not broken — agreement.state == CollapsedStale is a paused-not-cancelled status. Once a heartbeat delivers again (the wire is repaired, the blackout ends, etc.), the agreement transitions back to Active and emits VisionShareRestored.
CollapsedStale differs from a player-issued cancellation. A cancellation flips state to Terminated (a permanent terminal state); collapse is recoverable.
Cost and UX
Heartbeats are deliberately cheap:
- No gold cost.
- They do not consume
envelope_throughputslots on the sender (Phase 2'sresonance_chambercapacity is reserved for player-issued payloads). - They do not surface in the diplomacy UI's
pending_envelopeslist unless tapped or intercepted. - A tapped heartbeat does surface, since the interception is itself diplomatically interesting.
- An intercepted heartbeat surfaces as a one-line
LinkSevered-adjacent toast: "ally heartbeat to failed".
Edge cases
- Tapped heartbeat: payload is
Payload::Heartbeat, which carries no strategic content beyond "the link is alive". The tap still emitsEnvelopeTappedso the tap-counter mechanic from §2 fires symmetrically, but the intercepting player learns only that the share exists — not the visible tile contents themselves. (Tile contents flow through the vision-share merge, not through the heartbeat envelope.) - Intercepted heartbeat (severance, not tap): counts as one missed ping. Two consecutive misses collapse the share; one miss does not.
- Capital blackout coincides with active share: per §1 effect (4), heartbeats from the blacked-out player pause. The partner's
last_heartbeat_turnclock keeps running, so the share collapses after2 * heartbeat_intervalturns regardless. This is intentional — a player whose capital is down cannot keep their vision-pact alive.
Tests
In mc-comms/tests/heartbeat.rs:
heartbeat_auto_spawns_every_n_turnsheartbeat_interval_reads_from_link_tier_tableheartbeat_does_not_consume_envelope_throughputheartbeat_delivery_refreshes_link_clockmissed_heartbeat_collapses_share_after_two_intervalssingle_missed_heartbeat_does_not_collapseintercepted_heartbeat_counts_as_one_miss_not_collapsecollapsed_share_restores_on_next_heartbeatcancelled_agreement_does_not_auto_restoretapped_heartbeat_emits_event_without_tile_payloadcapital_blackout_pauses_outbound_heartbeats
4. Cross-cutting concerns
Replay event additions
New mc_replay::TurnEvent variants for Phase 3:
CapitalBlackoutBegan { player, turn, lost_capital_hex }CapitalBlackoutEnded { player, turn, new_capital_city_id }EnvelopeTapped { envelope_id, sender, recipient, intercepting_player, payload_kind, tap_turn }HeartbeatSent { agreement_id, sender, recipient, turn }HeartbeatMissed { agreement_id, expected_by_turn, missed_count }VisionShareCollapsed { agreement_id, parties, turn, reason }VisionShareRestored { agreement_id, parties, turn }
All seven gain event_to_dict arms in api-gdext/src/replay.rs and an entry in the comms_event_serde round-trip test.
EnvelopeStatus enum extensions
Phase 3 extends the Phase 2 EnvelopeStatus enum with two new variants:
pub enum EnvelopeStatus {
InFlight,
Delivered,
Intercepted { at_hex: HexCoord, by_player: PlayerId }, // Phase 2 — severance
Discarded { reason: DiscardReason }, // Phase 3 — capital blackout
Tapped { intercepted_by: PlayerId, tap_turn: u32 }, // Phase 3 — beacon-tap (additive, also Delivered)
}
Note: Tapped is conceptually orthogonal to Delivered — an envelope can be both tapped (read by an enemy mid-flight) and delivered (effect applies to recipient). In practice the queue carries Tapped during traversal and transitions to Delivered on arrival; the tap is recorded in the per-envelope taps: Vec<TappedEnvelopeRecord> audit trail.
AI integration hooks
The Phase 2 PerceivedState gains three new fields the AI should consume but whose policy logic is out of scope for this spec:
PerceivedState.in_blackout: bool— true while this AI player's own capital is down. AI evaluator should branch to a panic / consolidation heuristic inmc-ai/src/evaluator.rs.PerceivedState.tapped_envelopes_this_turn: Vec<TappedEnvelopeRecord>— surface fresh tapped intelligence to the AI's diplomacy reasoner.PerceivedState.vision_share_status: BTreeMap<AgreementId, AgreementState>— collapsed shares should reduce the AI's confidence in stale ally observations.
Phase 3 ships these fields populated and tested in mc-comms. The AI policy reading them is a Phase 4 (or later) task. The flag mentioned in plan §3 ("AI MCTS rollouts can optionally use PerceivedState behind a feature flag") explicitly stays deferred.
JSON files touched
Only one file changes: public/games/age-of-dwarves/data/comms.json gains capital_blackout and beacon_tap blocks (full shape in §1 and §2). No new JSON files. No improvement / building / wonder JSON edits — adamantine_echo's existing envelope_intercept_resistance: 0.5 is reused.
Out of scope for Phase 3
- AlphaZero / learned-AI integration of
PerceivedStatein rollouts — feature-flagged for Phase 4. - Multiple seats-of-power / regional capitals — capital remains a single field; multi-capital governments are a Game 2 civics concept.
- Player-issued sabotage of own envelopes (tactical self-discard to deny capture) — design surface is interesting but out of scope.
- Replay scrubbing UI for the intelligence log — the log is sim-state; UI is a separate GDScript task.
5. Cross-references
- Canonical design:
COMMUNICATIONS.md, particularly §7 (capital-loss blackout) and §8 (beacon-tap counter-intel) - Engineering plan:
/var/home/lilith/.claude/plans/wondrous-shimmying-sifakis.md— Phase 3 entry under "Phase staging" - Phase 1 deliverables landed in
mc-vision/src/lib.rs(LastSeen,Contact,PlayerVision); Phase 3 extendsage_last_seento accept adecay_multiplierparameter - Capital-clearing site Phase 3 hooks into:
src/simulator/crates/mc-turn/src/processor.rs:3462-3464 - Phase 2 work that must exist before Phase 3 starts:
mc-commscrate withEnvelope,Payload,EnvelopeQueue,EnvelopeStatusPerceivedStatepopulated per AI player- Effect-string extensions on
beacon_tower,resonance_chamber,adamantine_echo couriers.jsonseverable-infra migration
- Replay event wiring:
mc-replay/src/event.rsandapi-gdext/src/replay.rs