Skip to content

Bounties

Status: ๐Ÿšง Partial โ€” Gameserver placement/collection/cancel/expire/admin routes and post-commit bounty_updated realtime emits are shipped on origin/feat 46bce720. Player-client: StatusBar bounty_total + websocket consumer โœ…; bounty-board browse and player placement/cancel UI ๐Ÿšง tip-pending (Fibril #1178 / PR #653). Admin BountyAdminPanel โœ… Shipped on tip (force-cancel + collapse; EconomyDashboard mount; LEG-915 / PR #673 tip-land). PlayerBountyPanel also mounted from PlayerAnalytics player detail modal (PR #754 / LEG-853). Detail: Player-facing affordances below. (re-verified 2026-08-21 vs Sectorwars2102 46bce720.)

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
Faction-issued An admin action on behalf of an issuing faction, targeting a specific NPC pirate captain NPCCharacter.backstory["faction_bounty"] JSONB (single active entry, on the NPC) No

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, placer-only cancel/refund, REST endpoints (router mounted 2026-06-11). โœ… Shipped โ€” realtime bounty_updated emit on place / cancel / collect: connection_manager.send_bounty_update (websocket_service.py:679) post-commit from ranking.py place (:606) and cancel (:652), plus combat collect via _emit_bounty_collected (combat_service.py:222, called :1145); player-client consumes the frame (#190). Consolidated โœ…/๐Ÿšง split: see top Status banner and Player-facing affordances.

โœ… Shipped (2026-08-04) โ€” GS admin APIs: POST /admin/players/{target_id}/bounties/{bounty_id}/force-cancel and POST /admin/players/{target_id}/bounties/collapse (BountyService.admin_force_cancel_bounty / collapse_excess_bounties) โ€” kernel routes live on tip. โœ… Admin UI โ€” BountyAdminPanel (force-cancel + collapse) mounted from EconomyDashboard on origin/feat 46bce720 (LEG-915 / PR #673 tip-land). PlayerBountyPanel (same force-cancel + collapse controls) is also mounted from the PlayerAnalytics player detail modal (PlayerAnalytics.tsx:861, PR #754 / LEG-853, tip 46bce720).

โœ… Shipped (2026-08-04) โ€” faction-issued bounties: an admin action (POST /admin/npcs/{npc_id}/faction-bounty, ECONOMY_INTERVENE scope) places a single-entry bounty on an NPC pirate captain (bounty_service.place_faction_bounty, stored in NPCCharacter.backstory JSONB โ€” schema:no, mirrors the system pot's single-value shape rather than the player-placed list). Payout fires automatically on kill (combat_service.py's dead_npc hook, bounty_service.collect_faction_bounty) and pays the killer's credits ONLY if their own reputation with the issuing faction is at least RECOGNIZED (bounty_service._collector_passes_faction_gate, mirrors contraband_service's Fringe-Alliance gate exactly โ€” the codebase's established "faction membership" proxy, since there is no discrete membership row, only a per-faction Reputation tier). A collector below the gate still gets the kill; the bounty is left standing, uncleared, for a future eligible hunter to claim.

Placing a bounty

A player calls POST /ranking/bounties/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_updated event (action: "placed") is emitted on the realtime bus to the placer, the target, and any global bounty-board subscribers. โœ… Shipped โ€” post-commit best-effort broadcast via connection_manager.send_bounty_update in ranking.py:606 (place) / :652 (cancel).

System bounty tiers

The Federation attaches a system bounty to any player whose personal_reputation falls to โˆ’500 or below. The bounty is a stored pot that accrues over time (growing faster on dastardly acts via npc_scheduler) and resets to 0 after a hunter collects it. The pot grows toward the cap of the deepest reputation tier the criminal currently matches โ€” only the deepest tier's cap applies.

personal_reputation โ‰ค Pot cap
โˆ’500 5,000
โˆ’750 75,000
โˆ’1,000 250,000

The pot re-accrues from 0 after each collection. Reputation recovery above โˆ’500 stops accrual; the residual pot stays until collected or the player's reputation rises above the threshold for long enough for it to decay.

โœ… Shipped โ€” stored-pot model, tiered caps, and scheduler accrual. See services/gameserver/src/services/bounty_service.py and DECISIONS.md ยง system-bounty-accrual-model.

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_updated (action: "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 โ€” credit and clear on kill. โœ… Shipped โ€” collect emit: after a paying collect_bounty, combat calls _emit_bounty_collected (combat_service.py:222 / :1145) which fans out send_bounty_update(action="collected") (best-effort; never undoes the credit clear).

Cancellation

The placer can cancel an uncollected bounty:

POST /ranking/bounties/{bounty_id}/cancel  {target_id}
1. row-lock both the placer and the target
2. find entry by bounty_id in target.settings["bounties"]
3. verify placed_by == placer.id, not a system bounty, still present
4. remove the entry from the list FIRST
5. credit the uncollected principal (NOT the 10% fee) to placer.credits
6. emit bounty_updated (action: "cancelled")

Only the placer may cancel, and only a not-yet-collected bounty refunds. Removing the entry before crediting, under the dual row-lock, makes a double-refund impossible. The 10% fee is gone forever (canon invariant #9). Cancellation is a soft escape hatch, not a frictionless undo. โœ… Shipped.

Expiration

By default bounties do not auto-expire โ€” the placer may optionally set an expiry (1-90 days, duration_days at placement, BOUNTY_MIN_DURATION_DAYS/BOUNTY_MAX_DURATION_DAYS) when placing one; omitting it (the default) sits the bounty on the target until collected or cancelled, as before.

โœ… Shipped 2026-08-04. POST /ranking/bounties/place accepts an optional duration_days; the placed entry then carries an expires_at. A scheduler sweep (BountyService.expire_due_bounties, wired as _run_bounty_expire_sweep_sync, BOUNTY_EXPIRE_SWEEP_SECONDS default 10 min) auto-cancels any bounty past its expires_at and refunds the placer the escrowed principal โ€” the 10% placement fee stays non-refundable, same as a manual cancel. ๐Ÿ“ NO-CANON: the 1-90 day bounds and the 10-minute sweep cadence are conservative defaults pending a DECISIONS.md magnitude ruling (canon's own "48-hour vendetta" example is illustrative, not a ratified number).

Browsing bounties

GET /ranking/bounties/available 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,
  "portrait_url": null,
  "recent_kills": [
    {
      "combat_id": "...",
      "victim_id": "...",
      "victim_name": "...",
      "sector_id": 442,
      "timestamp": "2026-08-16T22:59:09+00:00"
    }
  ]
}

portrait_url is a nullable string; today the gameserver always emits null (no durable OAuth / User avatar โ€” LEG-DEC-33). recent_kills is an array of CombatLog PvP wins for the target (empty [] is OK); element keys are only combat_id, victim_id, victim_name, sector_id, timestamp as shipped by LEG-173 / PR #656 a368bea5 โ€” do not invent further fields.

This is the public bounty board. โœ… Shipped (service method get_available_bounties, including portrait_url + recent_kills). Player-client browse/placement UI status is consolidated under Player-facing affordances below โ€” do not read this REST contract as implying a live tip HUD for the board.

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 Admin force-cancels via POST /admin/players/{target_id}/bounties/{bounty_id}/force-cancel, refunding the placer. โœ… Shipped.
Bounty list grows unbounded (50+ small placements on one target) Admin collapses via POST /admin/players/{target_id}/bounties/collapse โ€” entries beyond the 50-entry soft cap merge per-placer (sum amounts). โœ… Shipped.
Reputation recovers after system bounty was attached System bounty disappears the moment personal_reputation rises above the threshold (computed live).

Player-facing affordances

  • โœ… Realtime bounty_updated consumer โ€” services/player-client/src/services/websocket.ts handles place / cancel / collect frames on origin/feat 46bce720 (GameContext.tsx:1877 subscriber).
  • โœ… StatusBar head-price โ€” services/player-client/src/components/layouts/StatusBar.tsx shows bounty_total when > 0 (title="Bounty on your head", :578). Partial surface only; not a board or placement flow.
  • ๐Ÿšง Bounty board browse UI โ€” tip-pending Fibril #1178. bountyAPI.getAvailable exists in services/player-client/src/services/api.ts (:886) but has zero imports/callers under services/player-client/src/components on tip; no BountyBoard component on tip. Pending PR #653 OPEN (not tip-ancestor as of 46bce720). When that PR lands, expect Service Record dossier mount + available-bounties contract bind; portraits stay honest-null until a separate avatar-persistence WO (LEG-DEC-33).
  • ๐Ÿšง Player bounty placement / cancel UI โ€” tip-pending Fibril #1178. bountyAPI.place and bountyAPI.getOnTarget are wired in api.ts with no component callers on tip. Do not read kernel REST โœ… above as implying live placement or cancel controls in the player-client on 46bce720 โ€” same PR #653 / #1178 stack.

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/ranking.py (/ranking/bounties/*)
Realtime events services/gameserver/src/services/websocket_service.py