magicciv/public/games/age-of-dwarves/docs/military/COMMUNICATIONS_PHASE3.md
2026-05-26 02:21:13 -07:00

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:

  1. In-flight outbound envelopes from player: every entry in mc_comms::propagation::EnvelopeQueue whose sender == player and whose status == InFlight flips to a new variant EnvelopeStatus::Discarded { reason: DiscardReason::CapitalLost }. Distinct from Intercepted (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.
  2. Comm-tier penalty on outbound links: every CourierRoute cached on player.cached_routes (the Phase 2 per-player route table) has effective_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 new PlayerState.blackout: Option<BlackoutState> field, never mutated into the underlying improvement tier — recovery is a single field-clear.
  3. Decay acceleration: LastSeen::age() consults a new decay_multiplier parameter passed by age_last_seen. While player.blackout.is_some(), both decay_short and decay_long for that player multiply by decay_multiplier (default 0.5), clamped at a floor of 1 turn each. Existing LastSeen entries do not retroactively re-age — only future calls to age() use the compressed thresholds.
  4. Heartbeat pause: while player.blackout.is_some(), the heartbeat scheduler (§3) skips auto-spawn for envelopes whose sender == player. Heartbeats inbound to player from 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. Two paths:

  • Player / AI explicit action: a new PlayerAction::NameSeatOfPower { city_id } becomes legal whenever blackout.is_some() and the player owns at least one city. The action consumes nothing (no gold, no production); it is a one-click civics declaration. On execution it sets player.capital_position to the named city's hex, flips that city's is_capital = true, and calls mc_comms::blackout::end_blackout.
  • Auto-promote fallback: if turn >= blackout.auto_promote_at_turn and the player still has cities, the turn processor auto-selects the highest-population surviving city (ties broken by lowest city_id for determinism) and runs the same NameSeatOfPower effect. This guards against AI stalls and surfaces a "capital auto-relocated" event in the replay log.

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": 5
}

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_envelopes
  • capital_blackout_drops_heartbeats
  • capital_blackout_penalises_comm_tier
  • capital_blackout_clamps_comm_tier_at_zero
  • capital_blackout_accelerates_decay
  • capital_blackout_decay_floor_is_one_turn
  • name_seat_of_power_ends_blackout
  • auto_promote_after_n_turns_ends_blackout
  • auto_promote_picks_highest_pop_city
  • auto_promote_breaks_ties_by_lowest_city_id
  • capital_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 status transitions to a new variant EnvelopeStatus::Tapped { intercepted_by: PlayerId, tap_turn: u32 }. Tapped is additive to delivery — the envelope continues to its recipient and still arrives at eta_turn with full effect. Compare EnvelopeStatus::Intercepted (severance — wire cut, envelope destroyed, payload effects never apply) and the §1 EnvelopeStatus::Discarded.
  • The intercepting player gains a full read of envelope.payload for one turn via PerceivedState.tapped_envelopes_this_turn: Vec<TappedEnvelopeRecord> (cleared at end-of-turn into a permanent intelligence_log on PlayerState).
  • 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_event
  • beacon_tap_does_not_destroy_envelope
  • beacon_tap_payload_visible_in_perceived_state_for_one_turn
  • beacon_tap_payload_appears_in_permanent_intelligence_log
  • adamantine_echo_halves_tap_chance_for_sender
  • adamantine_echo_halves_tap_chance_for_recipient
  • beacon_tap_compounds_across_multiple_tiles
  • multiple_tappers_each_succeed_independently
  • severance_takes_precedence_over_tap
  • unoccupied_beacon_does_not_tap
  • friendly_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_throughput slots on the sender (Phase 2's resonance_chamber capacity is reserved for player-issued payloads).
  • They do not surface in the diplomacy UI's pending_envelopes list 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 emits EnvelopeTapped so 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_turn clock keeps running, so the share collapses after 2 * heartbeat_interval turns 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_turns
  • heartbeat_interval_reads_from_link_tier_table
  • heartbeat_does_not_consume_envelope_throughput
  • heartbeat_delivery_refreshes_link_clock
  • missed_heartbeat_collapses_share_after_two_intervals
  • single_missed_heartbeat_does_not_collapse
  • intercepted_heartbeat_counts_as_one_miss_not_collapse
  • collapsed_share_restores_on_next_heartbeat
  • cancelled_agreement_does_not_auto_restore
  • tapped_heartbeat_emits_event_without_tile_payload
  • capital_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 in mc-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 PerceivedState in 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 extends age_last_seen to accept a decay_multiplier parameter
  • 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-comms crate with Envelope, Payload, EnvelopeQueue, EnvelopeStatus
    • PerceivedState populated per AI player
    • Effect-string extensions on beacon_tower, resonance_chamber, adamantine_echo
    • couriers.json severable-infra migration
  • Replay event wiring: mc-replay/src/event.rs and api-gdext/src/replay.rs