feat(champ): stream champion-spawn state to the sidecar board #3
@@ -194,6 +194,47 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
|||||||
|
|
||||||
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
|
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
|
||||||
|
|
||||||
|
#### Champion spawns
|
||||||
|
|
||||||
|
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
|
||||||
|
|
||||||
|
| `category` | source | what it is |
|
||||||
|
|------------|--------|-----------|
|
||||||
|
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
|
||||||
|
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
|
||||||
|
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
|
||||||
|
|
||||||
|
| kind | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
||||||
|
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
||||||
|
|
||||||
|
`status` is one of:
|
||||||
|
- **`active`** — running (or, for `sea`, the boss is alive).
|
||||||
|
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
|
||||||
|
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
|
||||||
|
|
||||||
|
Category-specific fields on `champ.update`:
|
||||||
|
|
||||||
|
| category | extra fields |
|
||||||
|
|----------|--------------|
|
||||||
|
| `champion` | `level` (0–16), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
|
||||||
|
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
|
||||||
|
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||||||
|
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
|
||||||
|
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
|
||||||
|
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
|
||||||
|
|
||||||
|
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
|
||||||
|
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
|
||||||
|
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
|
||||||
|
```
|
||||||
|
|
||||||
|
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. REST — read queries
|
## 5. REST — read queries
|
||||||
@@ -402,6 +443,29 @@ GET /economy?limit=200
|
|||||||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Champion-spawn board
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /champs
|
||||||
|
```
|
||||||
|
|
||||||
|
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /champs
|
||||||
|
→ { "spawns": [
|
||||||
|
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||||||
|
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
|
||||||
|
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
|
||||||
|
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
|
||||||
|
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
|
||||||
|
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
|
||||||
|
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Status codes
|
## 7. Status codes
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ StatSweepSeconds=30
|
|||||||
DecaySweepSeconds=60
|
DecaySweepSeconds=60
|
||||||
EconomySweepSeconds=300
|
EconomySweepSeconds=300
|
||||||
|
|
||||||
|
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
|
||||||
|
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
|
||||||
|
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
|
||||||
|
ChampSweepSeconds=10
|
||||||
|
|
||||||
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
|
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
|
||||||
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
|
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
|
||||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeConfig.Load();
|
BridgeConfig.Load();
|
||||||
BridgeSweeps.Rearm();
|
BridgeSweeps.Rearm();
|
||||||
BridgePages.Rearm();
|
BridgePages.Rearm();
|
||||||
|
BridgeChamps.Rearm();
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||||
break;
|
break;
|
||||||
@@ -171,8 +172,10 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
case "sweepnow":
|
case "sweepnow":
|
||||||
BridgeSweeps.SweepOnce();
|
BridgeSweeps.SweepOnce();
|
||||||
|
BridgeChamps.SweepOnce();
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -182,6 +185,7 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
||||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
287
overlay/Scripts/Custom/Bridge/BridgeChamps.cs
Normal file
287
overlay/Scripts/Custom/Bridge/BridgeChamps.cs
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Server.Engines.CannedEvil;
|
||||||
|
using Server.Engines.MiniChamps;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The champion-spawn stream. Like the streams in <see cref="BridgeSweeps"/>, this is polled:
|
||||||
|
/// none of the three champion families expose an EventSink, so their whole lifecycle lives
|
||||||
|
/// inside a per-second SliceTimer and is invisible to a subscriber. Instead we enumerate them
|
||||||
|
/// each tick, fold each to a small record, and emit `champ.update` only when that record
|
||||||
|
/// changes. A 5-10s sweep is well within the site's tolerance and the world holds only a
|
||||||
|
/// handful of spawns, so the pass is trivially cheap.
|
||||||
|
///
|
||||||
|
/// Three families, distinguished by the `category` field:
|
||||||
|
/// champion - ChampionSpawn: the classic Felucca-style altar (type/level/kills/boss/cooldown)
|
||||||
|
/// mini - MiniChamp: the TerMur mini-champ controller (type/level, auto-restarts)
|
||||||
|
/// sea - BaseSeaChampion: a High Seas world boss Mobile, alive only while summoned
|
||||||
|
///
|
||||||
|
/// Status folds public fields into three values (no core patch needed):
|
||||||
|
/// active - running / alive
|
||||||
|
/// cooldown - stopped but a restart is pending (ChampionSpawn: RestartTime ahead; MiniChamp:
|
||||||
|
/// inactive, since it always re-arms a restart)
|
||||||
|
/// dormant - stopped with nothing scheduled (ChampionSpawn only; a GM must turn it on)
|
||||||
|
///
|
||||||
|
/// The sidecar keeps the latest record per serial as a live board. A permanent controller's
|
||||||
|
/// row lives as long as the item; a transient sea boss is removed with `champ.remove` when it
|
||||||
|
/// dies or despawns. A (re)connection clears the diff cache (see OnConnected) so the next
|
||||||
|
/// sweep re-emits every spawn in full, rebuilding a sidecar that restarted on its own.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeChamps
|
||||||
|
{
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
// Last-emitted signature per tracked serial. A serial absent from this map has never been
|
||||||
|
// emitted (or the cache was cleared on reconnect), so its next sweep counts as a change.
|
||||||
|
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
|
||||||
|
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
||||||
|
|
||||||
|
private static long _sweeps, _emitted, _removed;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EventSink.ServerStarted += OnServerStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnServerStarted()
|
||||||
|
{
|
||||||
|
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
|
||||||
|
// independently of the shard rebuilds its state within one sweep.
|
||||||
|
BridgeLink.Connected_Core += OnConnected;
|
||||||
|
Rearm();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnConnected()
|
||||||
|
{
|
||||||
|
_last.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||||
|
public static void Rearm()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
_timer = Timer.DelayCall(
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
|
||||||
|
ChampSweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
|
||||||
|
_sweeps, _emitted, _removed, _last.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
|
public static void SweepOnce()
|
||||||
|
{
|
||||||
|
ChampSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ChampSweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
if (!BridgeLink.Connected)
|
||||||
|
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||||
|
|
||||||
|
var seen = new HashSet<Serial>();
|
||||||
|
|
||||||
|
foreach (var s in World.Items.Values.OfType<ChampionSpawn>())
|
||||||
|
{
|
||||||
|
if (s.Deleted)
|
||||||
|
continue;
|
||||||
|
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var s in World.Items.Values.OfType<MiniChamp>())
|
||||||
|
{
|
||||||
|
if (s.Deleted)
|
||||||
|
continue;
|
||||||
|
Track(seen, s.Serial, SigMini(s), WriteMini(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var b in World.Mobiles.Values.OfType<BaseSeaChampion>())
|
||||||
|
{
|
||||||
|
if (b.Deleted || !b.Alive)
|
||||||
|
continue;
|
||||||
|
Track(seen, b.Serial, SigSea(b), WriteSea(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything tracked last sweep but not seen now has gone away (a controller deleted, a
|
||||||
|
// sea boss slain). Tell the sidecar to drop its board row.
|
||||||
|
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||||
|
foreach (var serial in gone)
|
||||||
|
{
|
||||||
|
_last.Remove(serial);
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
|
||||||
|
_removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] champ sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a spawn as seen and emits it only if its signature changed since last sweep.</summary>
|
||||||
|
private static void Track(HashSet<Serial> seen, Serial serial, string sig, string line)
|
||||||
|
{
|
||||||
|
seen.Add(serial);
|
||||||
|
|
||||||
|
string prior;
|
||||||
|
if (_last.TryGetValue(serial, out prior) && prior == sig)
|
||||||
|
return; // unchanged since last emit
|
||||||
|
|
||||||
|
_last[serial] = sig;
|
||||||
|
BridgeLink.Emit(line);
|
||||||
|
_emitted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ChampionSpawn (classic) ----
|
||||||
|
|
||||||
|
private static string StatusOf(ChampionSpawn s)
|
||||||
|
{
|
||||||
|
if (s.Active)
|
||||||
|
return "active";
|
||||||
|
if (s.RestartTime > DateTime.UtcNow)
|
||||||
|
return "cooldown";
|
||||||
|
return "dormant";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The volatile fields that define a meaningful change. Kept in sync with WriteChampion so the
|
||||||
|
// site never misses a level, a kill-count tick, a boss pop, or a status/cooldown transition.
|
||||||
|
private static string SigChampion(ChampionSpawn s)
|
||||||
|
{
|
||||||
|
return String.Concat(
|
||||||
|
"champion|", StatusOf(s), "|",
|
||||||
|
s.Level.ToString(), "|",
|
||||||
|
s.Kills.ToString(), "|",
|
||||||
|
(s.Champion != null && !s.Champion.Deleted) ? "1" : "0", "|",
|
||||||
|
s.RestartTime.Ticks.ToString(), "|",
|
||||||
|
s.ExpireTime.Ticks.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteChampion(ChampionSpawn s)
|
||||||
|
{
|
||||||
|
var status = StatusOf(s);
|
||||||
|
var bossUp = s.Champion != null && !s.Champion.Deleted;
|
||||||
|
|
||||||
|
// Prefer a staff-set display name, then the group, then the spawn type.
|
||||||
|
string name = !String.IsNullOrEmpty(s.SpawnName) ? s.SpawnName
|
||||||
|
: !String.IsNullOrEmpty(s.GroupName) ? s.GroupName
|
||||||
|
: s.Type.ToString();
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("champ.update")
|
||||||
|
.Ser("serial", s.Serial)
|
||||||
|
.Str("category", "champion")
|
||||||
|
.Str("type", s.Type.ToString())
|
||||||
|
.Str("name", name)
|
||||||
|
.Str("status", status)
|
||||||
|
.Bool("active", s.Active)
|
||||||
|
.Num("level", s.Level)
|
||||||
|
.Num("rank", s.Rank)
|
||||||
|
.Num("kills", s.Kills)
|
||||||
|
.Num("maxKills", s.MaxKills)
|
||||||
|
.Bool("bossUp", bossUp)
|
||||||
|
.Bool("autoRestart", s.AutoRestart)
|
||||||
|
.Str("map", s.Map == null ? null : s.Map.Name)
|
||||||
|
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
|
||||||
|
|
||||||
|
if (bossUp)
|
||||||
|
sb.Str("boss", String.IsNullOrEmpty(s.Champion.Name) ? s.Champion.GetType().Name : s.Champion.Name);
|
||||||
|
|
||||||
|
// Cooldown ETA: when the spawn will auto-restart. Only meaningful while on cooldown.
|
||||||
|
if (status == "cooldown")
|
||||||
|
sb.Str("restartAt", s.RestartTime.ToUniversalTime().ToString("o"));
|
||||||
|
|
||||||
|
// Level-expiry ETA: when the current level times out if kills stall. Only while active.
|
||||||
|
if (s.Active)
|
||||||
|
sb.Str("expireAt", s.ExpireTime.ToUniversalTime().ToString("o"));
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MiniChamp (TerMur mini-champs) ----
|
||||||
|
|
||||||
|
// MiniChamp exposes no kills, no boss handle, and no restart-time getter. When inactive it has
|
||||||
|
// always re-armed a restart, so inactive folds to "cooldown" (there is no dormant state and no
|
||||||
|
// ETA to report).
|
||||||
|
private static string SigMini(MiniChamp s)
|
||||||
|
{
|
||||||
|
return String.Concat(
|
||||||
|
"mini|", (s.Active ? "active" : "cooldown"), "|", s.Level.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteMini(MiniChamp s)
|
||||||
|
{
|
||||||
|
var status = s.Active ? "active" : "cooldown";
|
||||||
|
var info = MiniChampInfo.GetInfo(s.Type);
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("champ.update")
|
||||||
|
.Ser("serial", s.Serial)
|
||||||
|
.Str("category", "mini")
|
||||||
|
.Str("type", s.Type.ToString())
|
||||||
|
.Str("name", s.Type.ToString())
|
||||||
|
.Str("status", status)
|
||||||
|
.Bool("active", s.Active)
|
||||||
|
.Num("level", s.Level)
|
||||||
|
.Bool("bossUp", false)
|
||||||
|
.Bool("autoRestart", true)
|
||||||
|
.Str("map", s.Map == null ? null : s.Map.Name)
|
||||||
|
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
|
||||||
|
|
||||||
|
if (info != null)
|
||||||
|
sb.Num("maxLevel", info.MaxLevel);
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BaseSeaChampion (High Seas world boss) ----
|
||||||
|
|
||||||
|
// A sea champion is a Mobile, not a controller: it exists only while summoned and alive, so it
|
||||||
|
// is always "active" on the board and leaves via champ.remove when slain. Position and health
|
||||||
|
// are tracked so the board can show a live "world boss here, N% hp".
|
||||||
|
private static string SigSea(BaseSeaChampion b)
|
||||||
|
{
|
||||||
|
return String.Concat(
|
||||||
|
"sea|", b.Hits.ToString(), "|", b.X.ToString(), "|", b.Y.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteSea(BaseSeaChampion b)
|
||||||
|
{
|
||||||
|
string name = String.IsNullOrEmpty(b.Name) ? b.GetType().Name : b.Name;
|
||||||
|
|
||||||
|
return BridgeJson.Begin("champ.update")
|
||||||
|
.Ser("serial", b.Serial)
|
||||||
|
.Str("category", "sea")
|
||||||
|
.Str("type", b.GetType().Name)
|
||||||
|
.Str("name", name)
|
||||||
|
.Str("status", "active")
|
||||||
|
.Bool("active", true)
|
||||||
|
.Bool("bossUp", true)
|
||||||
|
.Str("boss", name)
|
||||||
|
.Num("hits", b.Hits)
|
||||||
|
.Num("hitsMax", b.HitsMax)
|
||||||
|
.Str("map", b.Map == null ? null : b.Map.Name)
|
||||||
|
.Num("x", b.X).Num("y", b.Y).Num("z", b.Z)
|
||||||
|
.End();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ namespace Server.Custom.Bridge
|
|||||||
public static int DecaySweepSeconds { get; private set; }
|
public static int DecaySweepSeconds { get; private set; }
|
||||||
public static int EconomySweepSeconds { get; private set; }
|
public static int EconomySweepSeconds { get; private set; }
|
||||||
public static int PageSweepSeconds { get; private set; }
|
public static int PageSweepSeconds { get; private set; }
|
||||||
|
public static int ChampSweepSeconds { get; private set; }
|
||||||
|
|
||||||
public static string LinkUrl { get; private set; }
|
public static string LinkUrl { get; private set; }
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@ namespace Server.Custom.Bridge
|
|||||||
if (PageSweepSeconds < 1)
|
if (PageSweepSeconds < 1)
|
||||||
PageSweepSeconds = 1;
|
PageSweepSeconds = 1;
|
||||||
|
|
||||||
|
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
|
||||||
|
if (ChampSweepSeconds < 1)
|
||||||
|
ChampSweepSeconds = 1;
|
||||||
|
|
||||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||||
|
|
||||||
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
||||||
@@ -90,9 +95,9 @@ namespace Server.Custom.Bridge
|
|||||||
public static string Describe()
|
public static string Describe()
|
||||||
{
|
{
|
||||||
return String.Format(
|
return String.Format(
|
||||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s) adminWrite={7}(floor={8})",
|
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})",
|
||||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||||
AdminWriteEnabled, AdminAccessFloor);
|
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,35 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
||||||
tracing::warn!(error = %e, "failed to persist event");
|
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());
|
let _ = feed_tx.send(ev.value.to_string());
|
||||||
|
|||||||
@@ -133,6 +133,50 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
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> {
|
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,
|
json TEXT NOT NULL,
|
||||||
updated_t INTEGER 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
|
||||||
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
// History, read from SQLite rather than the shard.
|
// History, read from SQLite rather than the shard.
|
||||||
.route("/history", get(history))
|
.route("/history", get(history))
|
||||||
.route("/economy", get(economy))
|
.route("/economy", get(economy))
|
||||||
|
.route("/champs", get(champs))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||||
|
|
||||||
let app = Router::new()
|
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 ----
|
// ---- websocket ----
|
||||||
|
|
||||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user