Skip to content

Pirate Holding Raid System

Status: 🚧 Partial β€” Effectively unbuilt: no PirateHolding/OutlawBase models, no capture/lock/abandonment/recovery services, and no layered engagement … (impl audit 2026-06-16)

πŸ“ Design-only. No raid runtime is committed yet; this page is the prescriptive system spec. Canonical decision in ADR-0047. Player-facing reference in ../FEATURES/galaxy/pirate-holdings.md.

The runtime that governs pirate-holding combat β€” engagement order, damage composition with the canonical combat resolver, organic recovery between sessions, capture atomicity, ownership transfer, and concurrent-attacker arbitration. The holding's defensive layers compose with the existing combat-resolver, npc-scheduler, and station/planet defense surfaces; this page documents how the layers fit together and how raid state persists when no player is engaged.

Engagement order

When a player enters a sector belonging to a PirateHolding.interior_sector_ids (or the anchor_sector_id), defensive layers engage in this canonical sequence. Each layer is a separate combat-resolver invocation β€” no damage carryover within a single resolver run, but ship state (hull/shields) persists between layers via the standard Ship row.

Layer Trigger Combat phase Defenders Notes
Sector defenses (perimeter) Player enters any holding sector combat-resolver Phase 1 (Initiation) β†’ Phase 2 (Drone screen) Deployed pirate drones, mines Per Sector.defenses.deployed_drones and mines. Mines apply on entry; drones engage the entering ship.
Pirate ship patrols Patrol ship in the sector reacts to a hostile player on the realtime bus combat-resolver Phase 3 (ship-vs-ship) Patrolling HOSTILE_RAIDER NPCs from Sector.defenses.pirate_patrol_ships Squad leader is a named captain; KIA respawn per the per-tier recovery rate.
Station defense Player approaches a pirate-owned station Station drone screen (Phase 1–2) β†’ Port combat (Phase 3) Station drones (4–50 by class), STATION_SECURITY archetype guards, tractor beam Per ../FEATURES/economy/station-protection.md. Each station is a separate engagement.
Planetary defense (orbital) Player orbits a pirate-controlled planet Combined Phase 1 + Phase 3 of a single planet-assault resolver invocation Orbital platforms (range 2 sectors), rail guns (range 1 sector), drones, shields Per ../FEATURES/planets/defense.md β€” all defense weapons fire in the same combat instance.
Citadel siege Player attacks the planet's citadel structure after orbital defenses are down Phase 3 continued Citadel hull (L1–L5), shields, garrison HOSTILE_RAIDER NPCs Per ../FEATURES/planets/citadels.md and ADR-0040 does not apply (this is planet siege, not QJ).
OutlawBase clearing (terminal) All planets / stations / patrol ships in the holding's interior are neutralized; pirate Lord/captains living at the OutlawBase are the last threat Phase 3 ship-vs-ship against the named Lord/captains Named NPCs in their personal flagships, with any final escort Capture trigger fires when these are KIA.

The order is observed, not enforced. If a player burns straight to the planet without clearing the perimeter sector defenses first, the planet's orbital platforms will engage, but so will the perimeter drones (the ship is now in two combat states simultaneously β€” Phase 3 against the planet, Phase 1–2 with the perimeter from prior to entry). The combat resolver runs each engagement atomically; the player's ship state persists across.

Damage composition

Each engagement runs the canonical 5-step damage stack from ./combat-resolver.md. Holding-specific compositions:

Per-station / per-planet

A pirate-owned station or planet runs the combat resolver once per encounter. The defending side's combat_bonus is taken from the pirate captain commanding the station/planet (typically Ensign–Captain rank, providing +10–15% modifier on outgoing damage), not from the holding's Lord.

base_damage (player attacking station)
  Γ— player.attack_drones_modifier
  Γ— SHIP_COMBAT_MODIFIERS[player.ship.type, "STATION"]
  Γ— sector.modifier (nebula, radiation, etc.)
  Γ— (1 + player.rank.combat_bonus / 100)
  -> shield_hit applied to station drones first
  -> residual applies to station structure

(parallel, same round)
station_drone_damage
  Γ— pirate_captain.combat_bonus_factor
  -> applied to player.shields, then hull

Citadel siege specifically

When a pirate-controlled planet has orbital platforms + rail guns + a citadel, all three fire in the same combat-resolver invocation:

Round N:
  orbital_platform.fire(target=attacker, base_damage=500, multiplier=1.0)
  rail_gun.fire(target=attacker, base_damage=1500 Γ— ship_class_multiplier[attacker.ship.type])
  citadel_drones.fire(target=attacker)

  attacker.weapons.fire(target=orbital_platform OR rail_gun OR citadel)

Per the integration spec in ../FEATURES/planets/defense.md#combat-resolver-integration, defense weapons fire before attacker weapons in each round. The defending pirate captain's rank-bonus applies. Suppressed weapons (EMP'd) skip their turn; destroyed weapons stay offline.

Sector layer at planet-siege time

If the planet is inside a Bubble interior and sector drones / mines are still alive in the same sector as the planet, the combat resolver treats them as part of the planet engagement (Phase 2 drone screen of the planet-assault) rather than a separate invocation. This avoids "fight the drones, dock at the planet, fight the platforms separately" rhythm-breaking.

Current-strength state model

The holding's defensive state is the source of truth β€” there is no per-player RaidProgress table. Each defense layer carries:

  • current_strength: Float [0.0, 1.0] β€” defaults to 1.0, decreases on player damage.
  • last_damage_at: DateTime nullable β€” set on each player damage event.
  • tier_recovery_rate: Float β€” sourced from the holding's tier (Camp 0.25/day, Outpost 0.10/day, Stronghold 0.03/day).

Strength schema lives on the entity that carries the defense:

Defense surface Where strength lives
Sector drones / mines Sector.defenses.deployed_drones.current_strength, mines[*].current_strength
Pirate patrol ships Sector.defenses.pirate_patrol_ships[*].current_strength (for the squad as a unit)
Station structure Station.defense_state.current_strength (πŸ“ β€” new sub-shape on existing column)
Planet orbital platforms / rail guns per-platform / per-battery row in the existing planet-defense schema, with a new current_strength column
Citadel Planet.citadel_state.current_strength
OutlawBase / NPC presence OutlawBase.current_strength (logical-anchor health: 1.0 when at full roster, scales down with KIA proportion)

Recovery service

A daily-tick service runs at UTC midnight (alongside the existing planetary-production tick and influence-decay tick). For each pirate-controlled PirateHolding:

def recover_holding(holding):
    rate = TIER_RECOVERY_RATE[holding.tier]    # 0.25 / 0.10 / 0.03 per day
    days_idle = (now - holding.last_pirate_recovery_at).days

    for layer in holding.defense_layers():
        days_since_damage = (now - layer.last_damage_at).days if layer.last_damage_at else float('inf')
        delta = rate * min(days_since_damage, days_idle)
        layer.current_strength = min(1.0, layer.current_strength + delta)

    holding.last_pirate_recovery_at = now

Player-owned holdings skip the recovery service β€” there is no pirate faction rebuilding them. Damage from prior raids stays as-is until the player chooses to repair via the standard repair surfaces (station treasury, planet construction queue, drone manufacturing).

NPC respawn

KIA pirate NPCs follow the standard NPC scheduler KIA cooldown (per ./npc-scheduler.md) modified by tier:

Tier Lord respawn Captain respawn Enforcer respawn
Camp n/a 2 days 1 day
Outpost n/a 5 days 3 days
Stronghold 14 days 7 days 5 days

NPC scheduler Loop B (cadence: 10 min) reads each holding's OutlawBase roster targets and spawns replacement NPCs when their respawn-eligible time elapses. Player-owned holdings: KIA pirates do not respawn β€” the holding's faction is the player, not pirates.

Capture trigger

Capture flips the holding to player ownership when all HOSTILE_RAIDER NPCs anchored to the holding's OutlawBase are KIA, atomically.

def attempt_capture(holding_id, player_id, team_id=None):
    with transaction():
        holding = db.query(PirateHolding).filter(id=holding_id).with_for_update().one()
        if holding.owner_player_id is not None:
            return  # already owned

        active_npcs = db.query(NPCCharacter).filter(
            home_outlaw_base_id=holding.outlaw_base_id,
            status__in=['on_duty', 'off_duty', 'engaged', 'engaged_pending_arrival']
        ).count()

        if active_npcs > 0:
            return  # not all KIA yet

        # Atomic capture
        holding.owner_player_id = player_id
        holding.owner_team_id = team_id
        holding.captured_at = now()

        for planet_id in holding.planet_ids():
            db.query(Planet).filter(id=planet_id).update(controlling_faction='player', owner_player_id=player_id)
        for station_id in holding.station_ids():
            db.query(Station).filter(id=station_id).update(
                owner_player_id=player_id,
                security_level='basic',  # reset to basic; player rebuilds defenses
            )
        if holding.special_formation_id:
            db.query(SpecialFormation).filter(id=holding.special_formation_id).update(
                owner_player_id=player_id, owner_team_id=team_id
            )
        # Convert OutlawBase to NPCBarracks
        convert_outlawbase_to_barracks(holding.outlaw_base_id, owner_player_id=player_id)

        # Per ADR-0060 G-V1: PirateKillLog insert is atomic with capture,
        # inside the same transaction. Realtime events fire post-commit
        # via the transactional outbox per ADR-0054.
        db.session.add(PirateKillLog(
            region_id=holding.region_id,
            holding_id=holding_id,
            attacker_player_id=player_id,
            tier_at_kill=holding.tier,
            disposition='captured',
            killed_at=now(),
        ))

        outbox.queue('pirate.holding_captured', {
            'holding_id': holding_id,
            'tier': holding.tier,
            'name': holding.name,
            'captured_by_player_id': player_id,
            'captured_by_team_id': team_id,
        })
        award_capture_rewards(player_id, team_id, holding)

The with_for_update() row lock prevents two concurrent capture attempts. The cross-table updates plus the kill-log insert run inside a single transaction (per ADR-0060 G-V1); failure rolls everything back, leaving the holding in its current pirate-controlled state. A network failure that previously left the capture committed without the PirateKillLog row no longer occurs β€” the suppression-modifier read path that gates daughter-spawn rate sees a consistent log.

Concurrent-attacker arbitration

For Outposts and Strongholds, when a player initiates combat against any holding defense layer, the holding row is lock-claimed for that attacker:

def claim_holding_for_combat(holding_id, attacker_player_id):
    with transaction():
        holding = db.query(PirateHolding).filter(id=holding_id).with_for_update().one()
        if holding.tier == 'camp':
            return True  # camps are permissive; anyone can engage

        if holding.combat_lock_held_by and holding.combat_lock_held_until > now():
            # Per ADR-0060 G-F2: team-mate engagement is permitted via the
            # snapshotted team set captured at lock acquisition. Live team
            # changes don't grant access β€” late-joiners cannot bypass the lock.
            if attacker_player_id == holding.combat_lock_held_by or \
               attacker_player_id in (holding.combat_lock_team_snapshot or []):
                holding.combat_lock_held_until = now() + timedelta(minutes=15)
                return True
            raise HoldingUnderAttack(holding_id, holding.combat_lock_held_by, holding.combat_lock_held_until)

        # Fresh lock acquisition β€” capture the attacker's current team set
        # as the snapshot. Team-mates within this snapshot can engage in
        # parallel; players who join the team after this instant cannot.
        attacker = db.query(Player).filter(id=attacker_player_id).one()
        team_snapshot = (
            [m.player_id for m in attacker.team.members if m.player_id != attacker_player_id]
            if attacker.team_id else []
        )
        holding.combat_lock_held_by = attacker_player_id
        holding.combat_lock_team_snapshot = team_snapshot
        holding.combat_lock_held_until = now() + timedelta(minutes=15)
        return True

A second attacker hitting the same holding while the lock is active is permitted only if they are the lock holder or in the snapshotted team set. Other attackers receive ERR_HOLDING_UNDER_ATTACK with the lock holder + expiry timestamp. The lock auto-expires after 15 minutes of inactivity (no combat resolver invocations against the holding) β€” covering disconnects, idle pauses, and deliberate withdrawal. On expiry, both combat_lock_held_by and combat_lock_team_snapshot clear; a fresh acquisition rebuilds the snapshot from the new acquirer's current team.

The snapshot semantics close the late-join exploit: a player who joins a team mid-raid cannot bypass the lock just by joining the team β€” they must wait for the lock to release before engaging.

Camps have no lock β€” anyone can roll in. Two attackers at the same Camp race to capture; whoever clears the last NPC wins the atomic flip.

Abandonment cascade

For a player-owned holding inactive for 30 real-time days (no player visit, no team activity, subscription not lapsed), the abandonment service runs:

def process_abandonment(holding):
    if holding.last_visit_at > now - timedelta(days=30):
        return

    # Per ADR-0060 G-V2: re-seeding waits for the combat lock to clear.
    # If the holding's sector is in active combat, the OutlawBase ↔
    # NPCBarracks conversion is deferred to the next abandonment-service
    # tick β€” a player engaged in combat doesn't suddenly find their
    # half-conquered holding repopulated with fresh pirates mid-fight.
    if holding.combat_lock_held_by is not None and \
       holding.combat_lock_held_until > now():
        return

    # Defenses repopulate at 50% strength (anti-grief)
    for layer in holding.defense_layers():
        layer.current_strength = min(0.5, max(layer.current_strength, 0.0))

    # Spawn fresh pirate Lord/captain
    new_lord = spawn_npc(
        archetype=NPCArchetype.HOSTILE_RAIDER,
        role='pirate_lord' if holding.tier == 'stronghold' else 'pirate_captain',
        home_outlaw_base_id=holding.outlaw_base_id,
        # name pool randomized; not the same name as the prior Lord
    )

    # Convert NPCBarracks back to OutlawBase
    convert_barracks_to_outlawbase(holding.outlaw_base_id)

    # Flip ownership
    holding.owner_player_id = None
    holding.owner_team_id = None
    holding.captured_at = None
    holding.last_pirate_recovery_at = now

    # Flip planets / stations back to pirate
    for planet_id in holding.planet_ids():
        db.query(Planet).filter(id=planet_id).update(controlling_faction='pirates', owner_player_id=None)
    for station_id in holding.station_ids():
        db.query(Station).filter(id=station_id).update(owner_player_id=None, controlling_faction='pirates')

    emit_event('holding_abandoned', {'holding_id': holding.id, 'new_lord_npc_id': new_lord.id})

After 90 days untouched, the recovery service ticks the defenses up to 100%; the holding is fully re-fortified.

Insurance, wreck, and rep interactions

  • Insured player ships destroyed in raid: payout per the standard Insurance policy in ../FEATURES/gameplay/ship-insurance.md. PvE pirate combat carries no insurance penalty.
  • Pirate NPC kills: standard destruction handler creates a CargoWreck row; salvage is open to any player after the standard 1-hour grace.
  • Personal reputation on raid failure: no penalty. Dying to pirates in a known pirate site is the expected attempt cost.
  • Faction rep on raid clear: per the table in ../FEATURES/galaxy/pirate-holdings.md#reward-tiers.
  • Pirate vendetta bounty on raid failure (Outpost / Stronghold): 5,000 cr base, scaling with attacker rank, posted via Fringe Alliance, visible 72 hours.

Source map

Concern Path (target)
PirateHolding model services/gameserver/src/models/pirate_holding.py
Holding placement (Phase 12.6) services/gameserver/src/services/galaxy_service.py:GalaxyGenerator._seed_pirate_holdings
Daily recovery service services/gameserver/src/services/pirate_holding_recovery_service.py
Capture trigger services/gameserver/src/services/pirate_holding_service.py:attempt_capture
Abandonment service services/gameserver/src/services/pirate_holding_service.py:process_abandonment
Concurrent-attacker lock pirate_holding_service.py:claim_holding_for_combat
OutlawBase ↔ NPCBarracks conversion pirate_holding_service.py:convert_outlawbase_to_barracks / convert_barracks_to_outlawbase
Engagement routing for pirate patrols services/gameserver/src/services/npc_scheduler.py:route_engagement (existing β€” extends to pirate_patrol_ships)
API: list holdings (admin / player intel) services/gameserver/src/api/routes/pirate_holdings.py
API: capture status query GET /api/v1/pirate-holdings/{id}
Realtime events holding_engagement_started, holding_captured, holding_abandoned, holding_recovery_complete