Merge pull request 'Protocol 2.0 + 2.1 — account provisioning, world-state streams, Town Cryer news' (#7) from feat/protocol2-account-provisioning into main
Reviewed-on: UOM/link#7 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -29,6 +29,25 @@ ChampSweepSeconds=10
|
||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||
PageSweepSeconds=5
|
||||
|
||||
# Guild roster poll (docs/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
|
||||
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
|
||||
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
||||
GuildSweepSeconds=60
|
||||
|
||||
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
|
||||
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
|
||||
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
|
||||
CitySweepSeconds=300
|
||||
|
||||
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
|
||||
# interval and emitted as presence.online only when it changes. Region transitions come
|
||||
# through separately in real time as region.enter (EventSink.OnEnterRegion).
|
||||
PresenceSweepSeconds=30
|
||||
|
||||
# Housing registry poll. Every house is diffed on this interval to emit house.update /
|
||||
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
|
||||
HousingSweepSeconds=300
|
||||
|
||||
# Shown to a player when they run [link. The website page where they enter the code.
|
||||
LinkUrl=https://yoursite/link
|
||||
|
||||
@@ -39,6 +58,15 @@ TownCrierMaxLineLength=200
|
||||
TownCrierMaxActive=20
|
||||
TownCrierMaxDurationSec=86400
|
||||
|
||||
# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
|
||||
# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
|
||||
# above. The article title is also proclaimed by the criers (announce defaults on). Caps
|
||||
# are defense in depth on top of the loopback trust boundary.
|
||||
NewsMaxTitleLength=100
|
||||
NewsMaxBodyLength=2000
|
||||
NewsMaxExternal=20
|
||||
NewsAnnounceDurationSec=300
|
||||
|
||||
# Admin write plane (staff moderation from the website). OFF by default: the whole
|
||||
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
|
||||
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
|
||||
@@ -57,6 +85,31 @@ AdminReasonMaxLength=400
|
||||
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
|
||||
AdminBanMaxDurationSec=31536000
|
||||
|
||||
# Account provisioning (docs/PROTOCOL_2.md Part A). Which side may mint game accounts:
|
||||
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
|
||||
# (else an in-game login of any new name still mints an account).
|
||||
# game — the game server is the authority; website account.create is refused.
|
||||
# hybrid — either side may create (the default).
|
||||
# The bridge governs only the account.create verb; the in-game first-login auto-create is
|
||||
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
|
||||
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
|
||||
# (the safest — no website creation).
|
||||
SignupMode=hybrid
|
||||
|
||||
# Master switch for the account.create verb. Absent, it follows the mode (on unless
|
||||
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
|
||||
AccountCreateEnabled=true
|
||||
|
||||
# Fail closed if account.create omits a usable browser IP. The per-IP cap
|
||||
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
|
||||
# than waved through. Turn off only for a deployment that deliberately does not cap website
|
||||
# signups by IP (MaxAccountsPerIP still applies in-game either way).
|
||||
RequireIpForCreate=true
|
||||
|
||||
# Length caps on a website-supplied username / password, checked before the account is made.
|
||||
AccountNameMaxLength=16
|
||||
AccountPasswordMaxLength=30
|
||||
|
||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
|
||||
return;
|
||||
|
||||
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
|
||||
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
|
||||
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
|
||||
|
||||
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
|
||||
@@ -127,6 +128,53 @@ namespace Server.Custom.Bridge
|
||||
url, (int)CodeTtl.TotalMinutes);
|
||||
}
|
||||
|
||||
// ---- [unlink ----
|
||||
|
||||
[Usage("unlink")]
|
||||
[Description("Unlinks this game account from your website account.")]
|
||||
private static void OnUnlinkCommand(CommandEventArgs e)
|
||||
{
|
||||
Unlink(e.Mobile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so
|
||||
/// the website can reconcile a player-initiated unlink. Player-scoped (own account only),
|
||||
/// so it needs no access floor. After unlinking, [link works again.
|
||||
/// </summary>
|
||||
public static void Unlink(Mobile m)
|
||||
{
|
||||
if (m == null)
|
||||
return;
|
||||
|
||||
var acct = m.Account as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
m.SendMessage("Bridge: no account on this character.");
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = acct.GetTag(Tag);
|
||||
if (existing == null)
|
||||
{
|
||||
m.SendMessage("Your account is not linked to a website account.");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.RemoveTag(Tag);
|
||||
DropCodesFor(acct.Username); // drop any pending codes so nothing dangles
|
||||
|
||||
BridgeLink.Emit(BridgeJson.Begin("account.unlinked")
|
||||
.Str("origin", "in-game")
|
||||
.Str("account", acct.Username)
|
||||
.Str("websiteUserId", existing)
|
||||
.Str("char", m.Name)
|
||||
.End());
|
||||
|
||||
m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing);
|
||||
}
|
||||
|
||||
// ---- inbound link.confirm ----
|
||||
|
||||
private static void OnLinkConfirm(Dictionary<string, object> o)
|
||||
|
||||
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Misc;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The account provisioning plane (docs/PROTOCOL_2.md Part A): website-driven account
|
||||
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
|
||||
/// is unchanged.
|
||||
///
|
||||
/// account.create — mint a game account and link it to a website user in one step.
|
||||
/// account.unlink — sever the WebsiteUserId tie from the website side.
|
||||
///
|
||||
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
|
||||
/// Timer.DelayCall first), so they touch accounts freely.
|
||||
///
|
||||
/// Trust model matches the admin plane (docs/ADMIN_CONTROLS.md §5): authorization lives on
|
||||
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
|
||||
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
|
||||
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
|
||||
/// AccountCreateEnabled.
|
||||
/// </summary>
|
||||
public static class BridgeAccounts
|
||||
{
|
||||
private const string Tag = "WebsiteUserId";
|
||||
|
||||
// Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
|
||||
// in-game one (AccountHandler.cs). Kept local because that array is private.
|
||||
private static readonly char[] ForbiddenChars =
|
||||
{
|
||||
'<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
|
||||
};
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("account.create", OnCreate);
|
||||
BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
|
||||
}
|
||||
|
||||
// ---- account.create ----
|
||||
|
||||
/// <summary>
|
||||
/// Creates a game account and links it to the given website user. Refused unless the
|
||||
/// signup mode allows website creation. Enforces the same username/password character
|
||||
/// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
|
||||
/// process in any reply, audit, or log.
|
||||
/// </summary>
|
||||
private static void OnCreate(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var actor = BridgeJson.GetString(o, "actor");
|
||||
const string action = "create";
|
||||
|
||||
if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
|
||||
{
|
||||
Err(reqId, action, "signups disabled for this mode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||
{
|
||||
Err(reqId, action, "missing actor");
|
||||
return;
|
||||
}
|
||||
|
||||
var account = BridgeJson.GetString(o, "account");
|
||||
var password = BridgeJson.GetString(o, "password");
|
||||
var webId = BridgeJson.GetString(o, "websiteUserId");
|
||||
var ipStr = BridgeJson.GetString(o, "ip");
|
||||
|
||||
if (String.IsNullOrEmpty(account))
|
||||
{
|
||||
Err(reqId, action, "missing account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(password))
|
||||
{
|
||||
Err(reqId, action, "missing password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(webId))
|
||||
{
|
||||
Err(reqId, action, "missing websiteUserId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.Length > BridgeConfig.AccountNameMaxLength ||
|
||||
password.Length > BridgeConfig.AccountPasswordMaxLength)
|
||||
{
|
||||
Err(reqId, action, "username or password too long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsSafeUsername(account) || !IsSafePassword(password))
|
||||
{
|
||||
Err(reqId, action, "invalid username/password");
|
||||
return;
|
||||
}
|
||||
|
||||
// Collision: the only correct resolution of a website/in-game race for a name.
|
||||
if (Accounts.GetAccount(account) != null)
|
||||
{
|
||||
Err(reqId, action, "account already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
|
||||
// exempt in IPLimiter, so accepting it would silently bypass the cap.
|
||||
IPAddress ip;
|
||||
bool haveIp = TryParseIp(ipStr, out ip);
|
||||
|
||||
if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
|
||||
{
|
||||
Err(reqId, action, "client ip required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (haveIp && !AccountHandler.CanCreate(ip))
|
||||
{
|
||||
Err(reqId, action, "ip account limit reached");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create + link. new Account self-registers (Accounts.Add) and hashes the password per
|
||||
// the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
|
||||
// in-game first-login does; the tag persists on the next world save.
|
||||
var acct = new Account(account, password);
|
||||
|
||||
if (haveIp)
|
||||
acct.LogAccess(ip);
|
||||
|
||||
acct.SetTag(Tag, webId);
|
||||
|
||||
Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
|
||||
actor, account, webId, haveIp ? ip.ToString() : "-");
|
||||
|
||||
BridgeLink.Emit(AuditBegin(action, actor, account)
|
||||
.Str("websiteUserId", webId)
|
||||
.End());
|
||||
|
||||
var sb = BridgeJson.Begin("account.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- account.unlink ----
|
||||
|
||||
/// <summary>
|
||||
/// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
|
||||
/// Owner floor keeps a protected staff account unreachable from the web.
|
||||
/// </summary>
|
||||
private static void OnUnlink(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var actor = BridgeJson.GetString(o, "actor");
|
||||
const string action = "unlink";
|
||||
|
||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||
{
|
||||
Err(reqId, action, "missing actor");
|
||||
return;
|
||||
}
|
||||
|
||||
var acct = BridgeAdmin.ResolveTargetAccount(o);
|
||||
if (acct == null)
|
||||
{
|
||||
Err(reqId, action, "unknown or accountless target");
|
||||
return;
|
||||
}
|
||||
|
||||
if (BridgeAdmin.Protected(acct))
|
||||
{
|
||||
Err(reqId, action, "target is protected staff; refused");
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = acct.GetTag(Tag);
|
||||
if (existing == null)
|
||||
{
|
||||
Err(reqId, action, "not linked");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.RemoveTag(Tag);
|
||||
|
||||
Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
|
||||
actor, acct.Username, existing);
|
||||
|
||||
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
||||
.Str("websiteUserId", existing)
|
||||
.End());
|
||||
|
||||
var sb = BridgeJson.Begin("account.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("account", acct.Username);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static void Err(string reqId, string action, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("account.error");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
if (action != null) sb.Str("action", action);
|
||||
sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
|
||||
/// admin.audit. Never carries the password.
|
||||
/// </summary>
|
||||
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
||||
{
|
||||
return BridgeJson.Begin("account.audit")
|
||||
.Str("origin", "web")
|
||||
.Str("action", action)
|
||||
.Str("actor", "web:" + actor)
|
||||
.Str("target", target);
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
|
||||
private static bool IsSafeUsername(string un)
|
||||
{
|
||||
if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < un.Length; i++)
|
||||
{
|
||||
char c = un[i];
|
||||
if (c < 0x20 || c >= 0x7F || IsForbidden(c))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
|
||||
private static bool IsSafePassword(string pw)
|
||||
{
|
||||
for (int i = 0; i < pw.Length; i++)
|
||||
{
|
||||
char c = pw[i];
|
||||
if (c < 0x20 || c >= 0x7F)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsForbidden(char c)
|
||||
{
|
||||
for (int i = 0; i < ForbiddenChars.Length; i++)
|
||||
if (c == ForbiddenChars[i])
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseIp(string s, out IPAddress ip)
|
||||
{
|
||||
ip = null;
|
||||
|
||||
if (String.IsNullOrEmpty(s))
|
||||
return false;
|
||||
|
||||
return IPAddress.TryParse(s.Trim(), out ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,9 +283,10 @@ namespace Server.Custom.Bridge
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the command's target account, by "serial" (a player mobile's account) or by
|
||||
/// "account" (username). Returns null if neither resolves to a real account.
|
||||
/// "account" (username). Returns null if neither resolves to a real account. Public so the
|
||||
/// account plane (unlink) resolves targets the same way the moderation plane does.
|
||||
/// </summary>
|
||||
private static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
public static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
{
|
||||
var serialStr = BridgeJson.GetString(o, "serial");
|
||||
if (serialStr != null)
|
||||
@@ -301,9 +302,10 @@ namespace Server.Custom.Bridge
|
||||
/// <summary>
|
||||
/// The one shard-side safety floor. Protects any account whose effective access level —
|
||||
/// the account's own or the highest of its characters' — is at or above the configured
|
||||
/// floor. Even under CoOwner authority the Owner is never reachable from the web.
|
||||
/// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
|
||||
/// so the account plane (unlink) enforces the identical floor.
|
||||
/// </summary>
|
||||
private static bool Protected(Account acct)
|
||||
public static bool Protected(Account acct)
|
||||
{
|
||||
var lvl = acct.AccessLevel;
|
||||
|
||||
|
||||
@@ -161,6 +161,10 @@ namespace Server.Custom.Bridge
|
||||
BridgeSweeps.Rearm();
|
||||
BridgePages.Rearm();
|
||||
BridgeChamps.Rearm();
|
||||
BridgeSocial.Rearm();
|
||||
BridgeGovernance.Rearm();
|
||||
BridgePresence.Rearm();
|
||||
BridgeHousing.Rearm();
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||
break;
|
||||
@@ -173,9 +177,17 @@ namespace Server.Custom.Bridge
|
||||
case "sweepnow":
|
||||
BridgeSweeps.SweepOnce();
|
||||
BridgeChamps.SweepOnce();
|
||||
BridgeSocial.SweepOnce();
|
||||
BridgeGovernance.SweepOnce();
|
||||
BridgePresence.SweepOnce();
|
||||
BridgeHousing.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());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -186,6 +198,10 @@ namespace Server.Custom.Bridge
|
||||
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}", BridgePages.Status());
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ 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 (docs/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.
|
||||
@@ -19,6 +31,10 @@ namespace Server.Custom.Bridge
|
||||
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 string LinkUrl { get; private set; }
|
||||
|
||||
@@ -27,12 +43,25 @@ namespace Server.Custom.Bridge
|
||||
public static int TownCrierMaxActive { get; private set; }
|
||||
public static int TownCrierMaxDurationSec { get; private set; }
|
||||
|
||||
// Town Cryer news gump (docs/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 (docs/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()
|
||||
@@ -60,6 +89,24 @@ namespace Server.Custom.Bridge
|
||||
if (ChampSweepSeconds < 1)
|
||||
ChampSweepSeconds = 1;
|
||||
|
||||
// Social/political sweeps (docs/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;
|
||||
|
||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||
|
||||
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
||||
@@ -67,14 +114,77 @@ namespace Server.Custom.Bridge
|
||||
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>
|
||||
@@ -95,9 +205,9 @@ namespace Server.Custom.Bridge
|
||||
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})",
|
||||
"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);
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Engines.CityLoyalty;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The town-governor stream (docs/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a
|
||||
/// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of
|
||||
/// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
|
||||
/// an EventSink, so — like <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — the set
|
||||
/// is polled and each city emits `city.update` only when its signature changes. Governors turn
|
||||
/// over on the order of weeks, so a slow sweep (default 5 min) is ample.
|
||||
///
|
||||
/// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with
|
||||
/// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a
|
||||
/// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would
|
||||
/// otherwise fire spuriously for every city.
|
||||
///
|
||||
/// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here.
|
||||
/// </summary>
|
||||
public static class BridgeGovernance
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// City enum value -> last-emitted signature.
|
||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
||||
|
||||
private static long _sweeps, _emitted;
|
||||
private static bool _warnedDisabled;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
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.CitySweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
|
||||
CitySweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})",
|
||||
CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
CitySweep();
|
||||
}
|
||||
|
||||
private static void CitySweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null)
|
||||
{
|
||||
if (!_warnedDisabled)
|
||||
{
|
||||
Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle.");
|
||||
_warnedDisabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
foreach (var city in CityLoyaltySystem.Cities)
|
||||
{
|
||||
if (city == null)
|
||||
continue;
|
||||
|
||||
var sig = Signature(city);
|
||||
|
||||
int key = (int)city.City;
|
||||
|
||||
string prior;
|
||||
if (_last.TryGetValue(key, out prior) && prior == sig)
|
||||
continue; // unchanged since last emit
|
||||
|
||||
_last[key] = sig;
|
||||
BridgeLink.Emit(WriteCity(city));
|
||||
_emitted++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// The volatile fields: governor, governor-elect, and the election phase / candidate count.
|
||||
private static string Signature(CityLoyaltySystem city)
|
||||
{
|
||||
var gov = city.Governor == null ? 0 : city.Governor.Serial.Value;
|
||||
var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value;
|
||||
|
||||
var e = city.Election;
|
||||
var phase = ElectionPhase(e);
|
||||
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
||||
|
||||
return String.Concat(
|
||||
gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString());
|
||||
}
|
||||
|
||||
private static string WriteCity(CityLoyaltySystem city)
|
||||
{
|
||||
var e = city.Election;
|
||||
var phase = ElectionPhase(e);
|
||||
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
||||
|
||||
var sb = BridgeJson.Begin("city.update")
|
||||
.Str("city", city.City.ToString())
|
||||
.Str("electionPhase", phase)
|
||||
.Num("candidates", candidates);
|
||||
|
||||
sb.Actor("governor", city.Governor);
|
||||
sb.Actor("governorElect", city.GovernorElect);
|
||||
|
||||
if (e != null && e.Ongoing)
|
||||
sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o"));
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>Folds the election state into one of: none / nominate / vote / pending.</summary>
|
||||
private static string ElectionPhase(CityElection e)
|
||||
{
|
||||
if (e == null)
|
||||
return "none";
|
||||
|
||||
if (e.CanNominate())
|
||||
return "nominate";
|
||||
|
||||
if (e.CanVote())
|
||||
return "vote";
|
||||
|
||||
if (e.Ongoing)
|
||||
return "pending";
|
||||
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The housing registry (docs/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay
|
||||
/// *transitions*; this is the complementary *board*: one row per house with owner, location,
|
||||
/// region, co-owners, value, and current decay level, so the website can render an owner→houses
|
||||
/// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit
|
||||
/// house.update only when a house's signature changes, and house.remove when a house is gone.
|
||||
///
|
||||
/// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the
|
||||
/// registry is owner→houses; `price` is the house's placement value, not a sale listing.
|
||||
/// </summary>
|
||||
public static class BridgeHousing
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// house serial -> last-emitted signature.
|
||||
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()
|
||||
{
|
||||
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.HousingSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
|
||||
HouseSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("housing(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()
|
||||
{
|
||||
HouseSweep();
|
||||
}
|
||||
|
||||
private static void HouseSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
var seen = new HashSet<Serial>();
|
||||
|
||||
foreach (var house in BaseHouse.AllHouses)
|
||||
{
|
||||
if (house == null || house.Deleted)
|
||||
continue;
|
||||
|
||||
seen.Add(house.Serial);
|
||||
|
||||
var level = house.DecayLevel; // computed getter — read once
|
||||
var sig = Signature(house, level);
|
||||
|
||||
string prior;
|
||||
if (_last.TryGetValue(house.Serial, out prior) && prior == sig)
|
||||
continue; // unchanged since last emit
|
||||
|
||||
_last[house.Serial] = sig;
|
||||
BridgeLink.Emit(WriteHouse(house, level));
|
||||
_emitted++;
|
||||
}
|
||||
|
||||
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
foreach (var serial in gone)
|
||||
{
|
||||
_last.Remove(serial);
|
||||
BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End());
|
||||
_removed++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Signature(BaseHouse house, DecayLevel level)
|
||||
{
|
||||
var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value;
|
||||
var region = house.Region;
|
||||
var regionName = region == null ? "" : (region.Name ?? "");
|
||||
var sign = house.Sign;
|
||||
var name = sign == null ? "" : (sign.GetName() ?? "");
|
||||
var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count;
|
||||
|
||||
return String.Concat(
|
||||
ownerSerial.ToString(), "|",
|
||||
level.ToString(), "|",
|
||||
regionName, "|",
|
||||
name, "|",
|
||||
coOwners.ToString(), "|",
|
||||
house.Price.ToString());
|
||||
}
|
||||
|
||||
private static string WriteHouse(BaseHouse house, DecayLevel level)
|
||||
{
|
||||
var sb = BridgeJson.Begin("house.update")
|
||||
.Ser("serial", house.Serial)
|
||||
.Str("decay", level.ToString())
|
||||
.Num("price", house.Price)
|
||||
.Str("map", house.Map == null ? null : house.Map.Name)
|
||||
.Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
|
||||
|
||||
var sign = house.Sign;
|
||||
if (sign != null)
|
||||
sb.Str("name", sign.GetName());
|
||||
|
||||
var region = house.Region;
|
||||
if (region != null)
|
||||
sb.Str("region", region.Name);
|
||||
|
||||
sb.Actor("owner", house.Owner);
|
||||
|
||||
sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count);
|
||||
sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count);
|
||||
|
||||
sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
|
||||
sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,46 @@ namespace Server.Custom.Bridge
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a nested actor object: serial, name, account (when there is one), the linked
|
||||
/// webId (when the account is linked), and the player flag. A `null` mobile writes null.
|
||||
/// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a
|
||||
/// guild leader / joiner / governor can be attributed to a site user without a lookup.
|
||||
/// </summary>
|
||||
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":");
|
||||
|
||||
if (m == null)
|
||||
{
|
||||
sb.Append("null");
|
||||
return sb;
|
||||
}
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||
|
||||
sb.Append(",\"name\":");
|
||||
Escape(sb, m.Name ?? "");
|
||||
|
||||
var acct = m.Account as Accounting.Account;
|
||||
if (acct != null)
|
||||
{
|
||||
sb.Append(",\"acct\":");
|
||||
Escape(sb, acct.Username);
|
||||
|
||||
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||
if (webId != null)
|
||||
{
|
||||
sb.Append(",\"webId\":");
|
||||
Escape(sb, webId);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
||||
sb.Append('}');
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||||
public static string End(this StringBuilder sb)
|
||||
{
|
||||
|
||||
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Mobiles;
|
||||
using Server.Services.TownCryer;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Website news articles pushed into the modern Town Cryer News gump
|
||||
/// (docs/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier
|
||||
/// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML),
|
||||
/// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries,
|
||||
/// which the stock news gumps already render (they branch on TextDefinition.Number, so string
|
||||
/// content needs no gump change).
|
||||
///
|
||||
/// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep
|
||||
/// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just
|
||||
/// the title through the existing crier say path (default on), so players hear it in-world.
|
||||
///
|
||||
/// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall),
|
||||
/// which is required to touch the shared news list and to send crier packets.
|
||||
/// </summary>
|
||||
public static class BridgeNews
|
||||
{
|
||||
// A neutral scroll gump when the website supplies no image.
|
||||
private const int DefaultImage = 0x64E;
|
||||
|
||||
// Website id -> the news entry we created for it, so a later remove/replace can find it.
|
||||
private static readonly Dictionary<string, TownCryerNewsEntry> _ours =
|
||||
new Dictionary<string, TownCryerNewsEntry>(StringComparer.Ordinal);
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("news.add", OnAdd);
|
||||
BridgeBoot.RegisterHandler("news.remove", OnRemove);
|
||||
}
|
||||
|
||||
private static void OnAdd(Dictionary<string, object> o)
|
||||
{
|
||||
var id = BridgeJson.GetString(o, "id");
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
Reply("news.error", null, "missing id");
|
||||
return;
|
||||
}
|
||||
|
||||
var list = TownCryerSystem.NewsEntries;
|
||||
if (list == null)
|
||||
{
|
||||
Reply("news.error", id, "town cryer unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var title = BridgeJson.GetString(o, "title");
|
||||
if (String.IsNullOrEmpty(title))
|
||||
{
|
||||
Reply("news.error", id, "missing title");
|
||||
return;
|
||||
}
|
||||
|
||||
var body = BridgeJson.GetString(o, "body") ?? "";
|
||||
var url = BridgeJson.GetString(o, "url");
|
||||
int image = BridgeJson.GetInt(o, "image", DefaultImage);
|
||||
|
||||
// announce defaults to true (proclaim the title in-world); "announce":false suppresses it.
|
||||
bool announce = true;
|
||||
object rawAnnounce;
|
||||
if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool)
|
||||
announce = (bool)rawAnnounce;
|
||||
|
||||
if (title.Length > BridgeConfig.NewsMaxTitleLength)
|
||||
title = title.Substring(0, BridgeConfig.NewsMaxTitleLength);
|
||||
if (body.Length > BridgeConfig.NewsMaxBodyLength)
|
||||
body = body.Substring(0, BridgeConfig.NewsMaxBodyLength);
|
||||
|
||||
try
|
||||
{
|
||||
// Replace an existing id in place: drop the old entry first.
|
||||
TownCryerNewsEntry old;
|
||||
if (_ours.TryGetValue(id, out old) && old != null)
|
||||
{
|
||||
list.Remove(old);
|
||||
_ours.Remove(id);
|
||||
}
|
||||
else if (_ours.Count >= BridgeConfig.NewsMaxExternal)
|
||||
{
|
||||
Reply("news.error", id, "too many news entries");
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new TownCryerNewsEntry(
|
||||
new TextDefinition(title),
|
||||
new TextDefinition(body),
|
||||
image,
|
||||
null,
|
||||
url);
|
||||
|
||||
list.Insert(0, entry); // newest first, as the gump reads top-down
|
||||
_ours[id] = entry;
|
||||
|
||||
if (announce)
|
||||
Announce(title);
|
||||
|
||||
Reply("news.ok", id, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message);
|
||||
Reply("news.error", id, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnRemove(Dictionary<string, object> o)
|
||||
{
|
||||
var id = BridgeJson.GetString(o, "id");
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
Reply("news.error", null, "missing id");
|
||||
return;
|
||||
}
|
||||
|
||||
TownCryerNewsEntry entry;
|
||||
if (!_ours.TryGetValue(id, out entry))
|
||||
{
|
||||
Reply("news.error", id, "unknown id");
|
||||
return;
|
||||
}
|
||||
|
||||
_ours.Remove(id);
|
||||
|
||||
try
|
||||
{
|
||||
var list = TownCryerSystem.NewsEntries;
|
||||
if (list != null && entry != null)
|
||||
list.Remove(entry);
|
||||
|
||||
Reply("news.ok", id, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message);
|
||||
Reply("news.error", id, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Proclaims a single line — the article title — through the town criers.</summary>
|
||||
private static void Announce(string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
GlobalTownCrierEntryList.Instance.AddEntry(
|
||||
new[] { title },
|
||||
TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed proclamation must not fail the news add — the article is already posted.
|
||||
Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Reply(string kind, string id, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin(kind);
|
||||
if (id != null) sb.Str("id", id);
|
||||
if (reason != null) sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
}
|
||||
}
|
||||
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The presence stream (docs/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
|
||||
///
|
||||
/// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
|
||||
/// on a sweep but only when it changes, so the site has a live "N online"
|
||||
/// plus a change history without a firehose of identical frames.
|
||||
/// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap
|
||||
/// per-player movement signal PLAN.md §5.6 recommends over Movement.
|
||||
///
|
||||
/// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the
|
||||
/// same population the vitals sweep already walks; counting them by map and region is a handful
|
||||
/// of field reads. region.enter is filtered to players.
|
||||
/// </summary>
|
||||
public static class BridgePresence
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// Signature of the last-emitted snapshot, so an unchanged population emits nothing.
|
||||
private static string _lastSig;
|
||||
|
||||
private static long _sweeps, _emitted, _regionEnters;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.OnEnterRegion += OnEnterRegion;
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
// Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the
|
||||
// current population within one sweep.
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
_lastSig = null;
|
||||
}
|
||||
|
||||
/// <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.PresenceSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
|
||||
PresenceSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})",
|
||||
_sweeps, _emitted, _regionEnters);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
PresenceSweep();
|
||||
}
|
||||
|
||||
private static void PresenceSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
int total = 0;
|
||||
var byFacet = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||
var byRegion = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var pm = m as PlayerMobile;
|
||||
|
||||
if (pm == null || pm.NetState == null || pm.Deleted)
|
||||
continue;
|
||||
|
||||
total++;
|
||||
|
||||
var facet = pm.Map == null ? "Internal" : pm.Map.Name;
|
||||
Bump(byFacet, facet);
|
||||
|
||||
var region = pm.Region;
|
||||
var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name;
|
||||
Bump(byRegion, regionName);
|
||||
}
|
||||
|
||||
var sig = Signature(total, byFacet, byRegion);
|
||||
if (sig == _lastSig)
|
||||
return; // population unchanged since last emit
|
||||
|
||||
_lastSig = sig;
|
||||
BridgeLink.Emit(WriteOnline(total, byFacet, byRegion));
|
||||
_emitted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Bump(IDictionary<string, int> map, string key)
|
||||
{
|
||||
int n;
|
||||
map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
|
||||
}
|
||||
|
||||
private static string Signature(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append(total);
|
||||
foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
||||
sb.Append('#');
|
||||
foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string WriteOnline(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
||||
{
|
||||
var sb = BridgeJson.Begin("presence.online").Num("count", total);
|
||||
|
||||
WriteCounts(sb, "byFacet", byFacet);
|
||||
WriteCounts(sb, "byRegion", byRegion);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>Writes a nested object of {name: count} pairs.</summary>
|
||||
private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary<string, int> counts)
|
||||
{
|
||||
sb.Append(",\"").Append(field).Append("\":{");
|
||||
|
||||
bool first = true;
|
||||
foreach (var kv in counts)
|
||||
{
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
first = false;
|
||||
|
||||
BridgeJson.Escape(sb, kv.Key);
|
||||
sb.Append(':').Append(kv.Value);
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- real-time region transitions ----
|
||||
|
||||
private static void OnEnterRegion(OnEnterRegionEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (e == null || e.From == null || !e.From.Player)
|
||||
return;
|
||||
|
||||
var from = e.OldRegion;
|
||||
var to = e.NewRegion;
|
||||
|
||||
// Only meaningful when the named region actually changed.
|
||||
var fromName = from == null ? null : from.Name;
|
||||
var toName = to == null ? null : to.Name;
|
||||
if (String.Equals(fromName, toName, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var sb = BridgeJson.Begin("region.enter")
|
||||
.Str("from", fromName)
|
||||
.Str("to", toName)
|
||||
.Str("map", e.From.Map == null ? null : e.From.Map.Name);
|
||||
|
||||
sb.Actor("who", e.From);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
_regionEnters++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,55 @@ namespace Server.Custom.Bridge
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
WriteTitles(sb, m);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The titles a character holds (docs/PROTOCOL_2.md §10.3). `selected` is the index into
|
||||
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
|
||||
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
|
||||
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
|
||||
/// </summary>
|
||||
private static void WriteTitles(StringBuilder sb, PlayerMobile m)
|
||||
{
|
||||
sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle);
|
||||
|
||||
var fameKarma = m.FameKarmaTitle;
|
||||
if (!String.IsNullOrEmpty(fameKarma))
|
||||
{
|
||||
sb.Append(",\"fameKarma\":");
|
||||
BridgeJson.Escape(sb, fameKarma);
|
||||
}
|
||||
|
||||
var skill = m.PaperdollSkillTitle;
|
||||
if (!String.IsNullOrEmpty(skill))
|
||||
{
|
||||
sb.Append(",\"skill\":");
|
||||
BridgeJson.Escape(sb, skill);
|
||||
}
|
||||
|
||||
sb.Append(",\"reward\":[");
|
||||
var rewards = m.RewardTitles;
|
||||
if (rewards != null)
|
||||
{
|
||||
bool first = true;
|
||||
for (int i = 0; i < rewards.Count; i++)
|
||||
{
|
||||
var r = rewards[i];
|
||||
if (r == null)
|
||||
continue;
|
||||
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
|
||||
BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
sb.Append("]}");
|
||||
}
|
||||
|
||||
private static bool IsGearLayer(Layer layer)
|
||||
{
|
||||
switch (layer)
|
||||
|
||||
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using Server.Guilds;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The guild stream (docs/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
|
||||
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
|
||||
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
|
||||
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
|
||||
/// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
|
||||
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
|
||||
///
|
||||
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
|
||||
/// so joined" feed does not wait for the next sweep. A membership change also moves the board
|
||||
/// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
|
||||
/// the next `guild.update`; per-member leave events would need a core tap and are a later
|
||||
/// refinement (§10.1).
|
||||
///
|
||||
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
|
||||
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
|
||||
/// guild, would look like every guild being created at once.
|
||||
/// </summary>
|
||||
public static class BridgeSocial
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
|
||||
// was cleared on reconnect), so its next sweep counts as a change.
|
||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
||||
|
||||
private static long _sweeps, _emitted, _removed, _joins;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.JoinGuild += OnJoinGuild;
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
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.GuildSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
|
||||
GuildSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
|
||||
_sweeps, _emitted, _removed, _joins, _last.Count);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
GuildSweep();
|
||||
}
|
||||
|
||||
private static void GuildSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
var seen = new HashSet<int>();
|
||||
|
||||
foreach (var bg in BaseGuild.List.Values)
|
||||
{
|
||||
var g = bg as Guild;
|
||||
|
||||
// Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
|
||||
// and treating them as absent lets the "gone" pass below emit guild.remove.
|
||||
if (g == null || g.Disbanded)
|
||||
continue;
|
||||
|
||||
seen.Add(g.Id);
|
||||
|
||||
var sig = Signature(g);
|
||||
|
||||
string prior;
|
||||
if (_last.TryGetValue(g.Id, out prior) && prior == sig)
|
||||
continue; // unchanged since last emit
|
||||
|
||||
_last[g.Id] = sig;
|
||||
BridgeLink.Emit(WriteGuild(g));
|
||||
_emitted++;
|
||||
}
|
||||
|
||||
// Anything tracked last sweep but not seen now has disbanded or been removed.
|
||||
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
foreach (var id in gone)
|
||||
{
|
||||
_last.Remove(id);
|
||||
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
|
||||
_removed++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// The volatile fields that define a meaningful change: name, abbreviation, leader, member
|
||||
// count, the member set (order-independent serial sum), and alliance.
|
||||
private static string Signature(Guild g)
|
||||
{
|
||||
long memberSum = 0;
|
||||
int count = 0;
|
||||
|
||||
var members = g.Members;
|
||||
if (members != null)
|
||||
{
|
||||
for (int i = 0; i < members.Count; i++)
|
||||
{
|
||||
var m = members[i];
|
||||
if (m == null)
|
||||
continue;
|
||||
count++;
|
||||
unchecked { memberSum += (uint)m.Serial.Value; }
|
||||
}
|
||||
}
|
||||
|
||||
var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
|
||||
|
||||
return String.Concat(
|
||||
g.Name ?? "", "|",
|
||||
g.Abbreviation ?? "", "|",
|
||||
leaderSerial.ToString(), "|",
|
||||
count.ToString(), "|",
|
||||
memberSum.ToString(), "|",
|
||||
g.Alliance == null ? "" : (g.AllianceName ?? ""));
|
||||
}
|
||||
|
||||
private static string WriteGuild(Guild g)
|
||||
{
|
||||
int online = 0, count = 0;
|
||||
var members = g.Members;
|
||||
if (members != null)
|
||||
{
|
||||
for (int i = 0; i < members.Count; i++)
|
||||
{
|
||||
var m = members[i];
|
||||
if (m == null)
|
||||
continue;
|
||||
count++;
|
||||
if (m.NetState != null)
|
||||
online++;
|
||||
}
|
||||
}
|
||||
|
||||
var sb = BridgeJson.Begin("guild.update")
|
||||
.Num("id", g.Id)
|
||||
.Str("name", g.Name)
|
||||
.Str("abbr", g.Abbreviation)
|
||||
.Num("members", count)
|
||||
.Num("online", online)
|
||||
.Str("alliance", g.Alliance == null ? null : g.AllianceName);
|
||||
|
||||
sb.Actor("leader", g.Leader);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
// ---- real-time join ----
|
||||
|
||||
private static void OnJoinGuild(JoinGuildEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (e == null || e.Mobile == null)
|
||||
return;
|
||||
|
||||
var g = e.Guild as Guild;
|
||||
|
||||
var sb = BridgeJson.Begin("guild.join");
|
||||
if (g != null)
|
||||
sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
|
||||
sb.Actor("who", e.Mobile);
|
||||
BridgeLink.Emit(sb.End());
|
||||
_joins++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user