Skip to content

Bounties

Bounties turn the personal-reputation system into a hunt-and-collect loop. They give every player on the server a financial incentive to take down the worst offenders, and they give wronged players a way to put a real price tag on revenge.

A bounty is an amount of credits attached to a target. Anyone who lands the killing blow against that target in ship-vs-ship combat collects the entire pot.

Two kinds of bounty

Kind Created by Stored where Refundable
Player-placed Any player paying the placement fee Player.settings["bounties"] JSONB list (on the target) Yes (principal only)
System Auto-attached by the Federation Bounty Board based on the target's personal_reputation Computed on demand from reputation tier n/a

System bounties never live in the database — they are derived by reading the target's personal_reputation and looking up SYSTEM_BOUNTY_TIERS. Player-placed bounties are persistent JSONB entries on the target.

✅ Shipped — service-layer bounty placement, system-tier derivation, collection on kill, REST endpoints, websocket events.

🚧 Partial — admin tooling for cancelling stuck bounties on deleted targets, and a soft cap that auto-collapses many small placements.

📐 Design-only — faction-issued bounties (Federation putting a bounty on a specific pirate captain that pays out only to faction members).

Placing a bounty

A player calls POST /api/v1/bounty/place with {target_id, amount}. The placer pays amount + 10% up front:

amount             >= 1000 credits      (BOUNTY_MIN_AMOUNT)
fee                = floor(amount * 0.10)  (non-refundable)
total_cost         = amount + fee
placer.credits    -= total_cost
target.settings["bounties"].append({
    id, placed_by, placed_by_name,
    amount, placed_at, type: "player"
})

Self-bounties are rejected. The placer's row is row-locked during placement to keep two simultaneous placements from double-spending the same credits. ✅ Shipped.

A bounty_placed event is emitted on the realtime bus to the placer, the target, and any global bounty-board subscribers.

System bounty tiers

The Federation auto-attaches a bounty when a player's personal_reputation is deeply negative. Only the single highest-tier bounty applies (the deepest pit pays out, lower-tier bounties don't stack on top of it).

personal_reputation System bounty
−500 5,000
−750 25,000
−1,000 100,000

System bounties materialise and disappear automatically as reputation crosses thresholds — no scheduler involved. ✅ Shipped (service computes them per-call).

🐛 Bug — current code accumulates all matched tiers for the same target instead of taking only the deepest. Target spec is single-tier; the loop in _get_system_bounties should be replaced with a max-match.

Collection on kill

When the combat resolver finishes a ship-vs-ship fight with a clear winner (see combat.md), it calls BountyService.collect_bounty(collector_id, target_id):

  1. Lock both player rows.
  2. Sum player-placed bounties on target.
  3. Sum system bounties (computed from reputation).
  4. Credit the collector with the full total.
  5. Clear target.settings["bounties"] (player-placed only — system bounties go away when the target's reputation recovers).
  6. Emit bounty_collected to the collector, the target, and globally.

System-bounty payouts come from the Federation treasury; the model treats them as a pure credit source (the Federation is not modeled as a wallet). Player-placed payouts come from the escrow held in each placement.

✅ Shipped.

Cancellation

The placer can cancel an uncollected bounty:

DELETE /api/v1/bounty/{bounty_id}
1. find entry by bounty_id in target.settings["bounties"]
2. verify placed_by == placer.id
3. refund amount (NOT the 10% fee) to placer.credits
4. remove entry from list
5. emit bounty_cancelled

The 10% fee is gone forever. Cancellation is intended as a soft escape hatch, not a frictionless undo. ✅ Shipped.

Expiration

Bounties do not auto-expire. They sit on the target until collected or cancelled.

📐 Design-only — optional expiry timestamp on placement (expires_at); auto-refund-minus-fee on expiry. Useful for time-pressure events (e.g., a 48-hour vendetta).

Browsing bounties

GET /api/v1/bounty/board returns the top-N targets ranked by total bounty value (player + system combined), filtered to Player.is_active = true. Result shape per target:

{
  "player_id": "...",
  "player_name": "...",
  "reputation_tier": "Villain",
  "total_bounty": 137000,
  "bounty_count": 4,
  "current_sector": 442
}

This is the public bounty board. ✅ Shipped (service method get_available_bounties).

🚧 Partial — UI surface in the player client (a styled board with portraits, last-seen sector, recent kill log).

Reputation interactions

Bounties and reputation are tightly coupled:

  • Killing a player who had bounties awards +100 reputation (defeat_bounty_target) — bounty hunting is heroic.
  • Killing a player who had no bounty (an "innocent") costs −100 reputation (attack_innocent).
  • Killing a player in an escape pod costs −500 reputation regardless of bounty status (kill_escape_pod).
  • Defending against an attacker awards +50 (defend_against_attacker).

Drift to personal_reputation ≤ −500 automatically attaches a system bounty, so attacking innocents and pod-killers are self-targeting — they place a bounty on themselves the more they offend.

See SYSTEMS/bounty-and-reputation.md for the full reputation tier table and combat-hook contract.

Edge cases

Case Behaviour
Two attackers fight the same target simultaneously Combat resolver determines a single winner; collect_bounty runs once for that winner. Both rows row-locked during the call.
Target has zero bounties when killed collect_bounty returns {success: false}; combat result is unaffected.
Bounty placed on a player who deletes their account Placer can refund via admin tool. 🚧 Partial.
Bounty list grows unbounded (50+ small placements on one target) Soft cap at 50 entries; older entries collapsed by placer (sum amounts). 📐 Design-only.
Reputation recovers after system bounty was attached System bounty disappears the moment personal_reputation rises above the threshold (computed live).

Source map

Concern Path (target)
Bounty service services/gameserver/src/services/bounty_service.py
Reputation service (deltas) services/gameserver/src/services/personal_reputation_service.py
Combat hook calling collect_bounty services/gameserver/src/services/combat_service.py
Player JSONB field services/gameserver/src/models/player.py (settings["bounties"])
REST routes services/gameserver/src/api/routes/bounty.py (target — currently in player.py)
Realtime events services/gameserver/src/services/websocket_service.py