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
586 lines
22 KiB
C#
586 lines
22 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Text;
|
||
using System.Web.Script.Serialization;
|
||
|
||
namespace Server.Custom.Bridge
|
||
{
|
||
/// <summary>
|
||
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
|
||
/// every emitted event, and the measured budget in https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md assumes this cost, not a
|
||
/// reflection serializer's.
|
||
///
|
||
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
||
/// correctness beats speed there, and parsing happens on the reader thread anyway.
|
||
/// </summary>
|
||
public static class BridgeJson
|
||
{
|
||
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||
|
||
[ThreadStatic]
|
||
private static JavaScriptSerializer _parser;
|
||
|
||
public static long NowMs()
|
||
{
|
||
return (long)(DateTime.UtcNow - Epoch).TotalMilliseconds;
|
||
}
|
||
|
||
// ---- outbound ----
|
||
|
||
/// <summary>Opens an object and writes the `t` and `kind` fields.</summary>
|
||
public static StringBuilder Begin(string kind)
|
||
{
|
||
var sb = new StringBuilder(256);
|
||
sb.Append("{\"t\":").Append(NowMs());
|
||
sb.Append(",\"kind\":\"").Append(kind).Append('"');
|
||
return sb;
|
||
}
|
||
|
||
public static StringBuilder Str(this StringBuilder sb, string name, string value)
|
||
{
|
||
sb.Append(",\"").Append(name).Append("\":");
|
||
|
||
if (value == null)
|
||
sb.Append("null");
|
||
else
|
||
Escape(sb, value);
|
||
|
||
return sb;
|
||
}
|
||
|
||
public static StringBuilder Num(this StringBuilder sb, string name, long value)
|
||
{
|
||
sb.Append(",\"").Append(name).Append("\":").Append(value);
|
||
return sb;
|
||
}
|
||
|
||
public static StringBuilder Num(this StringBuilder sb, string name, double value)
|
||
{
|
||
sb.Append(",\"").Append(name).Append("\":")
|
||
.Append(value.ToString("R", CultureInfo.InvariantCulture));
|
||
return sb;
|
||
}
|
||
|
||
public static StringBuilder Bool(this StringBuilder sb, string name, bool value)
|
||
{
|
||
sb.Append(",\"").Append(name).Append("\":").Append(value ? "true" : "false");
|
||
return sb;
|
||
}
|
||
|
||
/// <summary>Serial as the canonical "0x1A2B" string the sidecar keys on.</summary>
|
||
public static StringBuilder Ser(this StringBuilder sb, string name, Serial serial)
|
||
{
|
||
sb.Append(",\"").Append(name).Append("\":\"0x")
|
||
.Append(serial.Value.ToString("X")).Append('"');
|
||
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("\":");
|
||
WriteActor(sb, m);
|
||
return sb;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes a named array of actor objects — a guild roster (Protocol 4) being the first
|
||
/// caller. Every other outbound helper here emits a leading `,"name":`, so an array
|
||
/// element needs the bare object; that is why <see cref="WriteActor"/> exists separately
|
||
/// rather than <see cref="Actor"/> being reused.
|
||
///
|
||
/// `count` bounds how many are written, because a roster frame must stay a bounded line
|
||
/// (Bridge.GuildRosterMembersPerLine). A null entry in the sequence is skipped rather
|
||
/// than written as null, so the array is always a list of real members and a caller can
|
||
/// trust its length.
|
||
///
|
||
/// `withGuildRank` adds each member's guild rank to their object. It is a parameter
|
||
/// rather than always-on because rank is a property of a mobile's membership of THIS
|
||
/// guild, not of the mobile — every other actor this bridge writes is a bystander,
|
||
/// a killer, a governor, and guild rank is meaningless on all of them.
|
||
/// </summary>
|
||
public static StringBuilder Actors(
|
||
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count,
|
||
bool withGuildRank = false)
|
||
{
|
||
sb.Append(",\"").Append(name).Append("\":[");
|
||
|
||
if (mobiles != null)
|
||
{
|
||
var end = Math.Min(start + count, mobiles.Count);
|
||
bool first = true;
|
||
|
||
for (int i = start; i < end; i++)
|
||
{
|
||
var m = mobiles[i];
|
||
|
||
if (m == null)
|
||
continue;
|
||
|
||
if (!first)
|
||
sb.Append(',');
|
||
|
||
if (withGuildRank)
|
||
WriteGuildMember(sb, m);
|
||
else
|
||
WriteActor(sb, m);
|
||
|
||
first = false;
|
||
}
|
||
}
|
||
|
||
sb.Append(']');
|
||
return sb;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A named array of actor objects each carrying a damage total — a boss kill's damage
|
||
/// table (Protocol 6), and the first actor array whose entries are ranked rather than
|
||
/// merely listed.
|
||
///
|
||
/// The pairs are written in the order given, so the CALLER owns the sort. That is
|
||
/// deliberate: "the top damagers" is a judgement about a fight, and the shard's job is
|
||
/// to report the numbers it holds rather than to decide what counts as a contribution.
|
||
///
|
||
/// Each entry is the standard actor object plus `damage`, which means it carries `acct`
|
||
/// and `webId` and is therefore governed by the website's locked-field rule exactly as
|
||
/// every other actor is. A shard that considers the whole table too revealing hides it
|
||
/// with one field rule rather than by dropping the kind.
|
||
/// </summary>
|
||
public static StringBuilder Damagers(
|
||
this StringBuilder sb, string name, IList<KeyValuePair<Mobile, int>> pairs, int count)
|
||
{
|
||
sb.Append(",\"").Append(name).Append("\":[");
|
||
|
||
if (pairs != null)
|
||
{
|
||
var end = Math.Min(count, pairs.Count);
|
||
bool first = true;
|
||
|
||
for (int i = 0; i < end; i++)
|
||
{
|
||
var m = pairs[i].Key;
|
||
|
||
if (m == null)
|
||
continue;
|
||
|
||
if (!first)
|
||
sb.Append(',');
|
||
|
||
sb.Append('{');
|
||
WriteActorFields(sb, m);
|
||
sb.Append(",\"damage\":").Append(pairs[i].Value);
|
||
sb.Append('}');
|
||
|
||
first = false;
|
||
}
|
||
}
|
||
|
||
sb.Append(']');
|
||
return sb;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A roster member: the standard actor object plus the member's rank in their guild.
|
||
///
|
||
/// **Only the raw rank is emitted, never a resolved label.** ServUO names the five
|
||
/// standard ranks with cliloc ids (1062959–1062963) and ships no text for them, so the
|
||
/// shard cannot produce "Warlord" without a client-file table it does not have. The
|
||
/// website module does have one, and resolving a game term is its job in any case.
|
||
///
|
||
/// `rank` is the numeric rank, 0–4, with 4 being Leader (`RankDefinition.Ranks`). A
|
||
/// custom rank definition may carry a literal string instead of a cliloc, so `rankName`
|
||
/// is written when there is one and `rankCliloc` when there is not; a shard that has
|
||
/// replaced the rank table therefore keeps its own naming rather than being flattened
|
||
/// into the stock five.
|
||
///
|
||
/// A member with no readable rank — a mobile that is not a PlayerMobile, or one whose
|
||
/// GuildRank is null — is written with no rank fields at all rather than a fabricated
|
||
/// default. Absent means "not known", and a consumer that treated a missing rank as 0
|
||
/// would silently demote them.
|
||
///
|
||
/// **Staff are deliberately written with no rank, and this is not a rounding error.**
|
||
/// `PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or
|
||
/// above, whatever their actual rank — a gameplay convenience so staff can operate a
|
||
/// guild stone, and emphatically not a claim about who leads the guild. The true value
|
||
/// is in a private field with no accessor, so the only honest options are "Leader" and
|
||
/// "not known", and publishing a staff member as a guild leader on a public roster is
|
||
/// the worse of the two by a wide margin. A staff account that genuinely leads its guild
|
||
/// shows as an unranked member, which is a visible gap rather than a false claim.
|
||
/// </summary>
|
||
private static void WriteGuildMember(StringBuilder sb, Mobile m)
|
||
{
|
||
if (m == null)
|
||
{
|
||
sb.Append("null");
|
||
return;
|
||
}
|
||
|
||
sb.Append('{');
|
||
WriteActorFields(sb, m);
|
||
|
||
var pm = m as Server.Mobiles.PlayerMobile;
|
||
var rank = pm == null || pm.AccessLevel >= AccessLevel.GameMaster ? null : pm.GuildRank;
|
||
|
||
if (rank != null)
|
||
{
|
||
sb.Append(",\"rank\":").Append(rank.Rank);
|
||
|
||
if (!string.IsNullOrEmpty(rank.Name.String))
|
||
{
|
||
sb.Append(",\"rankName\":");
|
||
Escape(sb, rank.Name.String);
|
||
}
|
||
else if (rank.Name.Number > 0)
|
||
{
|
||
sb.Append(",\"rankCliloc\":").Append(rank.Name.Number);
|
||
}
|
||
}
|
||
|
||
sb.Append('}');
|
||
}
|
||
|
||
/// <summary>
|
||
/// One bare actor object, with no leading field name: serial, name, account (when there
|
||
/// is one), the linked webId (when the account is linked), and the player flag. A `null`
|
||
/// mobile writes null.
|
||
///
|
||
/// `acct` and `webId` are the site-identity fields, and they are emitted here
|
||
/// unconditionally by design — the sidecar is a forwarder, and deciding who may see them
|
||
/// is the website's job (it projects per the shard visibility rungs). Note that `acct` is
|
||
/// genuinely optional: a PlayerMobile can have no Account at all.
|
||
/// </summary>
|
||
private static void WriteActor(StringBuilder sb, Mobile m)
|
||
{
|
||
if (m == null)
|
||
{
|
||
sb.Append("null");
|
||
return;
|
||
}
|
||
|
||
sb.Append('{');
|
||
WriteActorFields(sb, m);
|
||
sb.Append('}');
|
||
}
|
||
|
||
/// <summary>
|
||
/// The actor fields, with no braces, so a caller can add its own.
|
||
///
|
||
/// Split out for <see cref="WriteGuildMember"/>, which is the same object plus guild
|
||
/// rank. Note the first field is written WITHOUT a leading comma and every later one
|
||
/// with, so this must be the first thing inside its object.
|
||
/// </summary>
|
||
private static void WriteActorFields(StringBuilder sb, Mobile m)
|
||
{
|
||
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");
|
||
}
|
||
|
||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||
public static string End(this StringBuilder sb)
|
||
{
|
||
sb.Append('}');
|
||
return sb.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes a bare JSON string value, or `null`, with no leading comma and no field name.
|
||
/// For the hand-built arrays the event plane emits, where <see cref="Escape"/> would
|
||
/// throw on the null a nullable field is entitled to be.
|
||
/// </summary>
|
||
public static void Text(StringBuilder sb, string value)
|
||
{
|
||
if (value == null)
|
||
sb.Append("null");
|
||
else
|
||
Escape(sb, value);
|
||
}
|
||
|
||
public static void Escape(StringBuilder sb, string value)
|
||
{
|
||
sb.Append('"');
|
||
|
||
for (int i = 0; i < value.Length; i++)
|
||
{
|
||
char c = value[i];
|
||
|
||
switch (c)
|
||
{
|
||
case '"': sb.Append("\\\""); break;
|
||
case '\\': sb.Append("\\\\"); break;
|
||
case '\n': sb.Append("\\n"); break;
|
||
case '\r': sb.Append("\\r"); break;
|
||
case '\t': sb.Append("\\t"); break;
|
||
case '\b': sb.Append("\\b"); break;
|
||
case '\f': sb.Append("\\f"); break;
|
||
default:
|
||
if (c < ' ')
|
||
sb.Append("\\u").Append(((int)c).ToString("x4"));
|
||
else
|
||
sb.Append(c);
|
||
break;
|
||
}
|
||
}
|
||
|
||
sb.Append('"');
|
||
}
|
||
|
||
// ---- rewriting an already-built line (protocol 6) ----
|
||
//
|
||
// BridgeIdempotency replays a stored reply under the REPEAT's correlation id. It could
|
||
// parse the line, edit the dictionary and re-serialize, but a round trip through
|
||
// JavaScriptSerializer would silently renormalise every number and string in a reply this
|
||
// file went to the trouble of writing by hand. These two edit the text instead, so a
|
||
// replayed reply is byte-for-byte the original apart from the field that had to change.
|
||
|
||
/// <summary>
|
||
/// Replaces the value of a top-level STRING field, honouring backslash escapes when
|
||
/// finding the value's end. Returns null if the field is not present as a string —
|
||
/// never a half-rewritten line.
|
||
/// </summary>
|
||
public static string RewriteStringField(string line, string name, string value)
|
||
{
|
||
if (line == null || name == null || value == null)
|
||
return null;
|
||
|
||
// The leading comma is part of the needle: every top-level field is written by Str()
|
||
// after Begin() has already emitted `t` and `kind`, so a real one always has one. It
|
||
// is the cheapest thing that stops the search matching the same text inside a value.
|
||
var needle = ",\"" + name + "\":\"";
|
||
int at = line.IndexOf(needle, StringComparison.Ordinal);
|
||
|
||
if (at < 0)
|
||
return null;
|
||
|
||
int valueStart = at + needle.Length;
|
||
int i = valueStart;
|
||
|
||
while (i < line.Length)
|
||
{
|
||
char c = line[i];
|
||
|
||
if (c == '\\')
|
||
{
|
||
i += 2; // an escape consumes the next character, whatever it is
|
||
continue;
|
||
}
|
||
|
||
if (c == '"')
|
||
break;
|
||
|
||
i++;
|
||
}
|
||
|
||
if (i >= line.Length)
|
||
return null; // unterminated: refuse rather than guess
|
||
|
||
var sb = new StringBuilder(line.Length + value.Length);
|
||
sb.Append(line, 0, valueStart - 1); // up to and excluding the opening quote
|
||
Escape(sb, value);
|
||
sb.Append(line, i + 1, line.Length - i - 1);
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Appends `"name":true` to an already-closed object. Returns the line unchanged if it
|
||
/// is not one, so a malformed reply is passed through rather than corrupted further.
|
||
/// </summary>
|
||
public static string WithTrueFlag(string line, string name)
|
||
{
|
||
if (String.IsNullOrEmpty(line) || line[line.Length - 1] != '}')
|
||
return line;
|
||
|
||
return line.Substring(0, line.Length - 1) + ",\"" + name + "\":true}";
|
||
}
|
||
|
||
// ---- inbound ----
|
||
|
||
/// <summary>
|
||
/// Parses one line into a dictionary. Returns null on malformed input rather than
|
||
/// throwing: a bad line from the sidecar must never reach a game code path.
|
||
/// </summary>
|
||
public static Dictionary<string, object> Parse(string line)
|
||
{
|
||
if (String.IsNullOrEmpty(line))
|
||
return null;
|
||
|
||
try
|
||
{
|
||
if (_parser == null)
|
||
{
|
||
_parser = new JavaScriptSerializer();
|
||
_parser.MaxJsonLength = 1 << 20;
|
||
}
|
||
|
||
return _parser.Deserialize<Dictionary<string, object>>(line);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static string GetString(Dictionary<string, object> o, string key)
|
||
{
|
||
object v;
|
||
|
||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||
return null;
|
||
|
||
return v as string ?? Convert.ToString(v, CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts a JSON array of strings. JavaScriptSerializer materializes JSON arrays as
|
||
/// object[] (or ArrayList) when the target is object, so handle both and stringify each
|
||
/// element. Returns an empty list for a missing or non-array value, never null.
|
||
/// </summary>
|
||
public static List<string> GetStringList(Dictionary<string, object> o, string key)
|
||
{
|
||
var result = new List<string>();
|
||
|
||
object v;
|
||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||
return result;
|
||
|
||
var enumerable = v as System.Collections.IEnumerable;
|
||
|
||
if (enumerable == null || v is string)
|
||
return result;
|
||
|
||
foreach (var item in enumerable)
|
||
{
|
||
if (item == null)
|
||
continue;
|
||
|
||
result.Add(item as string ?? Convert.ToString(item, CultureInfo.InvariantCulture));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts a JSON array of OBJECTS, as a list of dictionaries.
|
||
///
|
||
/// `JavaScriptSerializer` already materializes a nested object as another
|
||
/// `Dictionary<string, object>` when the target is `object`, so this needs no
|
||
/// parser work -- only the same defensive walk `GetStringList` does. Anything in the
|
||
/// array that is not an object is skipped rather than failing the whole field: a
|
||
/// malformed row in an oracle's dialogue should cost that row, not the NPC.
|
||
///
|
||
/// Returns an empty list for a missing or non-array value, never null.
|
||
/// </summary>
|
||
public static List<Dictionary<string, object>> GetObjectList(
|
||
Dictionary<string, object> o, string key)
|
||
{
|
||
var result = new List<Dictionary<string, object>>();
|
||
|
||
object v;
|
||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||
return result;
|
||
|
||
var enumerable = v as System.Collections.IEnumerable;
|
||
|
||
if (enumerable == null || v is string)
|
||
return result;
|
||
|
||
foreach (var item in enumerable)
|
||
{
|
||
var row = item as Dictionary<string, object>;
|
||
|
||
if (row != null)
|
||
result.Add(row);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
|
||
{
|
||
object v;
|
||
|
||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||
return fallback;
|
||
|
||
try
|
||
{
|
||
return Convert.ToInt32(v, CultureInfo.InvariantCulture);
|
||
}
|
||
catch
|
||
{
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Epoch milliseconds and lease durations do not fit an int, and JavaScriptSerializer
|
||
/// hands a large JSON number back as a long or a decimal depending on its magnitude, so
|
||
/// the conversion is done rather than the cast attempted.
|
||
/// </summary>
|
||
public static long GetLong(Dictionary<string, object> o, string key, long fallback)
|
||
{
|
||
object v;
|
||
|
||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||
return fallback;
|
||
|
||
try
|
||
{
|
||
return Convert.ToInt64(v, CultureInfo.InvariantCulture);
|
||
}
|
||
catch
|
||
{
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A lease VALUE arrives as text on the wire whatever its declared type (see
|
||
/// BridgeLeases), so this exists for the numbers that are genuinely numbers - a radius,
|
||
/// a weight. InvariantCulture throughout: a shard running under a comma-decimal locale
|
||
/// must read the same bytes the same way as one that is not.
|
||
/// </summary>
|
||
public static double GetDouble(Dictionary<string, object> o, string key, double fallback)
|
||
{
|
||
object v;
|
||
|
||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||
return fallback;
|
||
|
||
try
|
||
{
|
||
return Convert.ToDouble(v, CultureInfo.InvariantCulture);
|
||
}
|
||
catch
|
||
{
|
||
return fallback;
|
||
}
|
||
}
|
||
}
|
||
}
|