Transmission #066: WebSocket event loop — achievements over AsyncAPI

Helerion’s gameplay event path used to be a dormant REST stub: POST /characters/{id}/events in openapi.yaml with no real server handler and no Unity client. That was fine for scaffolding, but achievement triggers need server-initiated updates — unlock ids pushed after the DB row exists — not a request/response poll loop.

This Transmission records a Prompt-Driven Development pass that replaced that stub with a spec-first WebSocket channel on the same port as Jersey REST. v1 scope: evaluate HARVEST / KILL / SKILL events, persist achievement progress in PostgreSQL, push achievement_ids only over the socket, and merge locally in Unity with lazy GET /achievements/{id} for display names. Full planning artifacts live under .agents/planning/2026-06-10-websocket-event-loop/.

Why this shape

Choice Rationale
AsyncAPI at repo root Mirrors openapi.yaml: one contract, two codegen pipelines (Java Tyrus + Unity C#).
Tyrus on shared Grizzly Same GlassFish ecosystem as Jersey; single listener on JERSEY_PORT (default 8080). Javalin sidecar rejected.
JSON envelope v1 {"v":1,"type":"…","payload":{…}} — debuggable today; Protobuf can swap the codec later without changing handler interfaces.
Custom asyncapi-generator No off-the-shelf Tyrus template; we generate CharacterEventsApi + @ServerEndpoint adapter like OpenAPI → JAX-RS.
Id-only eventResult WS stays a notification buffer; Unity merges into HydratedCharacter.achievements and fetches catalog detail once per new id.
NativeWebSocket (UPM) Coroutine-friendly Unity client with main-thread dispatch; outbound queue while offline.

Architecture

flowchart LR
  U[Unity client]
  G[Grizzly :8080]
  J[Jersey /v1 REST]
  T[Tyrus WebSocket]
  A[CharacterEventsApiImpl]
  E[AchievementEngine]
  P[(PostgreSQL)]
  U -->|HTTP OpenAPI| J
  U -->|WS AsyncAPI envelope| T
  J --> P
  T --> A
  A --> E
  E --> P
  A -->|eventResult| T

Tyrus registers a WebSocketAddOn on Jersey’s existing Grizzly NetworkListener so upgrades are handled before the HTTP filter chain:

flowchart TB
  subgraph listener["Grizzly NetworkListener"]
    WS[Tyrus WebSocketAddOn]
    HTTP[Jersey /v1]
  end
  REQ[TCP :8080] --> WS
  WS -->|HTTP| HTTP
  WS -->|Upgrade| EP["/v1/characters/{id}/events"]
  EP --> API[CharacterEventsApiImpl]

End-to-end lifecycle (verified in Play Mode — chop a tree, see unlock):

sequenceDiagram
  participant CS as CharacterSession
  participant EB as HelerionEventBridge
  participant ES as HelerionEventSession
  participant S as Server
  participant PB as HelerionEventProgressionBridge

  CS->>CS: SessionReady
  ES->>S: WS connect
  Note over EB: Player kills harvestable
  EB->>ES: reportEvents HARVEST
  ES->>S: JSON envelope
  S->>S: AchievementEngine + persist
  S-->>ES: eventResult achievement_ids
  ES->>PB: EventResultReceived
  PB->>PB: ProgressionStateMerger
  PB->>S: GET /achievements/id (new ids only)

Wire format (v1)

Channel: ws(s)://host/v1/characters/{characterId}/events

{
  "v": 1,
  "type": "reportEvents",
  "payload": {
    "events": [{ "type": "HARVEST", "quantity": 1 }]
  }
}

Server replies with type: "eventResult" and payload.achievement_ids: [1, …], or type: "error" for invalid events (socket stays open).

Try it locally

Server (from server/):

./gradlew shadowJar
make integration-environment-setup   # Postgres + JAR on :8080
# or: DB_* + JERSEY_PORT=8080 ./gradlew run
curl -s http://localhost:8080/v1/items | head

Unity: set HelerionGameData.apiBaseUrl to http://localhost:8080, enter Play Mode, create/select a hero, punch a harvestable tree. Console should show [HelerionEventSession] eventResult received and [HelerionEventProgression] unlocked "First Harvest".

Integration test: ./gradlew integrationTestCharacterEventsWsIntegrationTest covers REST create → WS → DB row.

What shipped (Steps 1–12)

All checklist items in implementation/plan.md are complete.

  1. Contract — repo-root asyncapi.yaml; Gradle asyncApiValidate.
  2. Models — Modelina Java + C#; scripts/regenerate-helerion-events-client.sh.
  3. Codec — hand-written JsonWireCodec + EnvelopeCodecTest.
  4. Persistence — Flyway player_achievement; PlayerAchievementRepository.
  5. EngineAchievementEngine + trigger JSONB; unit tests.
  6. Codegenserver/asyncapi-generator/ → Tyrus stubs + Unity ICharacterEventsApi.
  7. Tyrus bootstrap — shared Grizzly; SessionRegistry; Dagger wiring.
  8. HandlerCharacterEventsApiImpl persist-before-push; WS integration test.
  9. OpenAPI cleanup — removed HTTP POST …/events; regen REST clients.
  10. Unity sessionHelerionEventSession (connect, reconnect, queue, dispatch).
  11. Progression mergeProgressionStateMerger, AchievementDetailCache, HelerionEventProgressionBridge.
  12. Gameplay bridgeHelerionEventBridge from harvest / kill / skill hooks.

Unity notes

Out of scope (v1)

Protobuf on the wire, WS auth tokens, server replay, achievement item drops over WS, full progression deltas (quests/stats/items) — reserved in AsyncAPI as phase C.

Planning chronicle

Earlier PDD steps (research, Q1–Q12 requirements, detailed design) are unchanged in spirit; see research/synthesis.md and design/detailed-design.md for the full decision log. Research initially leaned Javalin sidecar; implementation landed Tyrus on Grizzly after owner review.