Protocol 3.0 §8. Every player vendor's shop name, owner, location and priced inventory, so the website can offer the search the in-game Vendor Search gump offers — from outside the game, and honouring the same per-player opt-out. It cannot be an RPC. rpc.rs correlates a reply on the FIRST frame carrying a matching reqId, so a chunked reply sharing one reqId would deliver chunk 1 to the HTTP caller and leak chunks 2..N onto the broadcast feed; a whole-world snapshot would not fit in one frame inside the 10 s timeout either. So it is a diff sweep on the broadcast stream, one authoritative frame per vendor. The one genuinely new pattern here is an amortized round-robin: every other sweep walks its whole collection per tick, which is fine for tens of houses and is not fine for a world of shops whose inventories recurse into containers. MarketSweepBatch (25) vendors are inventoried per tick from a persistent cursor, so per-tick cost is bounded by the batch rather than by world size. VendorSearch.GetItemName is never called: it builds an ObjectPropertyList, serialises it and byte-parses the packet per item. The frame carries itemId, hue, amount, price, the plain item.Name field and item.LabelNumber; the website resolves names against its own cliloc table. (It would not work anyway — every current client ships its cliloc files compressed and ServUO's Ultima.StringList cannot read them, so the in-game gump has the same gap.) Measured on the live shard (27 vendors x 40 listings, 209k items / 43k mobiles): 15.4 ms for the first cold tick of 25 vendors, 3.4 ms for the next, 0.3 ms in steady state. `[bridge status` now reports lastMs/maxMs and a tick over 50 ms warns, naming the knob — the batch cap is a claim about that number and an operator tuning it was otherwise tuning blind. - location is ONE nested object, not flat map/x/y/region, so the website's single market.location visibility rule can hide a vendor's whereabouts on both the live frame and the stored read model. Flat keys would need five rules. - Owner is flat ownerSerial/ownerName, never BridgeJson.Actor, which would add acct and webId. Same argument points.board makes. - pv.VendorSearch is honoured, so a shop hidden in game is hidden on the site; the seen-set removal then emits vendor.listing.remove. - Container-priced items carry child:true, exactly as DoSearch reports them. - Over MarketMaxListings (250) the frame says truncated and carries the real total, so the site shows "250 of 3,104" rather than a partial shop as complete. Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
16 KiB
C#
300 lines
16 KiB
C#
using System;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
|
|
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
|
|
/// the operator pairs with this (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §2).
|
|
/// </summary>
|
|
public enum SignupMode
|
|
{
|
|
Website, // website is the account authority; in-game auto-create should be off
|
|
Game, // game server is the authority; account.create is refused
|
|
Hybrid // either side may create
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
|
|
/// reads as "Bridge.Port" here.
|
|
///
|
|
/// Loaded in Configure(), which ScriptCompiler invokes before World.Load.
|
|
/// </summary>
|
|
public static class BridgeConfig
|
|
{
|
|
public static string Host { get; private set; }
|
|
public static int Port { get; private set; }
|
|
public static int QueueCap { get; private set; }
|
|
|
|
public static int StatSweepSeconds { get; private set; }
|
|
public static int DecaySweepSeconds { get; private set; }
|
|
public static int EconomySweepSeconds { get; private set; }
|
|
public static int PageSweepSeconds { get; private set; }
|
|
public static int ChampSweepSeconds { get; private set; }
|
|
public static int GuildSweepSeconds { get; private set; }
|
|
public static int CitySweepSeconds { get; private set; }
|
|
public static int PresenceSweepSeconds { get; private set; }
|
|
public static int HousingSweepSeconds { get; private set; }
|
|
public static int PointsSweepSeconds { get; private set; }
|
|
public static int MarketSweepSeconds { get; private set; }
|
|
|
|
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
|
|
public static bool MarketEnabled { get; private set; }
|
|
public static int MarketSweepBatch { get; private set; }
|
|
public static int MarketMaxListings { get; private set; }
|
|
|
|
// ---- points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7) ----
|
|
public static bool PointsLeaderboardEnabled { get; private set; }
|
|
public static int PointsTopN { get; private set; }
|
|
public static string PointsSystems { get; private set; }
|
|
public static bool PointsProfileEnabled { get; private set; }
|
|
public static bool PointsProfileRank { get; private set; }
|
|
|
|
// ---- shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5) ----
|
|
public static bool RulesetEnabled { get; private set; }
|
|
public static string PublicConnectAddress { get; private set; }
|
|
public static bool RulesetIncludeSchedule { get; private set; }
|
|
|
|
public static string LinkUrl { get; private set; }
|
|
|
|
public static int TownCrierMaxLines { get; private set; }
|
|
public static int TownCrierMaxLineLength { get; private set; }
|
|
public static int TownCrierMaxActive { get; private set; }
|
|
public static int TownCrierMaxDurationSec { get; private set; }
|
|
|
|
// Town Cryer news gump (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16).
|
|
public static int NewsMaxTitleLength { get; private set; }
|
|
public static int NewsMaxBodyLength { get; private set; }
|
|
public static int NewsMaxExternal { get; private set; }
|
|
public static int NewsAnnounceDurationSec { get; private set; }
|
|
|
|
public static bool AdminWriteEnabled { get; private set; }
|
|
public static AccessLevel AdminAccessFloor { get; private set; }
|
|
public static int AdminBroadcastMaxLength { get; private set; }
|
|
public static int AdminReasonMaxLength { get; private set; }
|
|
public static int AdminBanMaxDurationSec { get; private set; }
|
|
|
|
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
|
public static SignupMode Signup { get; private set; }
|
|
public static bool AccountCreateEnabled { get; private set; }
|
|
public static bool RequireIpForCreate { get; private set; }
|
|
public static int AccountNameMaxLength { get; private set; }
|
|
public static int AccountPasswordMaxLength { get; private set; }
|
|
|
|
public static bool Enabled { get; private set; }
|
|
|
|
public static void Configure()
|
|
{
|
|
Load();
|
|
}
|
|
|
|
/// <summary>Re-readable at runtime via `[bridge reload`.</summary>
|
|
public static void Load()
|
|
{
|
|
Enabled = Config.Get("Bridge.Enabled", true);
|
|
|
|
Host = Config.Get("Bridge.Host", "127.0.0.1");
|
|
Port = Config.Get("Bridge.Port", 7788);
|
|
QueueCap = Config.Get("Bridge.QueueCap", 10000);
|
|
|
|
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
|
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
|
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
|
PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
|
|
if (PageSweepSeconds < 1)
|
|
PageSweepSeconds = 1;
|
|
|
|
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
|
|
if (ChampSweepSeconds < 1)
|
|
ChampSweepSeconds = 1;
|
|
|
|
// Social/political sweeps (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Both change slowly, so the
|
|
// defaults are unhurried; the pass is a handful of field reads over a small set.
|
|
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
|
|
if (GuildSweepSeconds < 1)
|
|
GuildSweepSeconds = 1;
|
|
|
|
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
|
if (CitySweepSeconds < 1)
|
|
CitySweepSeconds = 1;
|
|
|
|
PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
|
|
if (PresenceSweepSeconds < 1)
|
|
PresenceSweepSeconds = 1;
|
|
|
|
HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
|
|
if (HousingSweepSeconds < 1)
|
|
HousingSweepSeconds = 1;
|
|
|
|
// Points/loyalty boards. The sweep touches every point entry on the shard, and ten of
|
|
// the ~25 systems keep a row per character ever created, so the default interval is
|
|
// deliberately slow — these are month-scale standings, not live state.
|
|
PointsSweepSeconds = Config.Get("Bridge.PointsSweepSeconds", 300);
|
|
if (PointsSweepSeconds < 1)
|
|
PointsSweepSeconds = 1;
|
|
|
|
PointsLeaderboardEnabled = Config.Get("Bridge.PointsLeaderboardEnabled", true);
|
|
|
|
// Board size. Bounded below at 1 because the selection indexes the Nth slot directly,
|
|
// and above at 100 because the frame is emitted per system — a large N multiplied by
|
|
// ~25 systems is how a "board" turns into a bandwidth problem.
|
|
PointsTopN = Config.Get("Bridge.PointsTopN", 10);
|
|
if (PointsTopN < 1)
|
|
PointsTopN = 1;
|
|
if (PointsTopN > 100)
|
|
PointsTopN = 100;
|
|
|
|
// Blank (the default) means "publish whatever the shard itself shows on the loyalty
|
|
// gump", so a shard that adds a subsystem gets its board without an edit here.
|
|
PointsSystems = Config.Get("Bridge.PointsSystems", "");
|
|
|
|
PointsProfileEnabled = Config.Get("Bridge.PointsProfileEnabled", true);
|
|
|
|
// Off by default, and the default is the point: a rank cannot early-exit the way a
|
|
// points lookup can — it must count every row that beats the player, in every system,
|
|
// on every profile build. See BridgeProfile.WritePoints.
|
|
PointsProfileRank = Config.Get("Bridge.PointsProfileRank", false);
|
|
|
|
// Player-vendor market index. Unlike every other sweep, this one does NOT walk its whole
|
|
// collection per tick: MarketSweepBatch caps how many vendors are inventoried, and a
|
|
// persistent cursor round-robins through the rest, so the per-tick cost is bounded by
|
|
// the batch rather than by how many vendors the world holds.
|
|
MarketEnabled = Config.Get("Bridge.MarketEnabled", true);
|
|
|
|
MarketSweepSeconds = Config.Get("Bridge.MarketSweepSeconds", 60);
|
|
if (MarketSweepSeconds < 1)
|
|
MarketSweepSeconds = 1;
|
|
|
|
// Bounded below at 1 (a batch of 0 would advance the cursor nowhere and publish nothing,
|
|
// silently) and above at 500, past which the batch stops bounding anything on any
|
|
// realistic shard and the tick is a whole-world pass by another name.
|
|
MarketSweepBatch = Config.Get("Bridge.MarketSweepBatch", 25);
|
|
if (MarketSweepBatch < 1)
|
|
MarketSweepBatch = 1;
|
|
if (MarketSweepBatch > 500)
|
|
MarketSweepBatch = 500;
|
|
|
|
// Per-vendor listing cap. BridgeJson.Parse caps INBOUND frames at 1 MB; outbound is
|
|
// uncapped and the sidecar's read_line will allocate whatever arrives, so the cap here
|
|
// is what keeps one commodity reseller with 8,000 stacked resources from emitting a
|
|
// multi-megabyte frame. Over the cap the frame carries "truncated": true and the site
|
|
// says so.
|
|
MarketMaxListings = Config.Get("Bridge.MarketMaxListings", 250);
|
|
if (MarketMaxListings < 1)
|
|
MarketMaxListings = 1;
|
|
if (MarketMaxListings > 5000)
|
|
MarketMaxListings = 5000;
|
|
|
|
// The ruleset frame is not a sweep — it is emitted once per sidecar connect (and on
|
|
// `[bridge reload`), so it has no interval. PublicConnectAddress is the ONE connection
|
|
// detail the bridge will publish, and only because an operator typed it here for that
|
|
// purpose; Server.cfg's Address/Port are never read (see BridgeRuleset's allowlist note).
|
|
RulesetEnabled = Config.Get("Bridge.RulesetEnabled", true);
|
|
PublicConnectAddress = Config.Get("Bridge.PublicConnectAddress", "");
|
|
RulesetIncludeSchedule = Config.Get("Bridge.RulesetIncludeSchedule", true);
|
|
|
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
|
|
|
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
|
TownCrierMaxLineLength = Config.Get("Bridge.TownCrierMaxLineLength", 200);
|
|
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
|
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
|
|
|
NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100);
|
|
NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000);
|
|
NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20);
|
|
NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300);
|
|
if (NewsAnnounceDurationSec < 1)
|
|
NewsAnnounceDurationSec = 1;
|
|
|
|
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
|
|
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
|
|
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
|
|
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
|
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
|
|
|
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
|
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
|
// typo can never accidentally open provisioning.
|
|
Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
|
|
// Default follows the mode: creation is on unless the shard is game-authority.
|
|
AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
|
|
RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
|
|
AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
|
|
AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
|
|
if (AccountNameMaxLength < 1)
|
|
AccountNameMaxLength = 1;
|
|
if (AccountPasswordMaxLength < 1)
|
|
AccountPasswordMaxLength = 1;
|
|
|
|
if (QueueCap < 16)
|
|
QueueCap = 16;
|
|
|
|
WarnOnSignupMismatch();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The bridge governs only the account.create verb; ServUO's in-game first-login
|
|
/// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
|
|
/// disagree is quietly broken (website-only that still auto-creates in game, or a mode
|
|
/// that expects in-game creation with it switched off), so surface the contradiction
|
|
/// loudly rather than silently doing the permissive thing.
|
|
/// </summary>
|
|
private static void WarnOnSignupMismatch()
|
|
{
|
|
var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
|
|
|
|
if (Signup == SignupMode.Website && autoCreate)
|
|
Console.WriteLine(
|
|
"[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
|
|
+ "an in-game login of any new name still mints an account. Set it false for website-only.");
|
|
else if (Signup == SignupMode.Game && !autoCreate)
|
|
Console.WriteLine(
|
|
"[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
|
|
+ "in-game creation is off and account.create is refused, so no account can be created.");
|
|
else if (Signup == SignupMode.Hybrid && !autoCreate)
|
|
Console.WriteLine(
|
|
"[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
|
|
+ "in-game first-login creation is off. Only website account.create will work.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
|
|
/// on anything unrecognized so a typo can never open provisioning wider than intended.
|
|
/// </summary>
|
|
private static SignupMode ParseSignupMode(string value, SignupMode fallback)
|
|
{
|
|
SignupMode parsed;
|
|
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
|
Enum.IsDefined(typeof(SignupMode), parsed))
|
|
return parsed;
|
|
|
|
Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
|
|
return fallback;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses an AccessLevel name from config, case-insensitively, falling back to the given
|
|
/// default on anything unrecognized so a typo can never open the floor wider than intended.
|
|
/// </summary>
|
|
private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
|
|
{
|
|
AccessLevel parsed;
|
|
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
|
Enum.IsDefined(typeof(AccessLevel), parsed))
|
|
return parsed;
|
|
|
|
Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
|
|
return fallback;
|
|
}
|
|
|
|
public static string Describe()
|
|
{
|
|
return String.Format(
|
|
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
|
|
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
|
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
|
|
}
|
|
}
|
|
}
|