The previous plan's Bundles 1–4 are all landed, plus the full GOAP arc, Bundle 4 tranche (S/H/G/Q/K/M/J/P with rep-tier), and the spatial grid (sim_core::spatial::UniformGrid, combat-ready). 209 tests, strict clean, fleet behaviour healthy at ~500 agents. See simulator-rust/CLAUDE.md for the up-to-date status.
The next arc is faction interactions — moving from "factions are economic tax pools" to "factions are political agents with relationships, treaties, rivalries, and (eventually) open warfare." You want the system to support:
- Allies — treaty-level friendship that unlocks access and reduces friction.
- Trade deals — bilateral agreements that shift pricing, fees, contract visibility.
- Competing pricing — markets and contracts that reflect the controlling faction's posture toward the buyer.
- Competing resources — contested bodies / stations / systems where multiple factions have interests.
- Warfare — full hostile state that triggers combat, blockades, fleet redirection.
This plan is a ramp: each bundle is shippable on its own, establishes primitives the next bundle reuses, and keeps the sim playable throughout. Combat is the final payoff but it's built on the relations/claims substrate below it, not bolted on.
Concurrent-edit caveat still applies. Symbol names (
FactionData,SimCommand,plan_goal,UniformGrid) are durable anchors; line numbers drift. This file will be copied tosimulator-rust/plan.mdas the first step of Bundle A.
FactionData(sim_core/src/agents/faction.rs): treasury, tax + subsidy cycles, construction decisions. Per-faction tick already exists.- Per-ship reputation (
ShipEconomy.faction_reputation: HashMap<String, u8>, 0..=100): grows on delivery, gates contracts. Ready to inform interaction penalties too. - Contract
faction_idtag + rep-gated access viaposted_by_factionrep-tier sub-buckets (committed earlier). Personality.aggressionaxis on ships (0..=1): currently unused. Perfect input for combat behaviour selection.SimCommandbuffer: handles every cross-entity effect atomically. New combat/diplomacy events (DamageShip, DeclareWar, SignTreaty) slot in as new variants.UniformGrid<T>(sim_core::spatial::UniformGrid): O(cells) proximity queries. The foundation for "ships within weapon range," "who's in our claim zone," "nearest hostile."- GOAP planner (
sim_core/src/ai/planner.rs): new operators (Attack, Flee, Patrol) extend it cleanly without touching the search. - Event stream (
ws::protocol::Events): ship_spawned, contract_delivered, etc. New events (treaty_signed, war_declared, ship_damaged, ship_destroyed) broadcast the same way.
| # | Bundle | Theme | Effort | Risk |
|---|---|---|---|---|
| A | Relations matrix | Data model for inter-faction standing + treaty state | 1 session | Low |
| B | Trade pacts | Treaty-driven pricing + visibility effects | 1 session | Low |
| C | Market posture | Faction-controlled markets charge/discount based on buyer's rep | 1 session | Low-Med |
| D | Territorial claims | Per-body controlling-faction + contested-zone mechanics | 1-2 sessions | Med |
| E | Hostile posture | RelationState::Hostile with refusal-to-dock + surcharges | 1 session | Med |
| F | Combat primitives | HP, weapons, Attack/Flee BT actions, damage events | 2 sessions | High |
| G | Full warfare | War declaration, fleet redirection, truce + capitulation | 2-3 sessions | High |
Recommended order: A → B → C → D → E → F → G. A is load-bearing — everything else reads the relations matrix. B/C/D are parallel economic pressure experiments; F/G are the combat escalation.
Shippable checkpoints: after A+B you have visibly richer diplomacy with no new UI panels needed. After D you have contested-zone gameplay without combat. After F you have skirmishes between hostile ships. After G you have genuine warfare.
Goal. Give every pair of factions a standing score and a treaty state. Every subsequent bundle reads from this one struct.
Data model (new file sim_core/src/agents/faction/relations.rs or inline in agents/faction.rs):
pub struct FactionRelations {
/// (faction_a_id, faction_b_id) with a_id < b_id lexically to
/// canonicalise pairs. Stored as a flat HashMap for O(1) lookup.
pub by_pair: HashMap<(String, String), RelationState>,
}
pub struct RelationState {
pub standing: i32, // -100..=100; 0 = neutral
pub treaty: TreatyKind,
pub since_tick: u64,
pub last_event_tick: u64,
}
pub enum TreatyKind {
Neutral,
TradePact, // bundle B consumes this
Allied, // strongest positive (eventually bundle G: forced mutual-aid on war)
Tense, // -50 < standing < -20 (no formal hostility yet)
Hostile, // bundle E
War, // bundle G
}Store one FactionRelations entity-resource (use a hecs::World resource-like singleton via a top-level struct on AgentStore). One entity, all pairs.
Tick updates. In agents/faction::tick_factions (new sub-pass):
- Standing drift toward 0 over time (decay constant per 1000 ticks) — old grudges fade.
- Delivery events trigger standing bumps: a ship delivering for faction X from source-faction Y's market → small +standing between X and Y (mutual trade benefit).
- Contract theft / breach fires a minus (reputation breach path in
DeliverContractapply already knows the penalty amount — mirror it to faction relations). - Treaty transitions gated by thresholds:
- standing ≥ +50 sustained 500 ticks → auto-upgrade Neutral → TradePact.
- standing ≥ +75 sustained 1000 ticks → TradePact → Allied.
- standing ≤ −50 → Neutral/TradePact → Tense.
- standing ≤ −70 → Tense → Hostile (requires bundle E live).
- War is never auto-entered; needs an explicit declaration (bundle G).
Events + API.
- Emit
faction_standing_changed,treaty_signed,treaty_brokeninto the engine event Vec. GET /api/factions/relationsreturns the matrix.POST /api/admin/factions/:a/relations/:bsets standing / treaty for debug.
Files to modify.
sim_core/src/agents/faction.rs— addFactionRelationsstruct +tick_relationssub-pass.sim_core/src/simulation/spawner.rs— initialise empty relations on world load.sim_server/src/engine.rs— wiretick_relationsinto the tick loop betweentick_factionsand snapshot.sim_server/src/http/api.rs— newfactions_relationshandler.sim_server/src/http/admin.rs— admin override.sim_server/src/snapshot.rs— publish relations matrix each tick (throttled per Bundle M).
Verification.
- Unit tests on
tick_relations: simulated deliveries move standing; decay returns it to 0; threshold crossings fire the right treaty transitions. /api/factions/relationsreturns the matrix;/api/admin/factions/:a/relations/:badjusts it live.- Smoke: run 2000 ticks, inspect relations matrix at tick 2000; expect some pacts to have formed between high-trade faction pairs.
Goal. Make TreatyKind::TradePact and Allied mean something concrete in the economy.
Effects, all gated on the relations matrix.
- Docking fee discount. When a ship docks at a facility whose owning faction has a TradePact (or Allied) with the ship's faction, the
DOCK_FEE_BURN_PCTburn is halved AND the ship pays a reduced base fee. Same code path as the Bundle (G) burn — just a relations lookup atcommit_docktime. - Contract visibility unlock. In
ContractBoard::posted_for_ship, treat the ship as having baseline rep with every TradePact-faction even if no explicit entry exists. Concretely: walk ally factions' buckets even when the ship has noship_reputation[fac]entry. - Payment bonus. Delivery contract payments scaled +10% when requestor-faction has TradePact with ship's faction. Applied in the
DeliverContractapply phase.
Where it plugs in.
engine::commit_dock— reads ship's faction + facility's owning faction, looks upFactionRelations, applies fee modifier.sim_core/src/economy/contracts.rs::posted_for_ship— extends the iteration with ally-faction buckets.sim_server/src/engine.rsDeliverContract apply — multiplies payment.
Verification.
- Unit test: post a subsidy from faction X, ship is faction-Y with zero rep, Y has TradePact with X → ship can see + accept.
- Smoke: manually set a TradePact via admin endpoint; confirm docking-fee events show reduced burn, contract payments are higher.
Goal. Every market has a controlling faction (derived from its primary facility). Ships transacting at that market pay different prices based on their rep with the controller.
Effects.
- Buy price paid by ship =
base_price × (1 + (50 - ship_rep_with_controller) × 0.005). Range: friend (rep=100) pays 0.75×, stranger (rep=50) pays 1.0×, enemy (rep=0) pays 1.25×. - Sell price received by ship = inverse curve.
- If relations matrix puts ship_faction and controller at Hostile, surcharge doubles OR the market refuses the transaction (latter is Bundle E's job, keep it soft here).
Where it plugs in.
sim_core/src/economy/price_engine.rs— newposture_multiplier(ship_rep, controller_rep_signal)that modifies the spread at buy/sell time.agents/ship/actions.rs— Buy and SellAll actions resolve the controlling faction and apply the multiplier. Currently they just usepricing.buy_priceetc.
Verification.
- Unit tests on
posture_multiplierbounds + symmetry. - Smoke: ship with high rep trades at a market; watch credits gained vs base price. Spawn a fresh ship with rep=0, verify it pays more at the same market.
Goal. Model "whose system/body is this?" so contested zones can produce emergent pressure.
Data model.
- Each body has a
controlling_faction: Option<String>derived from majority facility ownership (tiebreak: highest total facility credits). - Each system has a
controlling_factionderived from body majority. - Contested flag on a body: true when ≥2 factions each own ≥1 facility there.
Emergent effects.
- Contested body markets get increased shortage urgency + subsidy multipliers (existing Phase 19 subsidy system) — incumbent faction "fights back" by posting better-paid contracts to starve out rivals.
- Ship planner's strategic tier gets a new signal:
in_contested_zone(current_loc) × personality.aggressionfeeds into contract score. Aggressive ships gravitate toward contested areas. UniformGrid::query_radiusused here to "find all ships near contested body X" for future territorial pressure calc.
Snapshot + UI.
/api/bodies/:idexposescontrolling_faction+contested.- Godot body highlight color reflects faction palette (cyan/amber/red).
Files.
sim_core/src/agents/celestial_body.rs— addcontrolling_faction+contestedfields;derive_territorial_controlpass intick_factions.sim_core/src/agents/ship/mod.rs::plan_goalstrategic tier — score bump for contested-zone contracts when personality.aggression > threshold.
Verification.
- Unit test: a body with 3 faction-X facilities and 1 faction-Y facility is
controlling_faction = X,contested = true. - Smoke: seed a contested system (manually spawn cross-faction facilities); observe higher subsidy post rate there vs a single-faction body.
Goal. TreatyKind::Hostile meaningfully changes behaviour without yet introducing weapons.
Effects.
- Hostile ships are refused docking at facilities whose owning faction they're hostile with (
commit_dockreturns false with arefused_hostileevent). - Hostile ships trigger "wanted" status at that facility → other ships get a warning event, and any active contracts between them auto-cancel.
- Ship planner's
rest_crewviability check (already exists) treats hostile-faction stations as ineligible. Forces ships to route home or to ally stations. - Gate fees between hostile factions double (soft blockade). Gate code is in
sim_core/src/world/pathfinder.rs— add a fee multiplier resolver hook.
Files.
sim_server/src/engine.rs::commit_dock— hostile refusal branch + event.sim_core/src/agents/ship/mod.rs::any_rest_destination_viable— skip hostile-faction stations.sim_core/src/world/pathfinder.rs— gate-fee multiplier.
Verification.
- Set two factions hostile via admin; spawn a ship of faction A, try to dock at faction B station → refused event in log.
- rest_crew planner routes the ship to a non-hostile station instead of piling up at the refused one.
Goal. Ships can shoot each other. Damage accumulates. Destroyed ships get decommissioned with a wreck event.
Data model additions.
pub struct ShipCombat {
pub hp: f32, // 0..=100
pub shields: f32, // 0..=100
pub weapon_slug: &'static str,
pub last_shot_tick: u64,
}
// ShipAction additions:
Attack { target: hecs::Entity },
Flee { away_from: (f32, f32) },Weapons catalog (new sim_core/src/modules/weapons.rs — mirrors modules/ subsystem pattern):
laser_t1: 5 damage, 10-unit range, 30-tick cooldown.missile_t1: 15 damage, 30-unit range, 100-tick cooldown.pd_t1: 3 damage, 5-unit range, 10-tick cooldown (defensive).
Combat tick phase (runs after decide_and_act):
- Build
UniformGrid<Entity>from ship positions once per tick. - For each ship with
ShipAction::Attack { target }: check range viagrid.query_radius(my_pos, weapon_range); if target present, fire weapon, emitSimCommand::DamageShip { target, dmg }. - Apply phase: deduct shields first then HP; emit
ship_damagedevent; if HP ≤ 0, emitship_destroyed+ chain to existingDecommissionShip.
AI integration.
plan_goalgets new biological-tier-like consideration: if hostile-faction ship in sensor range AND my aggression > 0.5, generate Attack candidate. Score =ln(target_value) × personality.aggression × HOSTILE_BONUS.- If under fire (recently damaged), generate Flee candidate with higher priority for low-aggression personalities.
Files.
sim_core/src/agents/ship/mod.rs—ShipCombatcomponent, BT action, planner considerations.sim_core/src/agents/combat.rs(new) —tick_combatphase usingUniformGrid.sim_server/src/engine.rs— wire combat phase,DamageShip/DestroyShipSimCommand apply.sim_core/src/modules/weapons.rs(new) — weapon catalog.- Migration: add
ships.weapon_slugcolumn persisted like fitting slots.
Verification.
- Unit test: two ships, one Hostile to the other, spawn 8 units apart with
aggression=0.9→ Attack fires, target HP drops, eventually destroyed. - Smoke: set two factions Hostile via admin, spawn aggressive ships at common system → observe
ship_damagedandship_destroyedevents in the ticker.
NOTES
What breaks Rayon: hecs::World allows concurrent reads OR exclusive write, never mixed. If ship A's decide_and_act holds a mut-borrow
of A's components AND wants to read B's position (to decide "am I in weapon range?"), B can't simultaneously be mutating its own
components on another thread. Any live cross-entity read kills parallelism.
The existing sim already solves this pattern — look at dock_congestion and location_to_system in decide_and_act. They're snapshot maps built ONCE at the top of tick_ships (serial, O(n) read pass), then passed into per-ship planning as read-only &HashMap. Per-ship threads never touch live world state for cross-entity info.
The three rules to encode in Bundle F so combat doesn't become un-parallelizable:
- Build a combat snapshot at the top of the tick, serial. Something like combat_targets: HashMap<Loc, Vec<(Entity, faction_id, hp,
position)>>. All targeting decisions read this, not live world components. Same pattern as dock_congestion. - Attack is a command, not a direct mutation. ShipAction::Attack emits SimCommand::RequestAttack { attacker, target, weapon }. The
serial apply phase is where range re-check + HP deduction + destroy chain happens. Never write to another ship's components from inside a per-ship plan closure. - Combat tick phase runs AFTER decide_and_act, in its own pass. Per my plan already — but the discipline matters: the combat phase
itself iterates sequentially (over the commands buffer), so it's not where parallelism lives. Parallelism lives in decide_and_act
deciding which command to emit. The combat phase applying commands is inherently serial because it writes to arbitrary target entities.
The subtle one that's easy to miss: "reply" events like "I was just shot, I should flee." The temptation is for the combat phase to
directly set the victim's under_fire_until: tick + 100 field. That's fine serial, but if we ever want the victim's next-tick
decide_and_act to pick up on it in parallel, the component must already carry that state when the tick begins. So combat-phase writes
land in components → next tick's snapshot includes them → per-ship threads read from snapshot. Round trip is one tick.
Goal. Factions can declare war. War triggers fleet behaviour changes, economic blockades, and can end in truce or capitulation.
State + transitions.
- Declare war:
POST /api/admin/factions/:a/declare-war/:bor AI-driven when standing ≤ −90 and treasury > threshold (willing to fund conflict). - During War: all contracts between the two factions auto-Expire; subsidy cycles double for home-faction members; gate fees between them 4×; ships of one treat the other as Hostile (auto-target).
- End conditions:
- Truce — both factions' treasuries drop below a floor (can't afford conflict).
- Capitulation — one faction loses ≥75% of its ships within a 5000-tick window.
- Treaty — admin or AI signs a ceasefire (standing snaps to −50, treaty → Tense, 3000-tick cooldown before any escalation).
AI decisions.
FactionDatagets awar_posture: WarPosturefield: Peaceful / Ready / Aggressive.- New faction-tick sub-pass every 500 base-ticks: evaluate relations, treasury, recent losses; transition posture and potentially declare war.
- When at war, a faction biases its ship spawns toward combat-capable classes and directs their patrols via CrewMission-like directed contracts (
ContractType::WarPatrol? or reuse CrewMission with amission_category).
Observability.
- New events:
war_declared,truce_signed,capitulated. - Faction ledger panel in Godot adds a "War posture" column + active-wars list.
Files.
sim_core/src/agents/faction.rs— war posture logic, declare/end helpers.sim_core/src/economy/contracts.rs— contract auto-expire when war declared between participants.sim_server/src/http/admin.rs— war declaration endpoint.
Verification.
- Admin-declare a war; watch contracts between the two factions auto-expire in the board panel. Ships of each start pursuing Attack goals on each other.
- Starve one faction's treasury (admin-adjust) → observe truce event fire.
Bundle A is prerequisite for everything else. If you want to validate the approach cheaply, do A + B together in one session — that's ~150 lines of Rust plus ~50 lines of Godot panel changes and gives you visible "ships of allied factions pay less at each other's stations" behaviour without any combat code.
Then D is probably the next big gameplay addition (contested zones feel emergent even without combat). E is a prerequisite for F (ships need a refusal-to-dock mechanic before they'll try Attack).
Deferred to later sessions (noted in backlog for continuity):
- (N) Rayon on ship phase — still the real 10k-ship unlock. Complementary to combat (Attack phase is cross-ship-write-heavy, so a rayon refactor would need to batch those commands carefully).
- Fog-of-war / sensor-filtered observability — combat wants "ship X saw Y jump" events, which the current LOD system doesn't model.
- Pirates / third-party hostile factions — once F + G are working, "no-faction" raider ships become cheap to add.
For each bundle:
scripts/verify.sh --strictclean (all tests pass + clippy strict).WORLD_SPEED=4 ./target/debug/simulatorsmoke run to tick 2000+; confirm new behaviour via/api/factions/relations,/api/contracts, event ticker.- Godot panels reflect new state without needing UI work in Bundle A (relations visible via admin endpoint for now; UI lands in a later pass).
git status/git log --oneline -10— confirm we're picking up from the spatial-grid / rep-tier commits.- Read
simulator-rust/CLAUDE.mdStatus section to refresh on what's landed. - Start Bundle A. Draft the
FactionRelationsstruct first; get it intoAgentStore+ one unit test; then wire the tick pass; then the API. - After A, commit. Pick B next in the same session if time permits.
Core principle throughout: relations matrix is the single source of truth. Every other bundle reads from it; no bundle introduces parallel per-faction state that could drift.