feat(champ): stream champion-spawn state to the sidecar board

Champion spawns have no ServUO EventSink, so add a fourth polled stream
(BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick,
fold to a small record, and emit champ.update only on change. No core
patch — every field used is public.

Covers all three families via a `category` field:
  - champion: ChampionSpawn (type/level/kills/boss/cooldown ETA)
  - mini:     MiniChamp (type/level; auto-restarts, no kill counter)
  - sea:      BaseSeaChampion (a High Seas world-boss mobile, alive only
              while summoned; removed via champ.remove when slain)

Status folds to active/cooldown/dormant. A (re)connection clears the diff
cache so the next sweep re-emits the full board, rebuilding a sidecar that
restarted on its own. Transient entries leave via champ.remove.

Sidecar: a `champs` current-state table (one row per serial) fed by
champ.update (upsert) and champ.remove (delete), exposed at GET /champs as
the live board. New ChampSweepSeconds config (default 10s), wired into
[bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-14 05:46:21 -05:00
parent 9df337e186
commit b258ee3e60
8 changed files with 462 additions and 2 deletions

View File

@@ -105,6 +105,35 @@ async fn main() -> anyhow::Result<()> {
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
tracing::warn!(error = %e, "failed to persist event");
}
// The champ board is a live projection: champ.update folds in the latest state (one
// row per spawn), champ.remove drops a spawn that despawned or was slain.
match ev.kind.as_str() {
"champ.update" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store
.upsert_champ(
serial,
ev.value.get("status").and_then(|s| s.as_str()),
ev.value.get("name").and_then(|n| n.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert champ board");
}
}
}
"champ.remove" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store.delete_champ(serial).await {
tracing::warn!(error = %e, "failed to remove champ board row");
}
}
}
_ => {}
}
}
let _ = feed_tx.send(ev.value.to_string());

View File

@@ -133,6 +133,50 @@ impl Store {
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
/// Upserts one champion-spawn's latest state, keyed by serial. Fed from the `champ.update`
/// stream; this table is the live board the website reads, so there is exactly one row per
/// spawn and it always holds the most recent snapshot.
pub async fn upsert_champ(
&self,
serial: &str,
status: Option<&str>,
name: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO champs (serial, status, name, json, updated_t) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(serial) DO UPDATE SET status = excluded.status, name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(serial)
.bind(status)
.bind(name)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Drops one spawn from the board. Fed from the `champ.remove` stream: a controller that was
/// deleted, or a transient sea boss that was slain, leaves the board this way.
pub async fn delete_champ(&self, serial: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM champs WHERE serial = ?")
.bind(serial)
.execute(&self.pool)
.await?;
Ok(())
}
/// The full champion-spawn board: every spawn's latest snapshot. Ordered by name so the site
/// gets a stable list.
pub async fn champs_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM champs ORDER BY name, serial")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
}
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
@@ -163,4 +207,12 @@ CREATE TABLE IF NOT EXISTS profiles (
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS champs (
serial TEXT PRIMARY KEY,
status TEXT,
name TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
"#;

View File

@@ -70,6 +70,7 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// History, read from SQLite rather than the shard.
.route("/history", get(history))
.route("/economy", get(economy))
.route("/champs", get(champs))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -552,6 +553,19 @@ async fn economy(State(st): State<AppState>, Query(q): Query<HistoryQuery>) -> i
}
}
/// The champion-spawn board: every spawn's latest state (status/level/kills/boss/location and, when
/// relevant, the cooldown ETA). Served from the local board table, so it answers without touching
/// the shard and survives a shard outage with the last-known snapshot.
async fn champs(State(st): State<AppState>) -> impl IntoResponse {
match st.store.champs_all().await {
Ok(spawns) => (StatusCode::OK, Json(json!({"spawns": spawns}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
// ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {