Bounties¶
Status: ๐ง Partial โ Gameserver placement/collection/cancel/expire/admin routes and post-commit
bounty_updatedrealtime emits are shipped on origin/feat46bce720. Player-client: StatusBarbounty_total+ websocket consumer โ ; bounty-board browse and player placement/cancel UI ๐ง tip-pending (Fibril#1178/ PR #653). AdminBountyAdminPanelโ Shipped on tip (force-cancel + collapse;EconomyDashboardmount; LEG-915 / PR #673 tip-land).PlayerBountyPanelalso mounted fromPlayerAnalyticsplayer detail modal (PR #754 / LEG-853). Detail: Player-facing affordances below. (re-verified 2026-08-21 vs Sectorwars210246bce720.)
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):
- Lock both player rows.
- Sum player-placed bounties on target.
- Sum system bounties (computed from reputation).
- Credit the collector with the full total.
- Clear
target.settings["bounties"](player-placed only โ system bounties go away when the target's reputation recovers). - 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
+100reputation (defeat_bounty_target) โ bounty hunting is heroic. - Killing a player who had no bounty (an "innocent") costs
โ100reputation (attack_innocent). - Killing a player in an escape pod costs
โ500reputation 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_updatedconsumer โservices/player-client/src/services/websocket.tshandles place / cancel / collect frames on origin/feat46bce720(GameContext.tsx:1877subscriber). - โ
StatusBar head-price โ
services/player-client/src/components/layouts/StatusBar.tsxshowsbounty_totalwhen > 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.getAvailableexists inservices/player-client/src/services/api.ts(:886) but has zero imports/callers underservices/player-client/src/componentson tip; noBountyBoardcomponent on tip. Pending PR #653 OPEN (not tip-ancestor as of46bce720). 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.placeandbountyAPI.getOnTargetare wired inapi.tswith no component callers on tip. Do not read kernel REST โ above as implying live placement or cancel controls in the player-client on46bce720โ same PR #653 /#1178stack.
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 |
Related¶
combat.mdโ kill resolution and the combat-side hook.factions-and-teams.mdโ how reputation and faction standing differ.SYSTEMS/bounty-and-reputation.mdโ full subsystem spec including reputation tiers, decay, and invariants.SYSTEMS/combat-resolver.mdโ where the kill detection that triggers collection lives.