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());
}
}
}