feat(bridge): protocol 5 — cutover 2a of 7 (edgemain) #20

Merged
whitlocktech merged 4 commits from edge into main 2026-09-01 13:55:04 +00:00
6 changed files with 497 additions and 2 deletions
Showing only changes of commit a144c12c46 - Show all commits

View File

@@ -23,8 +23,8 @@
# manual duty: when the protocol changes, bump it here in the same PR that # manual duty: when the protocol changes, bump it here in the same PR that
# changes the emitters, exactly as link bumps PROTOCOL_VERSION. # changes the emitters, exactly as link bumps PROTOCOL_VERSION.
# #
# Current: 4 — see docs/link/v4.md (guild.roster, guild.leave). # Current: 5 — see docs/link/v5.md (house.decay scheduling, vendor.listing fees, account.login.result).
protocol = 4 protocol = 5
# ── ServUO compatibility ───────────────────────────────────────────────────── # ── ServUO compatibility ─────────────────────────────────────────────────────
# #

View File

@@ -170,9 +170,53 @@ namespace Server.Custom.Bridge
.Str("acct", e.Username) .Str("acct", e.Username)
.Str("ip", address) .Str("ip", address)
.End()); .End());
EmitLoginResult(e, address);
}); });
} }
/// <summary>
/// Protocol 5. The RESULT of the login above, which the attempt itself cannot carry.
///
/// Why a second kind rather than two more fields: PacketHandlers.AccountLogin invokes this
/// sink and only THEN branches on e.Accepted, and the decision is made by the handlers
/// themselves -- Server.Misc.AccountHandler is the one that validates the password and
/// sets Accepted/RejectReason. Inside our own handler the verdict therefore does not exist
/// yet: Accepted is still its constructor default of `true` for a password that is about
/// to be rejected. Anything built on the attempt alone fires on every SUCCESSFUL login
/// too, which is the wrong way round for a security notice -- it would tell a player
/// "someone tried to get into your account" every time they logged in themselves.
///
/// Reading it one Core slice later, via DelayCall(Zero), is what makes the verdict final
/// without a core patch and without depending on handler subscription ORDER, which
/// ServUO does not define and which a shard's own scripts can change.
///
/// On holding the args object: it carries the plaintext Password, so it is deliberately
/// alive for one extra slice and no longer, and exactly two properties are read off it.
/// The password is never read, never logged and never emitted -- the same rule the
/// attempt emitter above states.
/// </summary>
private static void EmitLoginResult(AccountLoginEventArgs e, string address)
{
// The NetState is disposed by AccountLogin_ReplyRej before this runs, which is why the
// address is passed in already resolved rather than re-read from e.State.
Timer.DelayCall(TimeSpan.Zero, () =>
Guard("account.login.result", () =>
{
var sb = BridgeJson.Begin("account.login.result")
.Str("acct", e.Username)
.Str("ip", address)
.Bool("accepted", e.Accepted);
// ALRReason is only meaningful on a rejection; on an accept it is still the
// enum's zero value (Invalid), which would read as a failure reason if emitted.
if (!e.Accepted)
sb.Str("reason", e.RejectReason.ToString());
BridgeLink.Emit(sb.End());
}));
}
// ---- economy ---- // ---- economy ----
private static void OnGoldChange(AccountGoldChangeEventArgs e) private static void OnGoldChange(AccountGoldChangeEventArgs e)

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
using Server.Accounting;
using Server.Items; using Server.Items;
using Server.Mobiles; using Server.Mobiles;
using Server.Multis; using Server.Multis;
@@ -506,8 +507,16 @@ namespace Server.Custom.Bridge
{ {
sb.Ser("ownerSerial", owner.Serial); sb.Ser("ownerSerial", owner.Serial);
sb.Str("ownerName", owner.Name); 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\":"); sb.Append(",\"location\":{\"map\":");
Text(sb, vendor.Map == null ? null : vendor.Map.Name); Text(sb, vendor.Map == null ? null : vendor.Map.Name);
sb.Append(",\"x\":").Append(vendor.X); sb.Append(",\"x\":").Append(vendor.X);
@@ -587,5 +596,75 @@ namespace Server.Custom.Bridge
return sb.End(); 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('}');
}
} }
} }

View File

@@ -215,11 +215,14 @@ namespace Server.Custom.Bridge
if (owner != null) if (owner != null)
{ {
sb.Ser("ownerSerial", owner.Serial); sb.Ser("ownerSerial", owner.Serial);
sb.Str("ownerName", owner.Name);
var acct = owner.Account as Account; var acct = owner.Account as Account;
if (acct != null) if (acct != null)
sb.Str("ownerAcct", acct.Username); sb.Str("ownerAcct", acct.Username);
} }
AppendDecaySchedule(sb, house, to);
// Where a player would physically stand to see it. // Where a player would physically stand to see it.
var ban = house.BanLocation; var ban = house.BanLocation;
sb.Append(",\"ban\":{\"x\":").Append(ban.X) sb.Append(",\"ban\":{\"x\":").Append(ban.X)
@@ -232,6 +235,67 @@ namespace Server.Custom.Bridge
return sb.End(); return sb.End();
} }
/// <summary>
/// Protocol 5. The three scheduling fields, and the reason they are not all always present.
///
/// ServUO has two decay implementations and they differ in how KNOWABLE the future is:
///
/// * Dynamic decay (DynamicDecay.Enabled, i.e. Core.ML) draws each stage's duration at
/// RANDOM when the stage is entered (BaseHouse.SetDynamicDecay ->
/// DynamicDecay.GetRandomDuration). So NextDecayStage is exact for the NEXT transition
/// and nothing beyond it is known at all. Collapse becomes exact only once the house is
/// already at IDOC, because then the next transition IS the collapse.
/// * Static decay (GetOldDecayLevel) is a pure function of LastRefreshed and DecayPeriod,
/// so collapse is exact at EVERY stage -- there is no randomness to wait out.
///
/// Emitting estimatedCollapse from a dynamic-decay house at, say, Fairly would therefore be
/// publishing a guess as a fact, which on the website's side becomes a dated promise in a
/// player's mail. It is omitted rather than approximated: the website's `required: false`
/// declaration already permits its absence, and an absent field is honest where a wrong
/// date is not.
/// </summary>
private static void AppendDecaySchedule(StringBuilder sb, BaseHouse house, DecayLevel to)
{
// ONE nested object rather than four sibling keys, for the same reason vendor.listing
// nests `location`: the website's visibility projection matches literal JSON keys, so a
// nested group is one admin rule that can hide the whole schedule, where four flat keys
// would be four rules that drift apart.
sb.Append(",\"schedule\":{");
// The stage clock. Only dynamic decay keeps one; static decay leaves it at MinValue.
bool dynamic = DynamicDecay.Enabled;
var next = house.NextDecayStage;
sb.Append("\"dynamicDecay\":").Append(dynamic ? "true" : "false");
if (dynamic && next > DateTime.MinValue)
sb.Str("nextStage", next.ToUniversalTime().ToString("o"));
// Total seconds from a full refresh to collapse. Constant per house type, but it is what
// lets a reader turn lastRefreshed into a percentage without knowing ServUO's tables.
var period = house.DecayPeriod;
if (period > TimeSpan.Zero)
sb.Num("decayPeriodSec", (long)period.TotalSeconds);
DateTime collapse;
bool knowable = true;
if (!dynamic)
collapse = house.LastRefreshed.ToUniversalTime() + period;
else if (to == DecayLevel.IDOC && next > DateTime.MinValue)
collapse = next.ToUniversalTime();
else
{
collapse = DateTime.MinValue;
knowable = false;
}
if (knowable)
sb.Str("estimatedCollapse", collapse.ToString("o"));
sb.Append('}');
}
// ---- economy supply ---- // ---- economy supply ----
/// <summary> /// <summary>

View File

@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
using Server.Mobiles;
using Server.Multis;
using Server.Network;
namespace Server.Custom
{
/// <summary>
/// Exercises all three Protocol 5 enrichments on a live shard, without a game client.
///
/// Each of the three needs something a unit test cannot produce, and each needs it for a
/// different reason:
///
/// * house.decay's `schedule` is only interesting ACROSS a transition, and the interesting
/// pair is Greatly -> IDOC: the first must carry no estimatedCollapse (under dynamic
/// decay the remaining stages have not been drawn yet) and the second must carry one.
/// A fixture can assert the mapping; only a real BaseHouse walking a real
/// SetDynamicDecay proves the emitter reads ServUO the way the comment claims.
/// * vendor.listing's `fees` are computed from PlayerVendor state that differs between
/// ServUO's two vendor systems. This reports what the shard actually holds so the
/// emitted frame can be checked against it rather than against an assumption.
/// * account.login.result is the one that could not be built at all before v5, because
/// EventSink.AccountLogin fires BEFORE the verdict exists. Invoking the real sink with a
/// real password (right and wrong) runs the shard's own AccountHandler, which is what
/// sets Accepted/RejectReason -- so this proves the deferred read sees the FINAL verdict
/// and not the constructor's default of true.
///
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
/// In game / at the console: `[p5probe`.
/// </summary>
public static class BridgeProtocol5Probe
{
public static void Initialize()
{
CommandSystem.Register("p5probe", AccessLevel.Administrator, Probe_OnCommand);
if (Config.Get("Bridge.Protocol5ProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(8.0), () => Run(null));
}
[Usage("p5probe")]
[Description("Drives the three Protocol 5 enrichments so their frames can be observed.")]
private static void Probe_OnCommand(CommandEventArgs e)
{
Run(e.Mobile);
}
private static void Report(Mobile to, string line)
{
Console.WriteLine("[P5Probe] " + line);
if (to != null)
to.SendMessage(line);
}
private static void Run(Mobile to)
{
try
{
ReportVendorFees(to);
DriveLogins(to);
WalkHouseToIdoc(to);
}
catch (Exception ex)
{
Report(to, "threw: " + ex);
}
}
// ---- (a) house.decay schedule ----
/// <summary>
/// Walks one house Greatly, then (after a pause long enough for a decay sweep to run)
/// IDOC. Two frames, and the PAIR is the assertion: no estimatedCollapse on the first,
/// one on the second.
/// </summary>
private static void WalkHouseToIdoc(Mobile to)
{
BaseHouse target = null;
var byType = new Dictionary<string, int>();
foreach (var h in BaseHouse.AllHouses)
{
if (h == null || h.Deleted || h.Owner == null)
continue;
var type = h.DecayType.ToString();
byType[type] = (byType.ContainsKey(type) ? byType[type] : 0) + 1;
// CanDecay is the filter that matters, and getting it wrong is silent. A house
// whose DecayType is AutoRefresh or Ageless -- and the owner's NEWEST house is
// always AutoRefresh -- has a DecayLevel getter that calls ResetDynamicDecay() and
// reports Ageless, so a forced SetDynamicDecay is wiped on the very next read. The
// sweep then sees no change and emits nothing at all, which looks exactly like a
// broken emitter.
if (!h.CanDecay)
continue;
// The current level does NOT disqualify a house. On this rig every decaying house
// is already at IDOC (a seeded world has only a couple of Condemned houses and they
// have long since bottomed out), so the walk starts by putting one BACK to Fairly.
// BridgeDemoDress.PrimeIdoc does the same thing for the same reason.
target = h;
break;
}
foreach (var kv in byType)
Report(to, "houses by DecayType: " + kv.Key + "=" + kv.Value);
if (target == null)
{
Report(to, "no walkable house found (none with CanDecay below IDOC)");
return;
}
Report(to, string.Format(
"walking house 0x{0:X} owner={1} decayType={2} from {3}; dynamicDecay={4}",
target.Serial.Value,
target.Owner == null ? "?" : target.Owner.Name,
target.DecayType,
target.DecayLevel,
DynamicDecay.Enabled));
// Each step needs its own sweep to land, or the sweep sees one net change and emits a
// single frame -- which would collapse the whole point, since the assertion is the
// DIFFERENCE between the Greatly frame and the IDOC one.
var step = TimeSpan.FromSeconds(Math.Max(4, BridgeConfigSeconds()) * 2 + 4);
Step(to, target, DecayLevel.Fairly, TimeSpan.Zero, "reset (no estimatedCollapse expected)");
Step(to, target, DecayLevel.Greatly, step, "expect schedule WITHOUT estimatedCollapse");
Step(to, target, DecayLevel.IDOC, TimeSpan.FromTicks(step.Ticks * 2), "expect schedule WITH estimatedCollapse");
}
private static void Step(Mobile to, BaseHouse house, DecayLevel level, TimeSpan after, string note)
{
Action go = () =>
{
if (house.Deleted)
return;
Report(to, string.Format("house 0x{0:X} -> {1} ({2})", house.Serial.Value, level, note));
house.SetDynamicDecay(level);
};
if (after <= TimeSpan.Zero)
go();
else
Timer.DelayCall(after, () => go());
}
/// <summary>The decay sweep interval, read the same way the bridge reads it.</summary>
private static int BridgeConfigSeconds()
{
return Config.Get("Bridge.DecaySweepSeconds", 60);
}
// ---- (b) vendor.listing fees ----
/// <summary>
/// Prints the fee state of the first few player vendors straight off the PlayerVendor
/// objects, so the emitted `fees` block can be compared against the shard's own numbers
/// rather than against what the emitter believes them to be.
/// </summary>
private static void ReportVendorFees(Mobile to)
{
bool newSystem = BaseHouse.NewVendorSystem;
int shown = 0;
Report(to, "NewVendorSystem=" + newSystem);
foreach (var m in World.Mobiles.Values)
{
var v = m as PlayerVendor;
if (v == null || v.Deleted)
continue;
int charge = newSystem ? v.ChargePerRealWorldDay : v.ChargePerDay;
int funds = newSystem ? v.HoldGold : v.BankAccount + v.HoldGold;
var acct = v.Owner == null ? null : v.Owner.Account as Account;
Report(to, string.Format(
"vendor 0x{0:X} owner={1} acct={2} commission={3} charge={4} funds={5} periods={6} nextPay={7:o}",
v.Serial.Value,
v.Owner == null ? "?" : v.Owner.Name,
acct == null ? "<none>" : acct.Username,
v.IsCommission,
charge,
funds,
charge > 0 ? (funds / charge).ToString() : "n/a",
v.NextPayTime.ToUniversalTime()));
if (++shown >= 3)
break;
}
if (shown == 0)
Report(to, "no player vendors in the world");
}
// ---- (c) account.login.result ----
/// <summary>
/// Fires the real EventSink.AccountLogin twice against a real account: once with a
/// deliberately wrong password and once with the right one.
///
/// The shard's own AccountHandler is what decides, and it decides AFTER our handler has
/// returned. So a correct implementation emits `accepted:false reason:BadPass` for the
/// first and `accepted:true` for the second. An implementation that read the verdict
/// inside the handler would emit `accepted:true` for BOTH -- which is precisely the bug
/// this kind exists to make impossible, and precisely what this probe would show.
///
/// The password is read from config, never compiled in. `Bridge.Protocol5ProbeAccount`
/// and `Bridge.Protocol5ProbePassword`; with no password configured only the failing
/// half runs, which is still the half that matters.
/// </summary>
private static void DriveLogins(Mobile to)
{
var username = Config.Get("Bridge.Protocol5ProbeAccount", (string)null);
if (String.IsNullOrEmpty(username))
{
Report(to, "no Bridge.Protocol5ProbeAccount configured; skipping the login probe");
return;
}
var password = Config.Get("Bridge.Protocol5ProbePassword", (string)null);
// Accounts store a hash, so the rig cannot READ a password to log in with -- it has to
// set one. Same posture as BridgeDemoDress, which does this for the same reason: the
// value comes from config and is never compiled in or logged.
if (!String.IsNullOrEmpty(password))
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Report(to, "account '" + username + "' does not exist; skipping the login probe");
return;
}
acct.SetPassword(password);
Report(to, "set a known password on '" + username + "' for the accepted half");
}
Report(to, "login probe: '" + username + "' with a WRONG password (expect accepted:false)");
Fire(username, "definitely-not-the-password-" + Guid.NewGuid().ToString("N"));
if (String.IsNullOrEmpty(password))
{
Report(to, "no Bridge.Protocol5ProbePassword configured; skipping the accepted half");
return;
}
// Spaced out so the two results are unambiguous in the sidecar's history.
Timer.DelayCall(TimeSpan.FromSeconds(3.0), () =>
{
Report(to, "login probe: '" + username + "' with the RIGHT password (expect accepted:true)");
Fire(username, password);
});
}
private static void Fire(string username, string password)
{
// A null NetState is deliberate and is itself part of the test: the real emitter reads
// the address defensively because AccountLogin_ReplyRej disposes the state before the
// deferred read runs, so it must already survive not having one.
EventSink.InvokeAccountLogin(new AccountLoginEventArgs(null, username, password));
}
}
}

View File

@@ -14,6 +14,7 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. | | `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. | | `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. | | `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
## Deploy overwrites Bridge.cfg ## Deploy overwrites Bridge.cfg
@@ -106,3 +107,35 @@ Probe, best-of-20 on the Core thread:
``` ```
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 24× for a fully-kitted character. Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 24× for a fully-kitted character.
## The login half needs a socket, not the sink
`BridgeProtocol5Probe` fires `EventSink.InvokeAccountLogin` directly, which proves the REJECTED
half of `account.login.result` and nothing more. ServUO's own `AccountHandler` calls
`acct.HasAccess(e.State)` *before* it ever checks the password, and a null `NetState` fails that --
so an in-process probe logs `Access denied` for a correct password too, and never produces an
`accepted:true`.
To prove the accepted half, speak the wire. A real socket also gives the frame a real `ip`, which
is one of the fields being tested:
```python
# 4-byte seed, then 0x80 = [0x80][30b username][30b password][1b]
s = socket.create_connection(('127.0.0.1', 2593))
s.sendall(b'\x7f\x00\x00\x01')
s.sendall(b'\x80' + pad(user) + pad(password) + b'\x5d')
```
The shard logs `Invalid password for '<acct>'` or `Valid credentials for '<acct>'`, and the sidecar's
`/history?kind=account.login.result` should show `accepted:false reason:BadPass` and `accepted:true`
respectively. **Both saying `accepted:true` is the bug the kind exists to prevent** -- it means the
verdict was read inside the handler, before it existed.
## Walking a house into IDOC needs a house that can decay
Only a `Condemned` or `ManualRefresh` house decays. An `AutoRefresh` one -- and the owner's NEWEST
house is always `AutoRefresh` -- has a `DecayLevel` getter that calls `ResetDynamicDecay()` and
reports `Ageless`, so a forced `SetDynamicDecay` is wiped on the very next read, the sweep sees no
change, and **nothing is emitted at all**. That looks exactly like a broken emitter. Filter on
`house.CanDecay`, and expect a seeded world to have only one or two houses that qualify -- both
probably already at IDOC, so the walk has to put one back down first.