feat(bridge): protocol 5 — decay schedule, vendor fee state, and a login result
Three emitter changes and the overlay's protocol declaration, in one PR because
"The bridge is a contract": overlay.toml must be bumped in the same change as the
emitters or the next bundle silently fails to compose.
BridgeSweeps — house.decay gains ownerName and a nested `schedule`
{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}.
estimatedCollapse is emitted only where ServUO can actually know it. Dynamic decay
(Core.ML) draws each stage's duration at RANDOM when the stage is entered, so
NextDecayStage is exact for the next transition and nothing beyond it is known —
collapse becomes exact only at IDOC, where the next transition IS the collapse.
Static decay is a pure function of LastRefreshed and DecayPeriod, so it is exact at
every stage. Emitting it anywhere else would publish a guess as a fact, and on the
website's side that becomes a dated promise in someone's mail.
BridgeMarket — vendor.listing gains ownerAcct and a nested `fees` block.
ownerAcct is the one that matters structurally: the frame has carried ownerName
since v3, but a character name joins to nothing — only the game account is the
website's link key. The fees block resolves PlayerVendor.PayTimer's dismissal rule
(pay > totalGold => Destroy) on the shard, because both halves of that comparison
differ between ServUO's two vendor systems and re-deriving them downstream would be
a second implementation of a rule that lives in core.
No daysRemaining: under the old vendor system a pay period is a UO day
(Clock.MinutesPerUODay, about two real hours), so the obvious name would be wrong
by a factor of twelve on exactly the shards least likely to notice. periodsRemaining
plus the interval, and dismissalAt as an instant. A commission vendor has no pay
timer at all and reports exempt with no schedule — "never dismissed" is not the same
as "dismissed in 400 days".
BridgeEvents — a new account.login.result kind.
EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision, and
AccountLoginEventArgs constructs with Accepted = true, so the existing
account.login.attempt fires on successful logins too and cannot carry a verdict. A
security rule built on it would have mailed "someone tried to get into your account"
every time the player logged in.
The verdict is read one Core slice later via DelayCall(Zero). That needs no core
patch AND does not depend on handler subscription order, which ServUO does not define
and a shard's own scripts can change. reason is omitted on an accept, because
ALRReason's zero value is Invalid and would read as a failure reason. The address is
resolved inside the handler, since AccountLogin_ReplyRej disposes the NetState before
the deferred read runs. The password is never read, logged or emitted.
tools/scaffolding/BridgeProtocol5Probe.cs drives all three on a live shard, and the
README records the two traps it took to get there — both of which produce SILENCE
rather than an error, so each looks exactly like a broken emitter:
* An in-process login probe can never produce accepted:true. AccountHandler calls
acct.HasAccess(e.State) BEFORE it checks the password, and a null NetState fails
that. Only a real socket proves the accepted half — and it is the better test
anyway, since it also produces the real ip.
* Forcing a decay stage on a house that cannot decay emits nothing at all. Only
Condemned and ManualRefresh houses decay; an AutoRefresh one — and the owner's
NEWEST house is always AutoRefresh — has a DecayLevel getter that calls
ResetDynamicDecay() and reports Ageless, wiping the forced stage before the sweep
reads it.
Verified on the local rig against the release sidecar: a house walked
Fairly -> Greatly -> IDOC carried estimatedCollapse on the IDOC frame and only there;
every vendor's periodsRemaining matched funds/chargePerPeriod, including one at 0
whose dismissalAt equals its next tick; a real socket login gave
accepted:false reason:BadPass and then accepted:true. Compiles clean against ServUO
57.4 reference assemblies.
Docs: RunicGateway/docs link/v5.md.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
@@ -506,8 +507,16 @@ namespace Server.Custom.Bridge
|
||||
{
|
||||
sb.Ser("ownerSerial", owner.Serial);
|
||||
sb.Str("ownerName", owner.Name);
|
||||
|
||||
// Protocol 5. Without this the listing names an owner the website cannot resolve to
|
||||
// a person: ownerName is a character name, and only the account is the link key.
|
||||
var acct = owner.Account as Account;
|
||||
if (acct != null)
|
||||
sb.Str("ownerAcct", acct.Username);
|
||||
}
|
||||
|
||||
AppendFees(sb, vendor);
|
||||
|
||||
sb.Append(",\"location\":{\"map\":");
|
||||
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
|
||||
sb.Append(",\"x\":").Append(vendor.X);
|
||||
@@ -587,5 +596,75 @@ namespace Server.Custom.Bridge
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protocol 5. The vendor's fee state, which is what makes "your vendor is about to be
|
||||
/// dismissed" a thing the website can say BEFORE it happens instead of after.
|
||||
///
|
||||
/// The dismissal rule is PlayerVendor.PayTimer.OnTick: at every tick the charge is
|
||||
/// compared with the funds, and `if (pay > totalGold) Destroy()`. Both halves of that
|
||||
/// comparison differ between ServUO's two vendor systems, so both are resolved here
|
||||
/// rather than left for the sidecar or the website to guess at:
|
||||
///
|
||||
/// | charge | funds | interval
|
||||
/// NewVendorSystem | ChargePerRealWorldDay | HoldGold | 1 real day
|
||||
/// old system | ChargePerDay | BankAccount + HoldGold | 1 UO day
|
||||
///
|
||||
/// Two consequences worth stating, because both are easy to get wrong downstream:
|
||||
///
|
||||
/// * A field called `daysRemaining` would be WRONG on an old-system shard, where a pay
|
||||
/// period is a UO day (Clock.MinutesPerUODay, roughly two real hours) rather than a
|
||||
/// real one. So this emits `periodsRemaining` plus the interval that gives it meaning,
|
||||
/// and resolves the arithmetic into `dismissalAt` -- an instant, which needs no units.
|
||||
/// * A commission vendor (IsCommission) has no PayTimer at all and is never dismissed
|
||||
/// for fees. It reports exempt:true and no schedule, rather than a misleading
|
||||
/// "infinite days".
|
||||
///
|
||||
/// `dismissalAt` assumes no further sales or deposits, exactly as a bank balance
|
||||
/// projection does. Unlike a dynamic-decay house, though, there is no randomness in it:
|
||||
/// given the current funds it is the exact tick the vendor is destroyed on.
|
||||
/// </summary>
|
||||
private static void AppendFees(StringBuilder sb, PlayerVendor vendor)
|
||||
{
|
||||
sb.Append(",\"fees\":{");
|
||||
|
||||
if (vendor.IsCommission)
|
||||
{
|
||||
sb.Append("\"exempt\":true}");
|
||||
return;
|
||||
}
|
||||
|
||||
bool newSystem = BaseHouse.NewVendorSystem;
|
||||
|
||||
int charge = newSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||||
int funds = newSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
|
||||
|
||||
sb.Append("\"exempt\":false");
|
||||
sb.Append(",\"newVendorSystem\":").Append(newSystem ? "true" : "false");
|
||||
sb.Append(",\"chargePerPeriod\":").Append(charge);
|
||||
sb.Append(",\"funds\":").Append(funds);
|
||||
sb.Append(",\"holdGold\":").Append(vendor.HoldGold);
|
||||
sb.Append(",\"bankAccount\":").Append(vendor.BankAccount);
|
||||
|
||||
var interval = newSystem ? TimeSpan.FromDays(1.0) : TimeSpan.FromMinutes(Clock.MinutesPerUODay);
|
||||
sb.Append(",\"payIntervalSec\":").Append((long)interval.TotalSeconds);
|
||||
|
||||
var nextPay = vendor.NextPayTime.ToUniversalTime();
|
||||
sb.Append(",\"nextPayAt\":");
|
||||
Text(sb, nextPay.ToString("o"));
|
||||
|
||||
// A free vendor (no priced stock under the old system can reach charge 0) never runs out.
|
||||
if (charge > 0)
|
||||
{
|
||||
// Ticks it survives before the one that finds pay > totalGold.
|
||||
long periods = funds / charge;
|
||||
sb.Append(",\"periodsRemaining\":").Append(periods);
|
||||
|
||||
sb.Append(",\"dismissalAt\":");
|
||||
Text(sb, nextPay.AddSeconds(periods * interval.TotalSeconds).ToString("o"));
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user