288 lines
17 KiB
Markdown
288 lines
17 KiB
Markdown
# 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`](./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):
|
|
|
|
```rust
|
|
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 whenever `blackout.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 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 stall guard**: with `auto_promote_after_turns: 1` in `comms.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 lowest `city_id` for determinism) and runs the same `NameSeatOfPower` effect. 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`:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```json
|
|
"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_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`](./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`:
|
|
|
|
```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 <player> 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:
|
|
|
|
```rust
|
|
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`](./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`
|