Five verbs an author sees -- creatures, an enhanced "boss", an oracle NPC, a temporary gate, decoration -- and ONE command family underneath them, because every one of them ends in the same sentence: an object exists, and this run owns it. `world.spawn` / `world.despawn` / `world.owned` carry a `what` discriminator, and the per-verb differences are fields rather than kinds. The ownership registry is PERSISTED, and that is forced rather than chosen. A spawned creature is in the world save, so it survives the restart that proves a town-crier line gone -- which already rules out reconcile-by-boot-stamp. But the record of which run owns which serial has nowhere else to live: in memory it is lost in the restart the creatures survive, and only in the website's ledger it is not held here at all, so `world.despawn` would delete whatever serial it was handed and "never touches a creature it did not create" would have no mechanism behind it. So the Bridge gains its second persisted file beside `Participation.bin` -- written by the same world save as the objects it describes, so the two cannot get out of step. The oracle is ours rather than `XmlSpawner2.XmlDialog`'s, and that engine is the reason for both halves of the decision. Its `SpeechEntry` is the evidence the shape is right -- `Text` plus comma-separated `Keywords`, a keyword-less entry as the greeting, a proximity range, a conversation lock. It is also why not to build on it: `SpeechEntry` carries an `Action` string, XmlSpawner's command-scripting language, which would leave an arbitrary-command field one step from an event author. `Mobile.OnMovement` (delivered to every mobile in range -- the `HandlesOnMovement` filter applies only to Items) and `Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are all it needs. Every `Bridge.EventsMax*` REFUSES rather than clamps, on `LeaseMaxDurationSec`'s argument from 11b: the shard's bound exists for the case where the website is wrong. `Bridge.EventsEnabled` gates all of it -- spawning is the same consent 11b introduced that switch for, not a third one. Decoration carries an `itemId`, because `Static` accounts for 5031 of the tree's decoration placements under 1992 different graphics: for that class the graphic IS the identity. Never applied to a `BaseAddon`, whose own ItemID is not what a player sees. Containers are refused outright -- teardown would delete whatever a player had left inside. `tools/scaffolding` gains `worldgone <serial>`, which deletes an object behind the registry's back. It is the one outcome the rig cannot reach by asking the bridge -- every bridge verb that removes an object also drops its row -- and it is what a player's sword does every time they kill an event creature. Verified on a real ServUO 57.4 world (206k items, 42k mobiles) against the release sidecar: all five verbs place; every ceiling refuses; a container and an unknown type refuse; one run cannot despawn another's object; the registry and its objects both survive a save and a clean restart (`pruned: 0`); a creature deleted behind the registry's back comes back `gone` rather than `removed`; and a five-second gate is collected by the shard's own deadline with `world.expired` on the wire. Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
1145 lines
44 KiB
C#
1145 lines
44 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Text;
|
|
|
|
using Server.Items;
|
|
using Server.Mobiles;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// Protocol 7. What an event OWNS: the five world verbs, the ownership registry that makes
|
|
/// them safe to undo, and the sweep that collects what has run out of time.
|
|
///
|
|
/// EVENTS_PLAN.md Phase 12a. Five verbs an author sees — creatures, a boss, an oracle, a
|
|
/// gate, decoration — and **one** command family on the wire, because every one of them
|
|
/// ends in the same sentence: an object exists, and this run owns it. The differences (a
|
|
/// boss's multipliers, an oracle's lines, a gate's destination and deadline) are fields.
|
|
/// One ledger shape, one teardown path, one reconcile, rather than five near-identical
|
|
/// ones in three repos.
|
|
///
|
|
/// ── Owning is the whole safety argument ────────────────────────────────────────────────
|
|
///
|
|
/// EVENTS.md §G excludes arbitrary `[add` and `[set` and then permits all of this, and the
|
|
/// distinction it draws is ownership: *an event may own what it creates and borrow what it
|
|
/// changes, and may never change something with no baseline recorded.* Everything in this
|
|
/// file is the first half. Nothing here touches an object it did not make — which is a
|
|
/// promise this file has to be able to KEEP, not merely intend, and that is what the
|
|
/// registry below is for.
|
|
///
|
|
/// ── Why the registry is persisted, which is forced rather than chosen ──────────────────
|
|
///
|
|
/// A spawned creature is a world object, so unlike a town-crier line it *survives* a
|
|
/// restart. That already rules out `reconcileByBootId` on the website's side: the boot
|
|
/// stamp would report gone the one class of resource that is still standing there.
|
|
///
|
|
/// But the record of *which run owns which serial* has nowhere else to live:
|
|
///
|
|
/// - In memory, it is lost in the restart the creatures themselves survive. The website
|
|
/// then holds serials the shard will not vouch for, and thirty orcs stand in Britain
|
|
/// until somebody deletes them by hand.
|
|
/// - Only in the website's ledger, it is not held on the shard at all — so `world.despawn`
|
|
/// would have to delete whatever serial it was handed, and "never touches a creature it
|
|
/// did not create" would be a sentence with no mechanism behind it. A bug in a step, or a
|
|
/// stolen sidecar token, would be a delete-anything primitive.
|
|
///
|
|
/// So the Bridge gains its second persisted file, beside `Participation.bin`. Same
|
|
/// mechanism, same reasoning, and the same rule about attaching the hooks: `Configure()`,
|
|
/// not `Initialize()`, because `EventSink.WorldLoad` fires inside `World.Load()`.
|
|
///
|
|
/// ── "Gone" is an ordinary outcome, not a failure ───────────────────────────────────────
|
|
///
|
|
/// Players kill event creatures. That is the point of spawning them. So a despawn that
|
|
/// finds its target already absent reports `gone` and counts as success, and the row leaves
|
|
/// the registry either way — §L's "gone, and that is fine". The failure this file reports
|
|
/// is a different one: a serial this run does not own, which comes back `refused` and is
|
|
/// the only answer here that means somebody asked for something they should not have.
|
|
/// </summary>
|
|
public static class BridgeWorld
|
|
{
|
|
private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Owned.bin");
|
|
|
|
private const int SaveVersion = 1;
|
|
|
|
/// <summary>The five things a run can own. Kept as strings: they cross the wire as text.</summary>
|
|
private const string WhatCreature = "creature";
|
|
private const string WhatBoss = "boss";
|
|
private const string WhatNpc = "npc";
|
|
private const string WhatGate = "gate";
|
|
private const string WhatDecor = "decor";
|
|
|
|
/// <summary>One object a run owns.</summary>
|
|
private sealed class Owned
|
|
{
|
|
public int Serial;
|
|
public string RunId;
|
|
public string What;
|
|
|
|
/// <summary>
|
|
/// The type name as placed, kept for the console and for the reply.
|
|
///
|
|
/// Read from the registry rather than from the object, so a row whose object has
|
|
/// since been deleted can still say what it was. "Something this run owned is
|
|
/// gone" is a less useful line in an audit than "the orc this run spawned is gone".
|
|
/// </summary>
|
|
public string Type;
|
|
|
|
public long CreatedMs;
|
|
|
|
/// <summary>When the shard collects this by itself. 0 for everything but a gate.</summary>
|
|
public long ExpiresMs;
|
|
}
|
|
|
|
private static readonly Dictionary<int, Owned> _owned = new Dictionary<int, Owned>();
|
|
|
|
/// <summary>runId to the serials it owns. Rebuilt from `_owned`, never persisted separately.</summary>
|
|
private static readonly Dictionary<string, HashSet<int>> _byRun =
|
|
new Dictionary<string, HashSet<int>>(StringComparer.Ordinal);
|
|
|
|
private static Timer _sweep;
|
|
|
|
private static long _placed, _removed, _expired, _refused, _pruned;
|
|
|
|
/// <summary>
|
|
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
|
|
/// fires, so this cannot be deferred to Initialize — the same rule
|
|
/// `BridgeParticipation.Configure` follows and for the same reason.
|
|
///
|
|
/// Attached unconditionally, ahead of the enabled gate: an operator who switches the
|
|
/// event plane off for an afternoon must not come back to a truncated registry and a
|
|
/// world full of creatures nothing admits to owning.
|
|
/// </summary>
|
|
[CallPriority(900)]
|
|
public static void Configure()
|
|
{
|
|
EventSink.WorldSave += OnWorldSave;
|
|
EventSink.WorldLoad += OnWorldLoad;
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
if (!BridgeConfig.Enabled)
|
|
return;
|
|
|
|
BridgeBoot.RegisterHandler("world.spawn", OnSpawn);
|
|
BridgeBoot.RegisterHandler("world.despawn", OnDespawn);
|
|
BridgeBoot.RegisterHandler("world.owned", OnOwned);
|
|
|
|
EventSink.ServerStarted += OnServerStarted;
|
|
}
|
|
|
|
private static void OnServerStarted()
|
|
{
|
|
Rearm();
|
|
}
|
|
|
|
/// <summary>Stops and recreates the sweep from current config. Called by `[bridge reload`.</summary>
|
|
public static void Rearm()
|
|
{
|
|
if (_sweep != null)
|
|
{
|
|
_sweep.Stop();
|
|
_sweep = null;
|
|
}
|
|
|
|
if (!BridgeConfig.Enabled)
|
|
return;
|
|
|
|
var period = TimeSpan.FromSeconds(BridgeConfig.EventsSweepSeconds);
|
|
_sweep = Timer.DelayCall(period, period, Sweep);
|
|
}
|
|
|
|
public static string Status()
|
|
{
|
|
return String.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"owned={0} runs={1} placed={2} removed={3} expired={4} refused={5} pruned={6}",
|
|
_owned.Count, _byRun.Count, _placed, _removed, _expired, _refused, _pruned);
|
|
}
|
|
|
|
// ── world.spawn ────────────────────────────────────────────────────────────────────────
|
|
|
|
private static void OnSpawn(Dictionary<string, object> o)
|
|
{
|
|
var reqId = BridgeJson.GetString(o, "reqId");
|
|
|
|
if (!Ready(reqId, "spawn"))
|
|
return;
|
|
|
|
var runId = BridgeJson.GetString(o, "runId");
|
|
|
|
if (String.IsNullOrEmpty(runId))
|
|
{
|
|
Err(reqId, "spawn", "a run id is required");
|
|
return;
|
|
}
|
|
|
|
var what = (BridgeJson.GetString(o, "what") ?? "").Trim().ToLowerInvariant();
|
|
|
|
if (what != WhatCreature && what != WhatBoss && what != WhatNpc &&
|
|
what != WhatGate && what != WhatDecor)
|
|
{
|
|
Err(reqId, "spawn", "unknown kind of thing to place: '" + what + "'");
|
|
return;
|
|
}
|
|
|
|
var mapName = BridgeJson.GetString(o, "map");
|
|
var map = MapByName(mapName);
|
|
|
|
if (map == null || map == Map.Internal)
|
|
{
|
|
Err(reqId, "spawn", "unknown map '" + (mapName ?? "") + "'");
|
|
return;
|
|
}
|
|
|
|
var x = BridgeJson.GetInt(o, "x", -1);
|
|
var y = BridgeJson.GetInt(o, "y", -1);
|
|
|
|
if (x < 0 || y < 0)
|
|
{
|
|
Err(reqId, "spawn", "a place needs an x and a y");
|
|
return;
|
|
}
|
|
|
|
var z = BridgeJson.GetInt(o, "z", map.GetAverageZ(x, y));
|
|
var anchor = new Point3D(x, y, z);
|
|
|
|
var count = BridgeJson.GetInt(o, "count", 1);
|
|
var ceiling = CeilingFor(what);
|
|
|
|
if (count < 1 || count > ceiling)
|
|
{
|
|
Refuse(reqId, "spawn",
|
|
String.Format(CultureInfo.InvariantCulture,
|
|
"this shard places 1 to {0} of '{1}' at a time, and {2} was asked for",
|
|
ceiling, what, count));
|
|
return;
|
|
}
|
|
|
|
// The per-verb ceiling above bounds one CALL. This bounds a run that calls a verb in a
|
|
// loop, which is the shape a runaway schedule actually takes.
|
|
var already = OwnedCount(runId);
|
|
|
|
if (already + count > BridgeConfig.EventsMaxOwnedPerRun)
|
|
{
|
|
Refuse(reqId, "spawn",
|
|
String.Format(CultureInfo.InvariantCulture,
|
|
"run {0} already owns {1} things and this shard allows {2} per run",
|
|
runId, already, BridgeConfig.EventsMaxOwnedPerRun));
|
|
return;
|
|
}
|
|
|
|
var spread = BridgeJson.GetInt(o, "spread", 0);
|
|
|
|
if (spread < 0 || spread > BridgeConfig.EventsMaxSpread)
|
|
{
|
|
Refuse(reqId, "spawn",
|
|
String.Format(CultureInfo.InvariantCulture,
|
|
"things are scattered up to {0} tiles, and {1} was asked for",
|
|
BridgeConfig.EventsMaxSpread, spread));
|
|
return;
|
|
}
|
|
|
|
string error;
|
|
var serials = new List<int>(count);
|
|
var typeName = BridgeJson.GetString(o, "type");
|
|
|
|
switch (what)
|
|
{
|
|
case WhatGate:
|
|
error = PlaceGate(o, runId, map, anchor, serials);
|
|
break;
|
|
|
|
case WhatNpc:
|
|
error = PlaceNpcs(o, runId, map, anchor, spread, count, serials);
|
|
break;
|
|
|
|
case WhatDecor:
|
|
error = PlaceDecor(o, runId, typeName, map, anchor, spread, count, serials);
|
|
break;
|
|
|
|
default:
|
|
error = PlaceCreatures(o, runId, what, typeName, map, anchor, spread, count, serials);
|
|
break;
|
|
}
|
|
|
|
if (error != null)
|
|
{
|
|
// Whatever DID get placed before the failure is already registered, so the run owns
|
|
// it and teardown will collect it. Rolling back here would be a second delete path
|
|
// that only ever runs on the unhappy branch, which is the path least likely to be
|
|
// right; leaving the rows is the same answer §L gives everywhere else.
|
|
Refuse(reqId, "spawn", error);
|
|
return;
|
|
}
|
|
|
|
_placed += serials.Count;
|
|
|
|
var sb = BridgeJson.Begin("world.ok");
|
|
if (reqId != null) sb.Str("reqId", reqId);
|
|
|
|
sb.Str("action", "spawn")
|
|
.Str("runId", runId)
|
|
.Str("what", what)
|
|
.Num("placed", serials.Count);
|
|
|
|
AppendSerials(sb, "serials", serials);
|
|
sb.Num("owned", OwnedCount(runId));
|
|
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
|
|
private static int CeilingFor(string what)
|
|
{
|
|
switch (what)
|
|
{
|
|
case WhatBoss: return BridgeConfig.EventsMaxBosses;
|
|
case WhatNpc: return BridgeConfig.EventsMaxNpcs;
|
|
case WhatDecor: return BridgeConfig.EventsMaxDecor;
|
|
case WhatGate: return 1;
|
|
default: return BridgeConfig.EventsMaxCreatures;
|
|
}
|
|
}
|
|
|
|
// ── the five placements ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Creatures and bosses, which differ only in what is done to the thing after it is
|
|
/// constructed. A boss is EVENTS.md §G's "enhanced regular mob": the same allowlisted
|
|
/// type, made harder, and named. Not a new species and not a stock boss touched in
|
|
/// place — an event-owned template it created and will delete.
|
|
/// </summary>
|
|
private static string PlaceCreatures(
|
|
Dictionary<string, object> o, string runId, string what, string typeName,
|
|
Map map, Point3D anchor, int spread, int count, List<int> serials)
|
|
{
|
|
var type = ResolveType(typeName);
|
|
|
|
if (type == null)
|
|
return "this shard has no type called '" + (typeName ?? "") + "'";
|
|
|
|
if (!typeof(BaseCreature).IsAssignableFrom(type))
|
|
return "'" + typeName + "' is not a creature";
|
|
|
|
double hitsMul = 1.0, damageMul = 1.0, statMul = 1.0;
|
|
|
|
if (what == WhatBoss)
|
|
{
|
|
hitsMul = BridgeJson.GetDouble(o, "hitsMultiplier", 1.0);
|
|
damageMul = BridgeJson.GetDouble(o, "damageMultiplier", 1.0);
|
|
statMul = BridgeJson.GetDouble(o, "statMultiplier", 1.0);
|
|
|
|
var max = BridgeConfig.EventsMaxBossMultiplier;
|
|
|
|
if (!Sane(hitsMul, max) || !Sane(damageMul, max) || !Sane(statMul, max))
|
|
return String.Format(CultureInfo.InvariantCulture,
|
|
"a boss is made 1 to {0} times a normal one, and that is outside it", max);
|
|
}
|
|
|
|
var name = BridgeJson.GetString(o, "name");
|
|
var hue = BridgeJson.GetInt(o, "hue", 0);
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
BaseCreature creature;
|
|
|
|
try
|
|
{
|
|
creature = Activator.CreateInstance(type) as BaseCreature;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
// A constructible-looking type whose parameterless constructor throws is an
|
|
// ordinary fact about a shard's own scripts, not a bug here. Name it.
|
|
return "'" + typeName + "' could not be created: " + e.GetBaseException().Message;
|
|
}
|
|
|
|
if (creature == null)
|
|
return "'" + typeName + "' could not be created";
|
|
|
|
if (!String.IsNullOrEmpty(name))
|
|
creature.Name = name;
|
|
|
|
if (hue > 0)
|
|
creature.Hue = hue;
|
|
|
|
if (what == WhatBoss)
|
|
Enhance(creature, hitsMul, damageMul, statMul);
|
|
|
|
creature.MoveToWorld(Scatter(map, anchor, spread), map);
|
|
Register(runId, what, creature.Serial.Value, type.Name, 0L);
|
|
serials.Add(creature.Serial.Value);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Multiply a creature's stats without rebuilding it.
|
|
///
|
|
/// `HitsMaxSeed` rather than `HitsMax`, because `HitsMax` is computed from `Str` on a
|
|
/// mobile with no seed set and assigning it would not stick. Setting `Hits` afterwards
|
|
/// is what makes the creature arrive at full health rather than at its old maximum.
|
|
/// </summary>
|
|
private static void Enhance(BaseCreature creature, double hits, double damage, double stat)
|
|
{
|
|
if (hits > 1.0)
|
|
{
|
|
var seed = creature.HitsMaxSeed > 0 ? creature.HitsMaxSeed : creature.HitsMax;
|
|
creature.HitsMaxSeed = Scale(seed, hits);
|
|
}
|
|
|
|
if (stat > 1.0)
|
|
{
|
|
creature.RawStr = Scale(creature.RawStr, stat);
|
|
creature.RawDex = Scale(creature.RawDex, stat);
|
|
creature.RawInt = Scale(creature.RawInt, stat);
|
|
}
|
|
|
|
if (damage > 1.0)
|
|
creature.SetDamage(Scale(creature.DamageMin, damage), Scale(creature.DamageMax, damage));
|
|
|
|
creature.Hits = creature.HitsMax;
|
|
creature.Mana = creature.ManaMax;
|
|
creature.Stam = creature.StamMax;
|
|
}
|
|
|
|
private static int Scale(int value, double by)
|
|
{
|
|
var scaled = value * by;
|
|
|
|
// Short of overflowing a stat into a negative number, which is a creature that dies to
|
|
// a stiff breeze after an author typed one zero too many.
|
|
return scaled >= Int32.MaxValue ? Int32.MaxValue : (int)Math.Round(scaled);
|
|
}
|
|
|
|
private static bool Sane(double value, double max)
|
|
{
|
|
return !Double.IsNaN(value) && !Double.IsInfinity(value) && value >= 1.0 && value <= max;
|
|
}
|
|
|
|
/// <summary>An oracle: a greeting, and up to `EventsOracleMaxLines` keyword rows.</summary>
|
|
private static string PlaceNpcs(
|
|
Dictionary<string, object> o, string runId, Map map, Point3D anchor, int spread,
|
|
int count, List<int> serials)
|
|
{
|
|
var name = BridgeJson.GetString(o, "name");
|
|
var title = BridgeJson.GetString(o, "title");
|
|
var hue = BridgeJson.GetInt(o, "hue", 0);
|
|
var female = String.Equals(BridgeJson.GetString(o, "sex"), "female",
|
|
StringComparison.OrdinalIgnoreCase);
|
|
var greeting = BridgeJson.GetString(o, "greeting");
|
|
|
|
var rows = BridgeJson.GetObjectList(o, "lines");
|
|
|
|
if (rows.Count > BridgeConfig.EventsOracleMaxLines)
|
|
return String.Format(CultureInfo.InvariantCulture,
|
|
"an oracle answers to at most {0} things, and {1} were given",
|
|
BridgeConfig.EventsOracleMaxLines, rows.Count);
|
|
|
|
var lines = new List<BridgeOracle.Line>(rows.Count);
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
var text = BridgeJson.GetString(row, "text");
|
|
var keywords = Keywords(BridgeJson.GetString(row, "keywords"));
|
|
|
|
if (String.IsNullOrEmpty(text) || keywords.Length == 0)
|
|
return "every line an oracle answers with needs both a keyword and something to say";
|
|
|
|
lines.Add(new BridgeOracle.Line { Keywords = keywords, Text = text });
|
|
}
|
|
|
|
if (String.IsNullOrEmpty(greeting) && lines.Count == 0)
|
|
return "an oracle with no greeting and no lines would stand there in silence";
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
var npc = new BridgeOracle(name, title, hue, female)
|
|
{
|
|
Greeting = greeting,
|
|
Lines = new List<BridgeOracle.Line>(lines),
|
|
};
|
|
|
|
npc.MoveToWorld(Scatter(map, anchor, spread), map);
|
|
Register(runId, WhatNpc, npc.Serial.Value, "BridgeOracle", 0L);
|
|
serials.Add(npc.Serial.Value);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lower-case, de-duplicated, empties dropped. Lower-cased HERE rather than at match
|
|
/// time so that the comparison in `BridgeOracle.Match` is an ordinal one on two strings
|
|
/// that are already in the same case — culture-aware casing at match time would make an
|
|
/// oracle answer differently on a Turkish shard.
|
|
/// </summary>
|
|
private static string[] Keywords(string raw)
|
|
{
|
|
if (String.IsNullOrEmpty(raw))
|
|
return new string[0];
|
|
|
|
var parts = raw.Split(',');
|
|
var seen = new List<string>(parts.Length);
|
|
|
|
foreach (var part in parts)
|
|
{
|
|
var word = part.Trim().ToLower(CultureInfo.InvariantCulture);
|
|
|
|
if (word.Length > 0 && !seen.Contains(word))
|
|
seen.Add(word);
|
|
}
|
|
|
|
return seen.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A temporary gate. One per call, and it is the only verb here that carries a deadline
|
|
/// the shard enforces on its own.
|
|
///
|
|
/// The deadline is a duration (`holdMs`), never an absolute time, for the reason
|
|
/// `v6.md` §8.2 gives about leases: an absolute deadline is measured against two clocks,
|
|
/// and a shard ten minutes fast would collect a ten-minute gate the moment it opened.
|
|
/// </summary>
|
|
private static string PlaceGate(
|
|
Dictionary<string, object> o, string runId, Map map, Point3D anchor, List<int> serials)
|
|
{
|
|
object raw;
|
|
|
|
if (!o.TryGetValue("target", out raw))
|
|
return "a gate needs somewhere to go";
|
|
|
|
var target = raw as Dictionary<string, object>;
|
|
|
|
if (target == null)
|
|
return "a gate's destination must be a map, an x and a y";
|
|
|
|
var targetMapName = BridgeJson.GetString(target, "map");
|
|
var targetMap = MapByName(targetMapName);
|
|
|
|
if (targetMap == null || targetMap == Map.Internal)
|
|
return "unknown destination map '" + (targetMapName ?? "") + "'";
|
|
|
|
var tx = BridgeJson.GetInt(target, "x", -1);
|
|
var ty = BridgeJson.GetInt(target, "y", -1);
|
|
|
|
if (tx < 0 || ty < 0)
|
|
return "a gate's destination needs an x and a y";
|
|
|
|
var tz = BridgeJson.GetInt(target, "z", targetMap.GetAverageZ(tx, ty));
|
|
|
|
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
|
|
var maxMs = (long)BridgeConfig.EventsMaxGateMinutes * 60000L;
|
|
|
|
if (holdMs < 1L || holdMs > maxMs)
|
|
return String.Format(CultureInfo.InvariantCulture,
|
|
"a gate stands for 1 millisecond to {0} minutes, and {1} was asked for",
|
|
BridgeConfig.EventsMaxGateMinutes, holdMs);
|
|
|
|
var gate = new Moongate(new Point3D(tx, ty, tz), targetMap)
|
|
{
|
|
// The event owns it and the event's teardown removes it. A dispellable gate is one
|
|
// a passing mage can end early, which would take the venue's only way in out from
|
|
// under everyone standing at it.
|
|
Dispellable = false,
|
|
};
|
|
|
|
var name = BridgeJson.GetString(o, "name");
|
|
|
|
if (!String.IsNullOrEmpty(name))
|
|
gate.Name = name;
|
|
|
|
var hue = BridgeJson.GetInt(o, "hue", 0);
|
|
|
|
if (hue > 0)
|
|
gate.Hue = hue;
|
|
|
|
gate.MoveToWorld(anchor, map);
|
|
Register(runId, WhatGate, gate.Serial.Value, "Moongate", BridgeJson.NowMs() + holdMs);
|
|
serials.Add(gate.Serial.Value);
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Event decoration, from the shard's own decoration vocabulary.
|
|
///
|
|
/// The website's dropdown is built from `Data/Decoration/**/*.cfg`, so what an
|
|
/// author can pick is what this shard already calls decoration. This end validates
|
|
/// independently anyway — it cannot trust the website, and the two ends read the tree at
|
|
/// different times.
|
|
///
|
|
/// Placed immovable, which is what "lockdown" means here: decoration a player can pick
|
|
/// up is decoration that is not where the ledger says it is by the time teardown runs.
|
|
/// </summary>
|
|
private static string PlaceDecor(
|
|
Dictionary<string, object> o, string runId, string typeName, Map map, Point3D anchor,
|
|
int spread, int count, List<int> serials)
|
|
{
|
|
var type = ResolveType(typeName);
|
|
|
|
if (type == null)
|
|
return "this shard has no type called '" + (typeName ?? "") + "'";
|
|
|
|
if (!typeof(Item).IsAssignableFrom(type))
|
|
return "'" + typeName + "' is not an item";
|
|
|
|
// A container placed as decoration is a container players will put things in, and
|
|
// deleting it at teardown would delete what they put there. Everything else in this
|
|
// file is safe to delete because the event made it; a container's contents are not.
|
|
if (typeof(Container).IsAssignableFrom(type))
|
|
return "'" + typeName + "' is a container, and teardown would delete whatever was left in it";
|
|
|
|
var hue = BridgeJson.GetInt(o, "hue", 0);
|
|
var name = BridgeJson.GetString(o, "name");
|
|
|
|
// **The graphic, for the classes whose identity IS the graphic.**
|
|
//
|
|
// Measured on 57.4: `Static` accounts for 5031 of the tree's decoration
|
|
// placements under 1992 different item ids, because a `Static` is not a thing, it is
|
|
// a picture. A bare `new Static()` is whatever the class defaults to and never what
|
|
// the author chose, so the website sends the id the shard's own decoration files use
|
|
// for that type and this applies it.
|
|
//
|
|
// **Never to an addon.** A `BaseAddon` is a group of components and its own `ItemID`
|
|
// is not what a player sees; writing a graphic over it would leave a stone oven that
|
|
// renders as one arbitrary tile of itself. Addons already construct with the right
|
|
// appearance, so there is nothing to fix and everything to break.
|
|
var itemId = BridgeJson.GetInt(o, "itemId", 0);
|
|
var isAddon = typeof(Server.Items.BaseAddon).IsAssignableFrom(type);
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
Item item;
|
|
|
|
try
|
|
{
|
|
item = Activator.CreateInstance(type) as Item;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return "'" + typeName + "' could not be created: " + e.GetBaseException().Message;
|
|
}
|
|
|
|
if (item == null)
|
|
return "'" + typeName + "' could not be created";
|
|
|
|
if (itemId > 0 && !isAddon)
|
|
item.ItemID = itemId;
|
|
|
|
if (hue > 0)
|
|
item.Hue = hue;
|
|
|
|
if (!String.IsNullOrEmpty(name))
|
|
item.Name = name;
|
|
|
|
item.Movable = false;
|
|
|
|
item.MoveToWorld(Scatter(map, anchor, spread), map);
|
|
Register(runId, WhatDecor, item.Serial.Value, type.Name, 0L);
|
|
serials.Add(item.Serial.Value);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── world.despawn ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Give back what a run owns. With no `serials`, everything it owns — which is the call
|
|
/// teardown actually makes.
|
|
///
|
|
/// Three answers, and the split is the point of the whole registry:
|
|
///
|
|
/// - `removed`: owned by this run, found, deleted.
|
|
/// - `gone`: owned by this run and already absent. A player killed it, which is what
|
|
/// event creatures are for. Success, and the row leaves.
|
|
/// - `refused`: NOT owned by this run. The row stays, nothing is touched, and this is
|
|
/// the only answer here that means somebody asked for something they should not have.
|
|
/// </summary>
|
|
private static void OnDespawn(Dictionary<string, object> o)
|
|
{
|
|
var reqId = BridgeJson.GetString(o, "reqId");
|
|
|
|
if (!Ready(reqId, "despawn"))
|
|
return;
|
|
|
|
var runId = BridgeJson.GetString(o, "runId");
|
|
|
|
if (String.IsNullOrEmpty(runId))
|
|
{
|
|
Err(reqId, "despawn", "a run id is required");
|
|
return;
|
|
}
|
|
|
|
var asked = BridgeJson.GetStringList(o, "serials");
|
|
List<int> wanted;
|
|
|
|
if (asked.Count == 0)
|
|
{
|
|
wanted = new List<int>(SerialsOf(runId));
|
|
}
|
|
else
|
|
{
|
|
wanted = new List<int>(asked.Count);
|
|
|
|
foreach (var text in asked)
|
|
{
|
|
int serial;
|
|
|
|
// An unparseable serial is not silently skipped: it becomes a refusal below,
|
|
// because a teardown that quietly ignored a malformed ref would report a clean
|
|
// sweep while leaving the thing standing.
|
|
wanted.Add(TryParseSerial(text, out serial) ? serial : -1);
|
|
}
|
|
}
|
|
|
|
var removed = new List<int>();
|
|
var gone = new List<int>();
|
|
var refused = new List<int>();
|
|
|
|
foreach (var serial in wanted)
|
|
{
|
|
Owned row;
|
|
|
|
if (serial < 0 || !_owned.TryGetValue(serial, out row) ||
|
|
!String.Equals(row.RunId, runId, StringComparison.Ordinal))
|
|
{
|
|
refused.Add(serial);
|
|
_refused++;
|
|
continue;
|
|
}
|
|
|
|
if (Collect(row))
|
|
removed.Add(serial);
|
|
else
|
|
gone.Add(serial);
|
|
}
|
|
|
|
_removed += removed.Count;
|
|
|
|
var sb = BridgeJson.Begin("world.ok");
|
|
if (reqId != null) sb.Str("reqId", reqId);
|
|
|
|
sb.Str("action", "despawn").Str("runId", runId);
|
|
|
|
AppendSerials(sb, "removed", removed);
|
|
AppendSerials(sb, "gone", gone);
|
|
AppendSerials(sb, "refused", refused);
|
|
|
|
sb.Num("owned", OwnedCount(runId));
|
|
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
|
|
// ── world.owned ────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// What a run still owns, pruning as it walks.
|
|
///
|
|
/// This is what the website's `reconcile()` asks, and the prune is what keeps the
|
|
/// registry from growing for the life of the save: a row whose object is gone is a row
|
|
/// about nothing. Anything not listed is gone, which is the answer core wants — it
|
|
/// takes rows OUT of its ledger only on an explicit reply, and this is that reply.
|
|
/// </summary>
|
|
private static void OnOwned(Dictionary<string, object> o)
|
|
{
|
|
var reqId = BridgeJson.GetString(o, "reqId");
|
|
|
|
if (!Ready(reqId, "owned"))
|
|
return;
|
|
|
|
var runId = BridgeJson.GetString(o, "runId");
|
|
|
|
if (String.IsNullOrEmpty(runId))
|
|
{
|
|
Err(reqId, "owned", "a run id is required");
|
|
return;
|
|
}
|
|
|
|
var live = new List<Owned>();
|
|
var pruned = 0;
|
|
|
|
foreach (var serial in new List<int>(SerialsOf(runId)))
|
|
{
|
|
Owned row;
|
|
|
|
if (!_owned.TryGetValue(serial, out row))
|
|
continue;
|
|
|
|
if (Find(serial) == null)
|
|
{
|
|
Forget(row);
|
|
pruned++;
|
|
continue;
|
|
}
|
|
|
|
live.Add(row);
|
|
}
|
|
|
|
_pruned += pruned;
|
|
|
|
var sb = BridgeJson.Begin("world.owned.ok");
|
|
if (reqId != null) sb.Str("reqId", reqId);
|
|
|
|
sb.Str("runId", runId);
|
|
sb.Append(",\"owned\":[");
|
|
|
|
for (int i = 0; i < live.Count; i++)
|
|
{
|
|
var row = live[i];
|
|
|
|
if (i > 0)
|
|
sb.Append(',');
|
|
|
|
sb.Append("{\"serial\":").Append(SerialText(row.Serial));
|
|
sb.Append(",\"what\":");
|
|
BridgeJson.Text(sb, row.What);
|
|
sb.Append(",\"type\":");
|
|
BridgeJson.Text(sb, row.Type);
|
|
sb.Append(",\"createdMs\":").Append(row.CreatedMs);
|
|
sb.Append(",\"untilMs\":").Append(row.ExpiresMs);
|
|
sb.Append('}');
|
|
}
|
|
|
|
sb.Append(']');
|
|
sb.Num("pruned", pruned);
|
|
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
|
|
// ── the sweep ──────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Collect what has run out of time, and prune what the world has already lost.
|
|
///
|
|
/// The deadline lives here rather than on the website for `v6.md` §8.3's reason: a gate
|
|
/// the shard closes by itself closes whether or not the website is ever heard from
|
|
/// again. A run whose engine died leaves a world that comes back early, not one stuck
|
|
/// open indefinitely.
|
|
/// </summary>
|
|
private static void Sweep()
|
|
{
|
|
if (_owned.Count == 0)
|
|
return;
|
|
|
|
var now = BridgeJson.NowMs();
|
|
List<Owned> due = null;
|
|
|
|
foreach (var row in _owned.Values)
|
|
{
|
|
if (row.ExpiresMs > 0L && row.ExpiresMs <= now)
|
|
(due ?? (due = new List<Owned>())).Add(row);
|
|
else if (Find(row.Serial) == null)
|
|
(due ?? (due = new List<Owned>())).Add(row);
|
|
}
|
|
|
|
if (due == null)
|
|
return;
|
|
|
|
foreach (var row in due)
|
|
{
|
|
var existed = Collect(row);
|
|
|
|
if (existed && row.ExpiresMs > 0L)
|
|
{
|
|
_expired++;
|
|
|
|
// Told, not asked. The website learns its gate closed without polling for it,
|
|
// exactly as `lease.expired` reports a lease the shard restored on its own.
|
|
var sb = BridgeJson.Begin("world.expired");
|
|
sb.Str("runId", row.RunId)
|
|
.Str("what", row.What)
|
|
.Str("type", row.Type);
|
|
sb.Append(",\"serial\":").Append(SerialText(row.Serial));
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
else if (!existed)
|
|
{
|
|
_pruned++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── the registry ───────────────────────────────────────────────────────────────────────
|
|
|
|
private static void Register(string runId, string what, int serial, string type, long expiresMs)
|
|
{
|
|
var row = new Owned
|
|
{
|
|
Serial = serial,
|
|
RunId = runId,
|
|
What = what,
|
|
Type = type,
|
|
CreatedMs = BridgeJson.NowMs(),
|
|
ExpiresMs = expiresMs,
|
|
};
|
|
|
|
_owned[serial] = row;
|
|
Index(row);
|
|
}
|
|
|
|
private static void Index(Owned row)
|
|
{
|
|
HashSet<int> set;
|
|
|
|
if (!_byRun.TryGetValue(row.RunId, out set))
|
|
{
|
|
set = new HashSet<int>();
|
|
_byRun[row.RunId] = set;
|
|
}
|
|
|
|
set.Add(row.Serial);
|
|
}
|
|
|
|
private static void Forget(Owned row)
|
|
{
|
|
_owned.Remove(row.Serial);
|
|
|
|
HashSet<int> set;
|
|
|
|
if (_byRun.TryGetValue(row.RunId, out set))
|
|
{
|
|
set.Remove(row.Serial);
|
|
|
|
if (set.Count == 0)
|
|
_byRun.Remove(row.RunId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Delete the object if it is still there, and drop the row either way.</summary>
|
|
private static bool Collect(Owned row)
|
|
{
|
|
var entity = Find(row.Serial);
|
|
|
|
Forget(row);
|
|
|
|
if (entity == null)
|
|
return false;
|
|
|
|
entity.Delete();
|
|
return true;
|
|
}
|
|
|
|
private static IEntity Find(int serial)
|
|
{
|
|
var entity = World.FindEntity((Serial)serial);
|
|
|
|
// A deleted entity can still be handed back for a tick. Treating it as present would
|
|
// make teardown call Delete() on it and report `removed` for something already gone.
|
|
return entity == null || entity.Deleted ? null : entity;
|
|
}
|
|
|
|
private static IEnumerable<int> SerialsOf(string runId)
|
|
{
|
|
HashSet<int> set;
|
|
return _byRun.TryGetValue(runId, out set) ? (IEnumerable<int>)set : new int[0];
|
|
}
|
|
|
|
private static int OwnedCount(string runId)
|
|
{
|
|
HashSet<int> set;
|
|
return _byRun.TryGetValue(runId, out set) ? set.Count : 0;
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A point within `spread` tiles of the anchor that something can stand on.
|
|
///
|
|
/// Falls back to the anchor rather than refusing: an author who picked a spot in a
|
|
/// crowded room should get their creatures, stacked, rather than an error about tile
|
|
/// heights they cannot act on from a web form.
|
|
/// </summary>
|
|
private static Point3D Scatter(Map map, Point3D anchor, int spread)
|
|
{
|
|
if (spread <= 0)
|
|
return anchor;
|
|
|
|
for (int i = 0; i < 12; i++)
|
|
{
|
|
var x = anchor.X + Utility.RandomMinMax(-spread, spread);
|
|
var y = anchor.Y + Utility.RandomMinMax(-spread, spread);
|
|
var z = map.GetAverageZ(x, y);
|
|
|
|
if (map.CanFit(x, y, z, 16, false, false))
|
|
return new Point3D(x, y, z);
|
|
}
|
|
|
|
return anchor;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve a type name the website sent.
|
|
///
|
|
/// Case-insensitive, because the atlas and the decoration files disagree about casing
|
|
/// for the same type often enough that a case-sensitive lookup would reject types the
|
|
/// shard demonstrably has. The caller still checks what the type IS — this only says
|
|
/// the shard knows the name.
|
|
/// </summary>
|
|
private static Type ResolveType(string name)
|
|
{
|
|
if (String.IsNullOrEmpty(name))
|
|
return null;
|
|
|
|
var trimmed = name.Trim();
|
|
|
|
return trimmed.Length == 0 ? null : ScriptCompiler.FindTypeByName(trimmed, true);
|
|
}
|
|
|
|
private static string SerialText(int serial)
|
|
{
|
|
return "\"0x" + ((uint)serial).ToString("X") + "\"";
|
|
}
|
|
|
|
private static bool TryParseSerial(string text, out int serial)
|
|
{
|
|
serial = 0;
|
|
|
|
if (String.IsNullOrEmpty(text))
|
|
return false;
|
|
|
|
var trimmed = text.Trim();
|
|
uint parsed;
|
|
|
|
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (!UInt32.TryParse(trimmed.Substring(2), NumberStyles.HexNumber,
|
|
CultureInfo.InvariantCulture, out parsed))
|
|
return false;
|
|
}
|
|
else if (!UInt32.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
serial = unchecked((int)parsed);
|
|
return true;
|
|
}
|
|
|
|
private static void AppendSerials(StringBuilder sb, string name, List<int> serials)
|
|
{
|
|
sb.Append(",\"").Append(name).Append("\":[");
|
|
|
|
for (int i = 0; i < serials.Count; i++)
|
|
{
|
|
if (i > 0)
|
|
sb.Append(',');
|
|
|
|
sb.Append(SerialText(serials[i]));
|
|
}
|
|
|
|
sb.Append(']');
|
|
}
|
|
|
|
private static Map MapByName(string name)
|
|
{
|
|
if (String.IsNullOrEmpty(name))
|
|
return null;
|
|
|
|
for (int i = 0; i < Map.Maps.Length; i++)
|
|
{
|
|
var map = Map.Maps[i];
|
|
|
|
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
|
|
return map;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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)
|
|
{
|
|
var sb = BridgeJson.Begin("world.error");
|
|
|
|
if (reqId != null)
|
|
sb.Str("reqId", reqId);
|
|
|
|
sb.Str("action", action).Str("reason", reason);
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
|
|
/// <summary>
|
|
/// A refusal a ceiling produced, as distinct from a malformed request.
|
|
///
|
|
/// Same kind on the wire, and deliberately so: both are a 400 to the caller and both
|
|
/// are permanent, so a module that retried one would retry the other. The counter is
|
|
/// the difference, because "this shard refused 200 placements last week" is the line
|
|
/// that tells an operator their ceilings are set wrong.
|
|
/// </summary>
|
|
private static void Refuse(string reqId, string action, string reason)
|
|
{
|
|
_refused++;
|
|
Err(reqId, action, reason);
|
|
}
|
|
|
|
// ── persistence ────────────────────────────────────────────────────────────────────────
|
|
|
|
private static void OnWorldSave(WorldSaveEventArgs e)
|
|
{
|
|
Persistence.Serialize(
|
|
SavePath,
|
|
writer =>
|
|
{
|
|
writer.Write(SaveVersion);
|
|
writer.Write(_owned.Count);
|
|
|
|
foreach (var row in _owned.Values)
|
|
{
|
|
writer.Write(row.Serial);
|
|
writer.Write(row.RunId ?? "");
|
|
writer.Write(row.What ?? "");
|
|
writer.Write(row.Type ?? "");
|
|
writer.Write(row.CreatedMs);
|
|
writer.Write(row.ExpiresMs);
|
|
}
|
|
});
|
|
}
|
|
|
|
private static void OnWorldLoad()
|
|
{
|
|
Persistence.Deserialize(
|
|
SavePath,
|
|
reader =>
|
|
{
|
|
var version = reader.ReadInt();
|
|
|
|
if (version < 1)
|
|
return;
|
|
|
|
var count = reader.ReadInt();
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
var row = new Owned
|
|
{
|
|
Serial = reader.ReadInt(),
|
|
RunId = reader.ReadString(),
|
|
What = reader.ReadString(),
|
|
Type = reader.ReadString(),
|
|
CreatedMs = reader.ReadLong(),
|
|
ExpiresMs = reader.ReadLong(),
|
|
};
|
|
|
|
_owned[row.Serial] = row;
|
|
Index(row);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|