Medal Service¶
Status: π§ Partial β A minimal MedalService awards a hardcoded 13-medal dict into Player.settings JSONB and is genuinely wired into combat + trading β¦ Β· β οΈ contains codeβspec divergence (impl audit 2026-06-16)
Purpose¶
MedalService is the dispatcher that turns gameplay events into awarded medals. Every event that could earn a player a medal β a combat victory, a trade completion, a sector first-discovery, a first-login completion, a faction-rep tier crossing β fires through one entry point. The service consults the Medal catalog, evaluates each medal's trigger condition against the event, and awards any that pass. Idempotent at the schema level; broadcast-driven for the player-facing notification.
This doc covers the lifecycle and machinery. The medal catalog itself (what medals exist, what they commemorate, what they look like, the Orange Cat Society lore) lives in FEATURES/gameplay/medals.md.
Inputs¶
The dispatcher reads:
- The fired event (
event_key,player_id, opaquecontextpayload). - The
Medalcatalog (static-ish, seeded fromservices/gameserver/src/services/medal_catalog.py). - The player's existing
PlayerMedalrows (idempotency check). - A registry of counter resolvers β small functions that read the current value of a counter the catalog references (
combat_victories,trades_completed,sectors_discovered,colonists_transported, etc.). Each owning service registers its resolver at import time. - The player's
Player.settings.medal_privacyblock for broadcast-routing decisions.
The dispatcher fires from these call sites:
| Service | Method | event_key |
|---|---|---|
combat_service.attack_player (post-resolution hook) |
post-hook | combat.victory |
trading_service.execute_trade (after commit) |
post-commit | trade.completed |
movement_service (first-discovery branch) |
inline | exploration.sector_discovered |
genesis_service.complete_planet_formation |
post-completion | genesis.planet_created |
first_login_service.complete_first_login |
session-completion | first_login.completed |
first_login_service (cat detection) |
per-exchange | first_login.cat_detected |
personal_reputation_service.adjust_reputation (tier-change branch) |
conditional | reputation.tier_changed |
faction_service.update_reputation (faction tier crossing) |
conditional | faction.tier_changed |
governance_service (election outcome, term completion) |
post-event | governance.term_completed |
bounty_service.collect_bounty |
post-payout | bounty.collected |
team_service (creation, alliance, war) |
post-event | social.team_founded, social.alliance_formed |
Admin route POST /api/v1/admin/medals/grant |
direct | (none β awarded_via = admin_grant) |
Scheduled CLI: python -m sw2102.cli backfill-medals |
catch-up | (per-player retroactive) |
Each call site is one line: medal_service.check_and_award_for_event(player_id, event_key, context). Adding a new medal in the catalog requires zero changes in the call sites β the dispatcher does the matching.
Storage¶
Two tables (per ADR-0028):
Medal (catalog)¶
Source: services/gameserver/src/models/medal.py (target).
One row per defined medal. Seeded at startup from services/gameserver/src/services/medal_catalog.py:MEDAL_SEEDS; MedalCatalog.sync_to_db() runs on application startup to apply additions / wording updates idempotently.
| name | type | constraints | notes |
|---|---|---|---|
| id | String(64) | PK | Stable hyphen-free key, e.g. combat.bronze_cluster, easter_egg.orange_cat_society |
| name | String(80) | not null | Display name, e.g. "Bronze Cluster" |
| category | Enum medal_category |
not null | COMBAT / ECONOMIC / EXPLORATION / DIPLOMATIC / SPECIAL |
| tier | Enum medal_tier |
not null | BRONZE / SILVER / GOLD / PLATINUM / UNIQUE |
| description | Text | not null | Short flavor (1β2 sentences) |
| lore_text | Text | nullable | Long flavor (paragraph) for the medal-detail modal |
| icon_key | String(64) | not null | UI asset key (e.g. combat/bronze_star.svg) |
| shape_tier | Enum medal_shape |
not null | circle / hexagon / star / shield / cross β accessibility-safe second axis beyond color |
| trigger_type | Enum medal_trigger |
not null | COUNT_THRESHOLD / EVENT_FIRED / FIRST_TIME / ADMIN_GRANT / HIDDEN |
| trigger_config | JSONB | not null | Shape varies by trigger_type β see below |
| effect_key | String(64) | nullable | One of the 3-allowlist effect keys; NULL for cosmetic-only medals |
| effect_magnitude | Float | nullable | Magnitude paired with effect_key |
| prerequisite_medal_ids | ARRAY(String(64)) | default [] |
Must hold all of these first |
| prerequisite_min_rank | String(50) | nullable | Military-rank gate |
| is_hidden | Boolean | default false | Not shown in public catalog until earned |
| is_retroactive | Boolean | default true | Eligible for backfill on launch |
| display_order | Integer | default 1000 | Sort order within category |
| created_at, updated_at | DateTime | server defaults |
trigger_config JSONB shape per trigger_type¶
// COUNT_THRESHOLD
{"counter_key": "combat_victories", "threshold": 100}
// EVENT_FIRED β fires once on first occurrence of named event
{"event_key": "first_login.cat_detected"}
// FIRST_TIME β like COUNT_THRESHOLD with threshold 1, but with an event filter
{"counter_key": "trades_completed", "threshold": 1, "filter": {"commodity": "luxury_goods"}}
// ADMIN_GRANT β no automatic check; only admin tool can award
{}
// HIDDEN β behaves like its underlying type encoded in subtype
{"subtype": "EVENT_FIRED", "event_key": "first_login.cat_detected"}
PlayerMedal (association)¶
Source: services/gameserver/src/models/medal.py (target).
One row per (player, medal) pair. Idempotent at the schema level via the unique constraint.
| name | type | constraints | notes |
|---|---|---|---|
| id | UUID | PK | |
| player_id | UUID FK players.id | not null, indexed, CASCADE | |
| medal_id | String(64) FK medals.id | not null, indexed, RESTRICT | RESTRICT prevents catalog deletion if any player holds it |
| awarded_at | DateTime | server default now, indexed | |
| awarded_via | Enum awarded_via |
not null | event_check / count_threshold / first_time / admin_grant / retroactive_backfill |
| source_combat_log_id | UUID FK combat_logs.id | nullable | For combat medals β provenance |
| source_event_key | String(64) | nullable | The event_key that triggered the award |
| awarded_by_user_id | UUID FK users.id | nullable | Admin grants only |
| context_payload | JSONB | nullable | Snapshot of trigger context (e.g. victory_count at award) |
| is_hidden_per_player | Boolean | default false | Per-player override of Medal.is_hidden (privacy curation) |
Constraints:
UNIQUE (player_id, medal_id)β the idempotency keystone.- Composite index
(player_id, awarded_at DESC)β recent-medals queries for the Trophy Room. - Index
(medal_id)β rarity / leaderboard queries.
Relationships:
playerβPlayer.medalβMedal.awarded_by_userβUser(nullable, admin grants only).
Process¶
The dispatcher state machine:
[event fires at call site]
β
βΌ
[MedalService.check_and_award_for_event(player_id, event_key, context)]
β
ββ Acquire row lock on Player (combat-resolver-style SELECT FOR UPDATE)
β
ββ Enumerate Medal catalog: candidates whose trigger matches event_key
β OR whose counter_key is the one this event would advance
β
ββ For each candidate medal:
β ββ Already held by player? β skip (no event)
β ββ Prerequisites satisfied (other medals + min rank)? β skip if no
β ββ Trigger condition met (re-evaluate against fresh context)? β skip if no
β β
β ββ INSERT PlayerMedal (idempotent β UNIQUE catches race)
β β
β βΌ
β [post-award hooks, all best-effort]
β ββ Apply effect (if effect_key set; one of the 3 allowlist effects)
β ββ Broadcast medal_awarded to:
β β - personal:{user_id} (always)
β β - team:{team_id} (if player in team)
β β - sector:{current_sector_id} (only if tier β₯ GOLD or category = UNIQUE)
β ββ Write Message to player inbox (system, priority high) for offline-pickup
β ββ Mark unviewed in Player.settings.medal_privacy.unviewed_awards
β
βΌ
[Return list of awarded medals to caller]
Counter-threshold side-channel¶
For COUNT_THRESHOLD medals the dispatcher additionally fires medal_progress events at meaningful percentages (25% / 50% / 75% / 90% / 99%) of the threshold β only to personal:{user_id}. Configurable per medal (some hidden medals suppress progress entirely so the player doesn't get spoiler hints).
Retroactive backfill¶
Launch problem: existing players have combat_victories > 100 but no Bronze Cluster.
Backfill is a separate CLI command, not part of the table-creation migration:
python -m sw2102.cli backfill-medals
Per player:
- Read all relevant lifetime counters via the registered counter resolvers.
- Iterate the Medal catalog, filtering to
is_retroactive = true. - For each match, INSERT
PlayerMedalwithawarded_via = retroactive_backfill,awarded_at = NOW()(we don't know historical timing). - Suppress
medal_awardedrealtime broadcast during backfill (avoids notification floods on launch day). - After backfill commits, fire one
medals_backfilledper-player notification at next connect.
is_retroactive = false medals (seasonal, "first-ever-to-do-X") are never backfilled.
Service-layer API¶
services/gameserver/src/services/medal_service.py (target):
class MedalService:
# ββ Single dispatcher (preferred entry point) ββββββββββββββββββ
def check_and_award_for_event(
self,
player_id: UUID,
event_key: str,
context: dict | None = None,
) -> list[PlayerMedal]:
"""Single entry point. Looks up Medal catalog entries whose trigger
matches event_key, evaluates each, awards any that pass. Idempotent."""
# ββ Compatibility shim β preserves the existing canonical signature β
def check_combat_medals(
self, winner_id: UUID, victory_count: int
) -> list[PlayerMedal]:
"""Thin wrapper β check_and_award_for_event(winner_id, 'combat.victory',
{'victory_count': victory_count})."""
# ββ Core award (idempotent) ββββββββββββββββββββββββββββββββββββ
def award_medal(
self,
player_id: UUID,
medal_id: str,
awarded_via: AwardedVia,
source_combat_log_id: UUID | None = None,
source_event_key: str | None = None,
awarded_by_user_id: UUID | None = None,
context_payload: dict | None = None,
broadcast: bool = True,
) -> PlayerMedal | None:
"""Returns the row if newly awarded, None if already held."""
# ββ Queries ββββββββββββββββββββββββββββββββββββββββββββββββββββ
def get_player_medals(self, player_id: UUID, *, include_hidden: bool = False) -> list[PlayerMedal]: ...
def get_player_medal_count(self, player_id: UUID, category: MedalCategory | None = None) -> int: ...
def get_medal_holders(self, medal_id: str, limit: int = 100) -> list[UUID]: ...
def get_medal_rarity(self, medal_id: str) -> float: ... # % of active playerbase
def get_catalog(self, *, include_hidden: bool = False) -> list[Medal]: ...
# ββ Admin ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def admin_grant(self, player_id: UUID, medal_id: str, granting_user_id: UUID, reason: str) -> PlayerMedal: ...
def admin_revoke(self, player_id: UUID, medal_id: str, revoking_user_id: UUID, reason: str) -> bool: ...
def admin_bulk_grant(self, player_ids: list[UUID], medal_id: str, granting_user_id: UUID, reason: str, batch_id: str) -> dict[UUID, bool]: ...
def admin_bulk_revoke(self, batch_id: str, revoking_user_id: UUID, reason: str) -> int: ...
# ββ Retroactive backfill βββββββββββββββββββββββββββββββββββββββ
def backfill_player(self, player_id: UUID) -> list[PlayerMedal]: ...
def backfill_all_players(self, *, suppress_broadcast: bool = True) -> dict[UUID, int]: ...
# ββ Counter resolvers (registry pattern, internal) βββββββββββββ
def _resolve_counter(self, counter_key: str, player_id: UUID) -> int: ...
Counter registry (each owning service registers at import time):
counter_key |
Resolver |
|---|---|
combat_victories |
CombatLog.count(winner_id=player_id) |
trades_completed |
MarketTransaction.count(player_id=player_id) |
sectors_discovered |
DiscoveredSector.count(player_id=player_id) |
colonists_transported |
sum of pioneer-contract quantity from MarketTransaction filtered to colonists |
planets_owned_now |
player_planets.count(player_id=player_id) |
warp_gates_built |
WarpGate.count(builder_id=player_id) |
quantum_shards_harvested_self |
sum of self-harvest entries on Player counters |
| (others) | added on demand by the owning service |
Realtime events¶
Three new event types added to the realtime-bus.md taxonomy:
medal_awarded¶
| Field | Value |
|---|---|
| Direction | server β client |
| Rooms | personal:{user_id} (always); team:{team_id} (if in team and not muted); sector:{current_sector_id} (only when tier β₯ GOLD or category = UNIQUE) |
| Payload | {medal_id, name, tier, category, icon_key, shape_tier, awarded_at, awarded_via, citation, is_hidden} |
| Status | π Design-only |
When is_hidden = true, the name and citation fields are obscured on first delivery (UI hint-shaped reveal); subsequent fetches via REST return clear text.
medal_progress¶
| Field | Value |
|---|---|
| Direction | server β client |
| Rooms | personal:{user_id} only |
| Payload | {medal_id, counter_key, current, threshold, percent} |
| Fired at | 25%, 50%, 75%, 90%, 99% of threshold (configurable per medal) |
| Status | π Design-only |
medal_revoked¶
| Field | Value |
|---|---|
| Direction | server β client |
| Rooms | personal:{user_id} |
| Payload | {medal_id, reason, revoking_admin_username} |
| Status | π Design-only |
Authentication / scope: the bus already enforces room membership from authoritative DB state (realtime-bus.md invariant 5). A player not in sector:{X} does not receive the sector copy of medal_awarded β no extra ACL needed.
Concurrency invariants¶
The "two combats both award Bronze Cluster at victory 100" race is closed by three layers:
- Player row lock. The combat resolver already
SELECT ⦠FOR UPDATEs both player rows before any state read (combat-resolver.mdinvariant 2). The second combat seesvictory_count = 100after the first commits and the threshold check passes a second time, but⦠UNIQUE (player_id, medal_id). The secondINSERT INTO player_medalsfails withIntegrityError; service catches it, returnsNone(already awarded), no event fires.- Pre-INSERT advisory check. Inside
award_medal, before INSERT we issue aSELECT 1 FROM player_medals WHERE player_id AND medal_id FOR UPDATE. Combined with (1), the second concurrent attempt blocks until the first commits and bails on the existing-row check.
Lifecycle invariants¶
- A
(player_id, medal_id)pair has at most onePlayerMedalrow. awarded_via = retroactive_backfillrows havesource_combat_log_id = NULL(historical timing unrecoverable).- The
medal_awardedrealtime broadcast is best-effort; failure to broadcast does not roll back the award. - Retroactive backfill is idempotent: running it twice on the same player yields zero new awards.
- Effect application is read-time β the medal's
effect_keymodifier is consulted by the relevant service (e.g.trading_servicereads any medals witheffect_key = trade_discount), not stored onPlayer. Same pattern asmilitary_rank'strading_bonus. - Hidden-medal handling is split:
Medal.is_hiddencontrols the public catalog;PlayerMedal.is_hidden_per_playeris the per-earner privacy override (defaults fromPlayer.settings.medal_privacy.show_hidden).
Failure modes¶
| Mode | Target handling |
|---|---|
| Award race (concurrent threshold-cross) | Closed by UNIQUE (player_id, medal_id); second insert is a no-op |
Counter-resolver missing for a counter_key referenced in catalog |
Log error, skip the medal, do not roll back the upstream event. Catalog is a Python dict β fix-forward |
| Realtime broadcast fails | Log; award is committed; player picks it up on next REST fetch via the inbox system message |
Catalog drift (medal removed from MEDAL_SEEDS while players still hold it) |
ON DELETE RESTRICT on FK blocks the catalog row delete; admins must admin_revoke from each holder first |
| Bulk-grant batch contains invalid player IDs | Server-side dry-run validates first; admin gets explicit invalid list before commit |
| Bulk-grant size > 50 | Personal toast suppression β players see the medal at next login splash, not realtime, to avoid sector/team broadcast spam |
Source map¶
| Concern | Path (target) |
|---|---|
| Models (Medal, PlayerMedal, enums) | services/gameserver/src/models/medal.py |
| Catalog seed data | services/gameserver/src/services/medal_catalog.py (MEDAL_SEEDS dict + sync_to_db()) |
| Service | services/gameserver/src/services/medal_service.py |
| API routes | services/gameserver/src/api/routes/medals.py (player) + services/gameserver/src/api/routes/admin_medals.py (admin) |
| Backfill CLI | services/gameserver/src/cli/backfill_medals.py |
| Migration | services/gameserver/alembic/versions/<rev>_add_medal_tables.py |
Related¶
- Catalog + lore + UX surfaces:
../FEATURES/gameplay/medals.md. - Schema decision rationale: ADR-0028.
- Realtime bus event taxonomy:
./realtime-bus.md. - Combat-resolver post-hook (the single largest medal-fire source):
./combat-resolver.md. - Cat-detection mechanic that fires
first_login.cat_detected:./first-login.md. - Personal-rep tier crossings (some medals fire here):
./bounty-and-reputation.md. - ARIA's existing "best-effort hooks" pattern (mirrored here):
combat-resolver.mdinvariant 7.