Skip to content

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, opaque context payload).
  • The Medal catalog (static-ish, seeded from services/gameserver/src/services/medal_catalog.py).
  • The player's existing PlayerMedal rows (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_privacy block 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:

  1. Read all relevant lifetime counters via the registered counter resolvers.
  2. Iterate the Medal catalog, filtering to is_retroactive = true.
  3. For each match, INSERT PlayerMedal with awarded_via = retroactive_backfill, awarded_at = NOW() (we don't know historical timing).
  4. Suppress medal_awarded realtime broadcast during backfill (avoids notification floods on launch day).
  5. After backfill commits, fire one medals_backfilled per-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:

  1. Player row lock. The combat resolver already SELECT … FOR UPDATEs both player rows before any state read (combat-resolver.md invariant 2). The second combat sees victory_count = 100 after the first commits and the threshold check passes a second time, but…
  2. UNIQUE (player_id, medal_id). The second INSERT INTO player_medals fails with IntegrityError; service catches it, returns None (already awarded), no event fires.
  3. Pre-INSERT advisory check. Inside award_medal, before INSERT we issue a SELECT 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

  1. A (player_id, medal_id) pair has at most one PlayerMedal row.
  2. awarded_via = retroactive_backfill rows have source_combat_log_id = NULL (historical timing unrecoverable).
  3. The medal_awarded realtime broadcast is best-effort; failure to broadcast does not roll back the award.
  4. Retroactive backfill is idempotent: running it twice on the same player yields zero new awards.
  5. Effect application is read-time β€” the medal's effect_key modifier is consulted by the relevant service (e.g. trading_service reads any medals with effect_key = trade_discount), not stored on Player. Same pattern as military_rank's trading_bonus.
  6. Hidden-medal handling is split: Medal.is_hidden controls the public catalog; PlayerMedal.is_hidden_per_player is the per-earner privacy override (defaults from Player.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