The body catalogue is a set; this is not. This client addresses 49,152 static ids and has art for 39,189 of them, plus 4,244 land tiles of 16,384 -- and hues multiply the statics by three thousand. So there is no manifest and no scan: `assets.fetch` grows two more families (`static`, `land`) and answers the keys the website's own data names. `assets.fetch` becomes shared plumbing. BridgeAssets now owns the command, does the reqId/consent/key-ceiling checks once, derives the family from the keys themselves (§5 made the key the address; a request that also named its family would have two places to be wrong and one of them silent) and dispatches to the reader that registered it. A batch must be of one family, because the reply carries one `catalog` id. `assets.sources` gains `families` -- additive, so the protocol stays 8, and EXTRACTOR_VERSION stays 2 because no existing key's bytes change. Two traps, both in §4.5's family -- a confident, plausible, wrong picture: - `Art.GetStatic` memoises into a static Bitmap[0xFFFF] and hands back the SAME instance, while `Hue.ApplyTo` repaints in place. Hue a static once and the plain key comes back hued from then on, and the next hue stacks on the last. Measured on this client before the fix. `Files.CacheData` is now off for the life of the process; `TryHue` re-checks it and refuses rather than risk it, and the same flag decides whether a bitmap is ours to dispose. - `PartialHue` decides whether a hue repaints every pixel or only the grey ones, per item id, out of `tiledata.mul` -- 13,259 of 65,536 ids on this client. Item 597 is a wooden screen with painted flowers: one mode reddens the flowers, the other the whole screen. Both decode. The first cut of this reader bound `TileData` to ServUO's OWN `Server.TileData` (the enclosing namespace beats `using Ultima;`, and it has a PartialHue flag too), which compiled and refused every hued key at runtime. Every such type is spelled `Ultima.` now. Land takes no hue segment: the mode that decides how is an item flag and land has no equivalent, so `land/3/h33` is refused rather than guessed. `h0` is not a key either -- hue 0 means "not hued", and the plain key already names it. Measured through the reader over the whole range: 39,189 statics and 4,244 land tiles served, and the only refusals are the 9,963 + 12,140 empty index slots §4.5 predicted. Nothing that carries art is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
271 lines
11 KiB
C#
271 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
using Server.Commands;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// Lifecycle wiring. Boot order (Server/Main.cs:544-562, all on the Core thread):
|
|
///
|
|
/// Configure() -> World.Load() -> Initialize() -> EventSink.ServerStarted
|
|
///
|
|
/// Config is read in Configure. Handlers are attached in Initialize. The socket opens on
|
|
/// ServerStarted, once the world is actually there to describe.
|
|
///
|
|
/// EventSink.Shutdown does NOT fire on a crash (Server/Main.cs:198,313), so the sidecar
|
|
/// must treat socket EOF as normal and re-handshake rather than waiting for a goodbye.
|
|
/// </summary>
|
|
public static class BridgeBoot
|
|
{
|
|
private static readonly Dictionary<string, Action<Dictionary<string, object>>> _handlers =
|
|
new Dictionary<string, Action<Dictionary<string, object>>>(StringComparer.Ordinal);
|
|
|
|
/// <summary>
|
|
/// Identifies this run of the shard. It is stable across sidecar reconnects and changes
|
|
/// on every shard restart, which is how the sidecar tells "I reconnected" (keep my
|
|
/// cached state) from "the shard restarted" (discard it).
|
|
/// </summary>
|
|
private static string _bootId;
|
|
|
|
public static void Configure()
|
|
{
|
|
BridgeConfig.Configure();
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
if (!BridgeConfig.Enabled)
|
|
{
|
|
Console.WriteLine("[Bridge] disabled by config");
|
|
return;
|
|
}
|
|
|
|
CommandSystem.Register("bridge", AccessLevel.Administrator, Bridge_OnCommand);
|
|
|
|
RegisterHandler("ping", OnPing);
|
|
|
|
BridgeLink.InboundLine += OnInboundLine;
|
|
BridgeLink.Connected_Core += EmitHello;
|
|
|
|
EventSink.ServerStarted += OnServerStarted;
|
|
EventSink.Shutdown += OnShutdown;
|
|
EventSink.Crashed += OnCrashed;
|
|
|
|
Console.WriteLine("[Bridge] {0}", BridgeConfig.Describe());
|
|
}
|
|
|
|
/// <summary>Handlers run on the Core thread. They may touch the world freely.</summary>
|
|
public static void RegisterHandler(string kind, Action<Dictionary<string, object>> handler)
|
|
{
|
|
_handlers[kind] = handler;
|
|
}
|
|
|
|
private static void OnServerStarted()
|
|
{
|
|
_bootId = Guid.NewGuid().ToString("N");
|
|
BridgeLink.Start();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core thread, once per connection. The sidecar restarts independently of the shard,
|
|
/// so this is sent on every connect rather than once at boot — otherwise a sidecar that
|
|
/// came up second would never learn which shard it is talking to.
|
|
/// </summary>
|
|
private static void EmitHello()
|
|
{
|
|
BridgeLink.Emit(BridgeJson.Begin("server.hello")
|
|
.Str("shard", Server.Misc.ServerList.ServerName)
|
|
.Str("bootId", _bootId)
|
|
.Num("connects", BridgeLink.Connects)
|
|
.Num("items", World.Items.Count)
|
|
.Num("mobiles", World.Mobiles.Count)
|
|
.Num("accounts", Accounting.Accounts.Count)
|
|
.End());
|
|
}
|
|
|
|
private static void OnShutdown(ShutdownEventArgs e)
|
|
{
|
|
BridgeLink.Emit(BridgeJson.Begin("server.shutdown").End());
|
|
|
|
// Stop() joins the link thread for up to 2s, which gives the writer a chance to drain
|
|
// the goodbye. Best effort: the sidecar must not depend on receiving it.
|
|
BridgeLink.Stop();
|
|
}
|
|
|
|
private static void OnCrashed(CrashedEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
BridgeLink.Emit(BridgeJson.Begin("server.crashed")
|
|
.Str("error", e.Exception == null ? null : e.Exception.Message)
|
|
.End());
|
|
|
|
BridgeLink.Stop();
|
|
}
|
|
catch
|
|
{
|
|
// The process is already going down. Never make a crash worse.
|
|
}
|
|
}
|
|
|
|
/// <summary>Core thread, one call per inbound line.</summary>
|
|
private static void OnInboundLine(string line)
|
|
{
|
|
var obj = BridgeJson.Parse(line);
|
|
|
|
if (obj == null)
|
|
{
|
|
Console.WriteLine("[Bridge] malformed inbound line, ignoring");
|
|
return;
|
|
}
|
|
|
|
var kind = BridgeJson.GetString(obj, "kind");
|
|
|
|
if (kind == null)
|
|
return;
|
|
|
|
Action<Dictionary<string, object>> handler;
|
|
|
|
if (!_handlers.TryGetValue(kind, out handler))
|
|
{
|
|
Console.WriteLine("[Bridge] no handler for inbound kind '{0}'", kind);
|
|
return;
|
|
}
|
|
|
|
// Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at
|
|
// most once: a repeat is answered with the original reply rather than re-run. The gate
|
|
// is here rather than in each handler so it covers every inbound kind — including the
|
|
// ones a later protocol adds, which is the half that is easy to forget. A command with
|
|
// no key behaves exactly as it did before, which is what keeps the admin screens (which
|
|
// send none) unchanged.
|
|
var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey");
|
|
|
|
if (idempotencyKey == null)
|
|
{
|
|
handler(obj);
|
|
return;
|
|
}
|
|
|
|
if (BridgeIdempotency.Intercept(idempotencyKey, obj))
|
|
return; // already answered: a replay of the original reply, or bridge.busy
|
|
|
|
string error = null;
|
|
|
|
try
|
|
{
|
|
handler(obj);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Swallowed deliberately, and only on the keyed path: the key must be closed out
|
|
// with a definite answer (see BridgeIdempotency's header) rather than left in
|
|
// flight by an exception unwinding past Finish. Unkeyed commands still throw the
|
|
// way they always have.
|
|
error = ex.Message;
|
|
Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex);
|
|
}
|
|
finally
|
|
{
|
|
BridgeIdempotency.Finish(idempotencyKey, error);
|
|
}
|
|
}
|
|
|
|
private static void OnPing(Dictionary<string, object> o)
|
|
{
|
|
var sb = BridgeJson.Begin("pong");
|
|
|
|
var id = BridgeJson.GetString(o, "id");
|
|
|
|
if (id != null)
|
|
sb.Str("id", id);
|
|
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
|
|
[Usage("bridge [status | reload | ping | sweepnow]")]
|
|
[Description("Inspects and controls the sidecar link.")]
|
|
private static void Bridge_OnCommand(CommandEventArgs e)
|
|
{
|
|
var arg = e.Length > 0 ? e.GetString(0).ToLowerInvariant() : "status";
|
|
|
|
switch (arg)
|
|
{
|
|
case "reload":
|
|
BridgeConfig.Load();
|
|
BridgeSweeps.Rearm();
|
|
BridgePages.Rearm();
|
|
BridgeChamps.Rearm();
|
|
BridgeSocial.Rearm();
|
|
BridgeGovernance.Rearm();
|
|
BridgePresence.Rearm();
|
|
BridgeHousing.Rearm();
|
|
BridgePoints.Rearm();
|
|
BridgeMarket.Rearm();
|
|
BridgeParticipation.Rearm();
|
|
BridgeLeases.Rearm();
|
|
BridgeWorld.Rearm();
|
|
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
|
|
// .cfg wants the change on the site now, not after a shard restart.
|
|
BridgeRuleset.Emit();
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
|
e.Mobile.SendMessage("Bridge: sweeps re-armed; ruleset re-emitted; endpoint changes take effect on reconnect.");
|
|
break;
|
|
|
|
case "ping":
|
|
BridgeLink.Emit(BridgeJson.Begin("ping").End());
|
|
e.Mobile.SendMessage("Bridge: ping queued.");
|
|
break;
|
|
|
|
case "sweepnow":
|
|
BridgeSweeps.SweepOnce();
|
|
BridgeChamps.SweepOnce();
|
|
BridgeSocial.SweepOnce();
|
|
BridgeGovernance.SweepOnce();
|
|
BridgePresence.SweepOnce();
|
|
BridgeHousing.SweepOnce();
|
|
BridgePoints.SweepOnce();
|
|
BridgeMarket.SweepOnce();
|
|
BridgeParticipation.SweepOnce();
|
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
|
break;
|
|
|
|
default:
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
|
e.Mobile.SendMessage(
|
|
"Bridge: connected={0} depth={1} sent={2} dropped={3} received={4} connects={5} writeErrors={6}",
|
|
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
|
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeAssets.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeCatalog.Status());
|
|
e.Mobile.SendMessage("Bridge: {0}", BridgeArt.Status());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|