using System; using System.Collections.Generic; using System.Globalization; using Server.Items; using Server.Misc; using Server.Mobiles; namespace Server.Custom.Bridge { /// /// Protocol 7, part b. The two verbs that are neither owned nor borrowed: an item put into /// someone's hands, and a world save. /// /// EVENTS_PLAN.md Phase 12b. Everything else the event plane does is a thing this shard can /// take back — a creature it deletes, a value it restores. These two are not, and they are /// in the same file because that is what they have in common: done is done. /// /// ── The grant, and why §8's exclusion of it was reopened separately ──────────────────── /// /// `ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both — /// deliberately as two reversals rather than one, because permitting an event to create a /// creature says nothing about permitting it to hand out loot. What makes this grant a /// different proposition from the one §8 refused is four properties it did not have then, /// and all four are visible in this file: /// /// - **Declared, not typed.** The allowlist below is the shard's, and an item not on it /// cannot be granted however the request is spelled. There is no free-text type name /// reaching `Activator.CreateInstance` — that is `[add`, which §G excludes. /// - **Bounded.** `EventsMaxGrantPerRun` bounds the whole run and /// `EventsMaxGrantStack` bounds one hand; both refuse rather than clamp. /// - **Attributable.** The run id rides on every grant and is logged with it. /// - **Idempotent.** Protocol 6's key means a lost acknowledgement cannot double a /// reward, which is the failure that made §G call the grant un-retryable when it was /// written. It is retryable now, and 11a is the whole reason. /// /// ── Who receives it is answered HERE, and that is the interesting decision ───────────── /// /// A grant needs a list of people, and the website has one — `event_run_participants`. It /// would have had to reach through core to get it, because a module cannot read core's /// tables, so the alternative was a new core surface handing participants to a module's /// `perform()`. /// /// It is not needed: **this shard already has the list**, in 11b's run-scoped participation /// ledger, keyed by the same character serials the website's `member_key` holds. So the /// grant names a run and the recipients are resolved from the ledger the run has been /// keeping all along — no new core surface, no participant list crossing the wire twice, /// and no window in which the two disagree. /// /// A run with no open ledger grants to nobody and says so, rather than granting to /// everybody online. "Everyone present" is not a thing this file will guess at. /// /// ── The save ────────────────────────────────────────────────────────────────────────── /// /// `ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a /// phase boundary — the point in an event after which what has happened is safe from a /// crash — and `world.save.before` / `world.save.after` are already on the wire, so the /// acknowledgement it needs exists. /// /// **A save stops the world**, so unlike every other verb here it is rate-limited by the /// shard rather than only capped: `EventsMinSaveIntervalSec` refuses a save that comes too /// soon after the last one, whether the last one was an event's or ServUO's own autosave. /// Refuses, never queues — a queued save would arrive at a moment nobody chose. /// public static class BridgeOneShots { // ---- the grant allowlist ---- /// /// One grantable item: what an author names it, and what this shard builds. /// /// **The list is short and boring on purpose.** Every entry is a thing an event /// plausibly hands out and nothing here is equipment with rolled properties — an /// artifact generator behind an unattended schedule is a different proposition and one /// nobody has asked for. An operator who wants more edits this array, which is a /// deployment they control rather than a field on a web form. /// private sealed class GrantEntry { public string Key; public string Label; public string Type; } private static readonly GrantEntry[] Grants = { new GrantEntry { Key = "gold", Label = "Gold", Type = "Server.Items.Gold" }, new GrantEntry { Key = "cloak", Label = "Cloak", Type = "Server.Items.Cloak" }, new GrantEntry { Key = "sandals", Label = "Sandals", Type = "Server.Items.Sandals" }, new GrantEntry { Key = "candle", Label = "Candle", Type = "Server.Items.Candle" }, new GrantEntry { Key = "earrings", Label = "Silver earrings", Type = "Server.Items.SilverEarrings" }, new GrantEntry { Key = "fireworks", Label = "Fireworks wand", Type = "Server.Items.FireworksWand" }, new GrantEntry { Key = "bottle", Label = "Message in a bottle", Type = "Server.Items.MessageInABottle" }, }; private static long _granted, _saves, _refused; private static long _lastSaveMs; public static void Initialize() { if (!BridgeConfig.Enabled) return; BridgeBoot.RegisterHandler("item.grant", OnGrant); BridgeBoot.RegisterHandler("item.catalog", OnCatalog); BridgeBoot.RegisterHandler("world.save", OnSave); // Counted whoever asked for it, so the interval below also covers ServUO's own // autosave. An event save landing thirty seconds after the hourly one is the same // freeze twice, and the shard is the only half that can see both. EventSink.WorldSave += (e) => { _lastSaveMs = BridgeJson.NowMs(); }; } public static string Status() { return String.Format("oneshots(granted={0} saves={1} refused={2})", _granted, _saves, _refused); } // ---- item.catalog ---- /// /// What this shard is willing to grant. /// /// A read, so the website's option source can offer real choices — and the module holds /// the same list, so the dropdown still works with the shard down. Two copies of a /// short allowlist, exactly like the lease bounds: the module's is what makes a bad /// value a refusal on a form, and this one is what is true when the website is wrong. /// private static void OnCatalog(Dictionary o) { var reqId = BridgeJson.GetString(o, "reqId"); if (!Ready(reqId, "catalog")) return; var sb = BridgeJson.Begin("item.catalog.ok"); if (reqId != null) sb.Str("reqId", reqId); sb.Append(",\"items\":["); for (int i = 0; i < Grants.Length; i++) { if (i > 0) sb.Append(','); sb.Append("{\"key\":"); BridgeJson.Text(sb, Grants[i].Key); sb.Append(",\"label\":"); BridgeJson.Text(sb, Grants[i].Label); sb.Append(",\"stackable\":").Append(Stackable(Grants[i]) ? "true" : "false"); sb.Append('}'); } sb.Append(']'); sb.Append(",\"maxPerRun\":").Append(BridgeConfig.EventsMaxGrantPerRun); sb.Append(",\"maxStack\":").Append(BridgeConfig.EventsMaxGrantStack); BridgeLink.Emit(sb.End()); } // ---- item.grant ---- private static void OnGrant(Dictionary o) { var reqId = BridgeJson.GetString(o, "reqId"); if (!Ready(reqId, "grant")) return; var runId = BridgeJson.GetString(o, "runId"); if (String.IsNullOrEmpty(runId)) { Err(reqId, "grant", "a grant needs a runId"); return; } var entry = LookupGrant(BridgeJson.GetString(o, "item")); if (entry == null) { Err(reqId, "grant", "this shard does not grant '" + BridgeJson.GetString(o, "item") + "'"); return; } var amount = (int)BridgeJson.GetLong(o, "amount", 1L); if (amount < 1) { Err(reqId, "grant", "a grant needs a positive amount"); return; } // Refused rather than clamped, on `LeaseMaxDurationSec`'s argument from 11b: the // website is the half that records what was handed out, and a silent clamp would make // its ledger a description of a grant that did not happen. if (amount > BridgeConfig.EventsMaxGrantStack) { Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture, "this shard grants at most {0} at a time, and {1} were asked for", BridgeConfig.EventsMaxGrantStack, amount)); return; } var toBank = String.Equals(BridgeJson.GetString(o, "where"), "bank", StringComparison.OrdinalIgnoreCase); var serials = BridgeParticipation.MemberSerials(runId); if (serials == null) { Err(reqId, "grant", "run " + runId + " has no participation ledger open on this shard"); return; } if (serials.Count == 0) { // Not a refusal: a run whose event nobody attended is a real outcome, and the // website needs to record a grant that reached nobody rather than a failed step it // will retry against the same empty ledger. var none = BridgeJson.Begin("item.grant.ok"); if (reqId != null) none.Str("reqId", reqId); none.Str("runId", runId).Str("item", entry.Key); none.Append(",\"granted\":0,\"missed\":[]"); BridgeLink.Emit(none.End()); return; } if (serials.Count > BridgeConfig.EventsMaxGrantPerRun) { Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture, "that run has {0} participants and this shard grants to at most {1}", serials.Count, BridgeConfig.EventsMaxGrantPerRun)); return; } var hue = (int)BridgeJson.GetLong(o, "hue", 0L); var name = BridgeJson.GetString(o, "name"); if (name != null && name.Length > 40) name = name.Substring(0, 40); var granted = 0; var missed = new List(); for (int i = 0; i < serials.Count; i++) { var mobile = World.FindMobile((Serial)serials[i]) as PlayerMobile; if (mobile == null || mobile.Deleted) { missed.Add(Hex(serials[i]) + ": no such character"); continue; } string why; if (Give(mobile, entry, amount, hue, name, toBank, out why)) granted++; else missed.Add(Hex(serials[i]) + ": " + why); } _granted += granted; Console.WriteLine("[Bridge] grant {0} x{1} to run {2}: {3} of {4}", entry.Key, amount, runId, granted, serials.Count); var sb = BridgeJson.Begin("item.grant.ok"); if (reqId != null) sb.Str("reqId", reqId); sb.Str("runId", runId).Str("item", entry.Key); sb.Append(",\"granted\":").Append(granted); sb.Append(",\"missed\":["); for (int i = 0; i < missed.Count; i++) { if (i > 0) sb.Append(','); BridgeJson.Text(sb, missed[i]); } sb.Append(']'); BridgeLink.Emit(sb.End()); } /// /// Builds and hands over one grant, or says why it could not. /// /// **A grant that cannot be delivered is deleted rather than dropped on the floor.** /// `AddItem` failing on a full backpack would otherwise leave the item in the world at /// (0,0) — a real ServUO trap — and an event that quietly littered the map with /// undeliverable rewards would be worse than one that reported a miss. /// private static bool Give(PlayerMobile mobile, GrantEntry entry, int amount, int hue, string name, bool toBank, out string why) { why = null; var container = toBank ? (Container)mobile.BankBox : mobile.Backpack; if (container == null || container.Deleted) { why = toBank ? "no bank box" : "no backpack"; return false; } Item item; try { item = Build(entry); } catch (Exception e) { why = "could not be created (" + e.Message + ")"; return false; } if (item == null) { why = "could not be created"; return false; } if (item.Stackable) { item.Amount = amount; } else if (amount > 1) { // A non-stackable granted in quantity would be N items, and N items is N chances to // overflow a backpack halfway through with no way to report which half landed. // One is what an event means by "a commemorative cloak" anyway. item.Delete(); why = "is not stackable, so it can only be granted one at a time"; return false; } if (hue > 0) item.Hue = hue; if (!String.IsNullOrEmpty(name)) item.Name = name; if (!container.TryDropItem(mobile, item, false)) { item.Delete(); why = toBank ? "bank box is full" : "backpack is full"; return false; } return true; } private static Item Build(GrantEntry entry) { var type = ScriptCompiler.FindTypeByFullName(entry.Type); if (type == null) return null; return Activator.CreateInstance(type) as Item; } private static bool Stackable(GrantEntry entry) { Item probe = null; try { probe = Build(entry); return probe != null && probe.Stackable; } catch { return false; } finally { if (probe != null) probe.Delete(); } } // ---- world.save ---- private static void OnSave(Dictionary o) { var reqId = BridgeJson.GetString(o, "reqId"); if (!Ready(reqId, "save")) return; var since = BridgeJson.NowMs() - _lastSaveMs; var minimum = (long)BridgeConfig.EventsMinSaveIntervalSec * 1000L; if (_lastSaveMs > 0L && since < minimum) { // **Refused, not queued.** A queued save would land at a moment nobody chose, in the // middle of whatever the next step is doing. Refusing tells the website exactly what // happened, and a save skipped because one just happened has cost nothing. Err(reqId, "save", String.Format(CultureInfo.InvariantCulture, "this shard saves at most every {0} seconds, and the last save was {1} seconds ago", BridgeConfig.EventsMinSaveIntervalSec, since / 1000L)); return; } // `world.save.before` and `world.save.after` are emitted by `BridgeEvents` from ServUO's // own hooks, so the acknowledgement of what actually happened rides those rather than // being asserted here. This reply says only that the save was STARTED. _saves++; AutoSave.Save(); var sb = BridgeJson.Begin("world.save.ok"); if (reqId != null) sb.Str("reqId", reqId); sb.Bool("started", true); BridgeLink.Emit(sb.End()); } // ---- helpers ---- private static GrantEntry LookupGrant(string key) { if (key == null) return null; for (int i = 0; i < Grants.Length; i++) { if (String.Equals(Grants[i].Key, key, StringComparison.OrdinalIgnoreCase)) return Grants[i]; } return null; } private static string Hex(int serial) { return "0x" + serial.ToString("X", CultureInfo.InvariantCulture); } private static bool Ready(string reqId, string action) { if (!BridgeConfig.EventsEnabled) { Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)"); return false; } return true; } private static void Err(string reqId, string action, string reason) { _refused++; var sb = BridgeJson.Begin("oneshot.error"); if (reqId != null) sb.Str("reqId", reqId); sb.Str("action", action).Str("reason", reason); BridgeLink.Emit(sb.End()); } } }