diff --git a/README.md b/README.md
index 92886e7..67d4b4e 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
-| 5 — `[link` account linking | not started |
+| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** |
| 6 — town-crier inbound | not started |
| 7 — `PlayerVendorSale` core event | not started |
| 8 — cheat signals | not started |
@@ -69,6 +69,7 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. |
+| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. |
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 5c2dc2d..e1c6c65 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -307,6 +307,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
+5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15.
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
@@ -348,6 +349,36 @@ Two defects were found this way and fixed:
---
+## 15. Phase 5 acceptance
+
+`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`.
+
+Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it):
+
+```
+<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300
+-> link.confirm code=77M9TK websiteUserId=web-9931
+<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931
+-> link.confirm code=BADCOD ...
+<- link.error code=BADCOD reason="unknown or expired code"
+```
+
+**The tag persists.** After a `World.Save()`, `accounts.xml` contained:
+
+```xml
+
+ web-9931
+
+```
+
+This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised.
+
+Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup.
+
+Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`.
+
+---
+
## 14. Phase 4 acceptance
`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index f1ddd8f..5ceccce 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -19,6 +19,9 @@ StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
+# Shown to a player when they run [link. The website page where they enter the code.
+LinkUrl=https://yoursite/link
+
# 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
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
new file mode 100644
index 0000000..d9a34e2
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
@@ -0,0 +1,250 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Accounting;
+using Server.Commands;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// Ties a game account to a website account.
+ ///
+ /// Flow:
+ /// 1. In game, the player runs [link. The shard mints a short, one-time, expiring code,
+ /// holds it in memory keyed to their account, and emits link.request to the sidecar.
+ /// 2. The player enters that code on the website. The website tells the sidecar, which
+ /// sends link.confirm inbound.
+ /// 3. The shard validates the code, writes Account tag "WebsiteUserId", drops the code,
+ /// and replies link.ok. The tag persists to accounts.xml across restarts.
+ ///
+ /// The code table and the account write both live on the Core thread. The websiteUserId in
+ /// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md §2); if the
+ /// sidecar ever moves off-host, gate it behind a shared secret.
+ ///
+ public static class BridgeAccountLink
+ {
+ private const string Tag = "WebsiteUserId";
+
+ // Unambiguous alphabet: no O/0, I/1, so a player reading a code aloud can't get it wrong.
+ private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ private const int CodeLength = 6;
+
+ private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
+ private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
+
+ private sealed class Pending
+ {
+ public string Account;
+ public DateTime Expires;
+ }
+
+ // code -> pending link. Core-thread only.
+ private static readonly Dictionary _codes =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // account -> last [link time, to rate-limit code spam.
+ private static readonly Dictionary _lastRequest =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
+ BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
+
+ // Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
+ Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), PurgeExpired);
+ }
+
+ /// Reads the linked website id for an account, or null. Used to enrich events.
+ public static string WebIdFor(Account acct)
+ {
+ if (acct == null)
+ return null;
+
+ return acct.GetTag(Tag);
+ }
+
+ // ---- [link ----
+
+ [Usage("link")]
+ [Description("Links this game account to your website account via a one-time code.")]
+ private static void OnLinkCommand(CommandEventArgs e)
+ {
+ RequestLink(e.Mobile);
+ }
+
+ ///
+ /// Mints a one-time code for the mobile's account and emits link.request. This is the
+ /// body of the [link command, exposed so it can be driven in tests without a client.
+ ///
+ public static void RequestLink(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 already linked to website user {0}.", existing);
+ return;
+ }
+
+ DateTime last;
+ if (_lastRequest.TryGetValue(acct.Username, out last) && DateTime.UtcNow - last < RequestCooldown)
+ {
+ m.SendMessage("Please wait a moment before requesting another link code.");
+ return;
+ }
+
+ // One outstanding code per account: drop any prior code so only the newest works.
+ DropCodesFor(acct.Username);
+
+ var code = MintCode();
+ _codes[code] = new Pending { Account = acct.Username, Expires = DateTime.UtcNow + CodeTtl };
+ _lastRequest[acct.Username] = DateTime.UtcNow;
+
+ BridgeLink.Emit(BridgeJson.Begin("link.request")
+ .Str("code", code)
+ .Str("account", acct.Username)
+ .Str("char", m.Name)
+ .Num("ttlSec", (long)CodeTtl.TotalSeconds)
+ .End());
+
+ var url = BridgeConfig.LinkUrl;
+ m.SendMessage(0x35, "Link code: {0}", code);
+ m.SendMessage("Enter it at {0} within {1} minutes to link your account.",
+ url, (int)CodeTtl.TotalMinutes);
+ }
+
+ // ---- inbound link.confirm ----
+
+ private static void OnLinkConfirm(Dictionary o)
+ {
+ var code = BridgeJson.GetString(o, "code");
+ var webId = BridgeJson.GetString(o, "websiteUserId");
+
+ if (code == null || webId == null)
+ {
+ Reply("link.error", null, null, "malformed link.confirm");
+ return;
+ }
+
+ Pending pending;
+ if (!_codes.TryGetValue(code, out pending))
+ {
+ Reply("link.error", code, null, "unknown or expired code");
+ return;
+ }
+
+ _codes.Remove(code);
+
+ if (DateTime.UtcNow > pending.Expires)
+ {
+ Reply("link.error", code, pending.Account, "code expired");
+ return;
+ }
+
+ var acct = Accounting.Accounts.GetAccount(pending.Account) as Account;
+ if (acct == null)
+ {
+ Reply("link.error", code, pending.Account, "account no longer exists");
+ return;
+ }
+
+ // Persisted to accounts.xml on the next world save.
+ acct.SetTag(Tag, webId);
+ DropCodesFor(pending.Account);
+
+ Reply("link.ok", code, pending.Account, null, webId);
+
+ NotifyOnline(acct, webId);
+ }
+
+ // ---- helpers ----
+
+ private static void Reply(string kind, string code, string account, string reason, string webId = null)
+ {
+ var sb = BridgeJson.Begin(kind);
+ if (code != null) sb.Str("code", code);
+ if (account != null) sb.Str("account", account);
+ if (webId != null) sb.Str("websiteUserId", webId);
+ if (reason != null) sb.Str("reason", reason);
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void NotifyOnline(Account acct, string webId)
+ {
+ for (int i = 0; i < acct.Length; i++)
+ {
+ var m = acct[i];
+ if (m != null && m.NetState != null)
+ m.SendMessage(0x40, "Your account is now linked to website user {0}.", webId);
+ }
+ }
+
+ private static string MintCode()
+ {
+ // Avoid a collision with an outstanding code, though at 32^6 it is astronomically rare.
+ for (int attempt = 0; attempt < 8; attempt++)
+ {
+ var chars = new char[CodeLength];
+ for (int i = 0; i < CodeLength; i++)
+ chars[i] = Alphabet[Utility.Random(Alphabet.Length)];
+
+ var code = new string(chars);
+ if (!_codes.ContainsKey(code))
+ return code;
+ }
+
+ // Fall back to a guaranteed-unique code.
+ return "L" + DateTime.UtcNow.Ticks.ToString("X").Substring(0, CodeLength - 1);
+ }
+
+ private static void DropCodesFor(string account)
+ {
+ var doomed = new List();
+
+ foreach (var kv in _codes)
+ {
+ if (String.Equals(kv.Value.Account, account, StringComparison.OrdinalIgnoreCase))
+ doomed.Add(kv.Key);
+ }
+
+ foreach (var c in doomed)
+ _codes.Remove(c);
+ }
+
+ private static void PurgeExpired()
+ {
+ try
+ {
+ var now = DateTime.UtcNow;
+ var doomed = new List();
+
+ foreach (var kv in _codes)
+ {
+ if (now > kv.Value.Expires)
+ doomed.Add(kv.Key);
+ }
+
+ foreach (var c in doomed)
+ _codes.Remove(c);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] link purge threw: {0}", ex.Message);
+ }
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index c5e0207..a369599 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -18,6 +18,8 @@ namespace Server.Custom.Bridge
public static int DecaySweepSeconds { get; private set; }
public static int EconomySweepSeconds { get; private set; }
+ public static string LinkUrl { get; private set; }
+
public static bool Enabled { get; private set; }
public static void Configure()
@@ -38,6 +40,8 @@ namespace Server.Custom.Bridge
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
+ LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
+
if (QueueCap < 16)
QueueCap = 16;
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
index 9c373ce..a05fbea 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
@@ -123,11 +123,19 @@ namespace Server.Custom.Bridge
if (m == null)
return;
- BridgeLink.Emit(BridgeJson.Begin("mob.login")
+ // Carry the linked website id on the login anchor so the sidecar can attribute
+ // this session (and everything after it) to a site user without a lookup.
+ var webId = BridgeAccountLink.WebIdFor(m.Account as Account);
+
+ var sb = BridgeJson.Begin("mob.login")
.Mob("who", m)
.Str("map", m.Map == null ? null : m.Map.Name)
- .Num("x", m.X).Num("y", m.Y).Num("z", m.Z)
- .End());
+ .Num("x", m.X).Num("y", m.Y).Num("z", m.Z);
+
+ if (webId != null)
+ sb.Str("webId", webId);
+
+ BridgeLink.Emit(sb.End());
});
}
diff --git a/tools/scaffolding/BridgeLinkProbe.cs b/tools/scaffolding/BridgeLinkProbe.cs
new file mode 100644
index 0000000..7004b80
--- /dev/null
+++ b/tools/scaffolding/BridgeLinkProbe.cs
@@ -0,0 +1,64 @@
+using System;
+
+using Server.Accounting;
+using Server.Custom.Bridge;
+using Server.Mobiles;
+
+namespace Server.Custom
+{
+ ///
+ /// Triggers the [link flow for a seeded character without a game client, so the account
+ /// linking round-trip can be tested end to end: RequestLink emits link.request, the test
+ /// sidecar reads the code and sends link.confirm, and the account gets tagged.
+ ///
+ /// Test scaffolding. Never deployed. Writes an account tag (persisted on save).
+ ///
+ public static class BridgeLinkProbe
+ {
+ public static void Initialize()
+ {
+ if (Config.Get("Bridge.LinkProbeOnStart", false))
+ EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(3.0), Run);
+ }
+
+ private static void Run()
+ {
+ try
+ {
+ // Use a seed account not already linked. seed_001 slot 0.
+ var acct = Accounting.Accounts.GetAccount("seed_001") as Account;
+ var pm = acct == null ? null : acct[0] as PlayerMobile;
+
+ if (pm == null)
+ {
+ Console.WriteLine("[LinkProbe] seed_001 slot 0 not found; seed the world first");
+ return;
+ }
+
+ var existing = acct.GetTag("WebsiteUserId");
+ if (existing != null)
+ Console.WriteLine("[LinkProbe] seed_001 already linked to {0}; re-running anyway", existing);
+
+ Console.WriteLine("[LinkProbe] requesting link for seed_001 / {0}", pm.Name);
+ BridgeAccountLink.RequestLink(pm);
+ Console.WriteLine("[LinkProbe] link.request emitted; watch for the code -> link.confirm -> link.ok");
+
+ // Give the confirm time to land and set the tag, then save so it reaches
+ // accounts.xml. This proves persistence across a restart.
+ Timer.DelayCall(TimeSpan.FromSeconds(5.0), () =>
+ {
+ var tag = acct.GetTag("WebsiteUserId");
+ Console.WriteLine("[LinkProbe] after confirm, seed_001 WebsiteUserId tag = {0}",
+ tag ?? "(null)");
+ Console.WriteLine("[LinkProbe] saving world to persist the tag...");
+ World.Save();
+ Console.WriteLine("[LinkProbe] saved");
+ });
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[LinkProbe] FAILED: " + ex);
+ }
+ }
+ }
+}
diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md
index 14a83e8..3c33838 100644
--- a/tools/scaffolding/README.md
+++ b/tools/scaffolding/README.md
@@ -10,6 +10,7 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k
| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. |
| `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. |
+| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
## Deploy overwrites Bridge.cfg
diff --git a/tools/stub_sidecar_link.ps1 b/tools/stub_sidecar_link.ps1
new file mode 100644
index 0000000..123eab1
--- /dev/null
+++ b/tools/stub_sidecar_link.ps1
@@ -0,0 +1,66 @@
+param(
+ [int] $Port = 7788,
+ [string] $Log = "$PSScriptRoot\sc_link.log"
+)
+
+function Say($msg) {
+ for ($i = 0; $i -lt 5; $i++) {
+ try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
+ catch { Start-Sleep -Milliseconds 100 }
+ }
+}
+
+"" | Out-File -FilePath $Log -Encoding utf8
+Say "[link-sc] starting on 127.0.0.1:$Port"
+
+$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
+$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
+$bound = $false
+for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
+ try { $listener.Start(); $bound = $true } catch { Start-Sleep -Seconds 1 }
+}
+if (-not $bound) { Say "[link-sc] could not bind"; exit 1 }
+Say "[link-sc] listening"
+
+$client = $listener.AcceptTcpClient()
+Say "[link-sc] === shard connected ==="
+$stream = $client.GetStream()
+$reader = New-Object System.IO.StreamReader($stream)
+$writer = New-Object System.IO.StreamWriter($stream)
+$writer.AutoFlush = $true
+
+$deadline = (Get-Date).AddSeconds(20)
+$confirmed = $false
+
+while ((Get-Date) -lt $deadline) {
+ if ($stream.DataAvailable) {
+ $line = $reader.ReadLine()
+ if ($null -eq $line) { break }
+ Say "[link-sc] <- $line"
+
+ # When the shard emits a link.request, extract the code and confirm it.
+ if (-not $confirmed -and $line -match '"kind":"link\.request"') {
+ if ($line -match '"code":"([^"]+)"') {
+ $code = $Matches[1]
+ $confirm = '{"kind":"link.confirm","code":"' + $code + '","websiteUserId":"web-9931"}'
+ $writer.WriteLine($confirm)
+ Say "[link-sc] -> $confirm"
+ $confirmed = $true
+ }
+ }
+ } else {
+ Start-Sleep -Milliseconds 100
+ }
+}
+
+# Second exchange: send a bad confirm to prove the error path.
+$writer.WriteLine('{"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}')
+Say '[link-sc] -> {"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}'
+$t = (Get-Date).AddSeconds(3)
+while ((Get-Date) -lt $t) {
+ if ($stream.DataAvailable) { $l = $reader.ReadLine(); if ($l) { Say "[link-sc] <- $l" } }
+ else { Start-Sleep -Milliseconds 100 }
+}
+
+Say "[link-sc] done"
+$client.Close(); $listener.Stop()