7 Commits

Author SHA1 Message Date
badc1702de Merge pull request 'chore(tools): build guilds, and walk houses into IDOC where the site can see it' (#17) from chore/demo-guilds-and-idoc into main
All checks were successful
Release overlay / release (push) Successful in 7s
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-25 15:03:35 +00:00
fc4ebf0f5a chore(tools): build guilds, and walk houses into IDOC where the site can see it
Two things the screenshot rig needed and the dressing pass could not give it
(runicgateway.com PLAN.md §13 phase 9).

GUILDS. The world had none — the guild board on the site was two rows of week-old
cache for guilds that had been deleted, and the website's Teams reconcile from that
board, so Teams was empty too. There is nothing to rename here: a guild has to exist
before it can be called something, so this builds four of them out of characters the
seeder already made, with a leader, two officers per guild and an alliance across the
first two. Idempotent by name, and a character already in a guild is never moved.

IDOC. "Houses in danger" reads a column the ingest only writes when the plugin reports
a house CHANGING stage; the registry frame carries the stage too, but the ingest leaves
that column to the transition feed so the two cannot clobber each other. A house that is
already collapsing when the site connects is therefore invisible: the sweep baselines it
at IDOC and no transition is ever emitted. The staging is now two passes — prime a few
houses at a middle stage at boot, collapse them 150 seconds later — so the site watches
it happen. Where too few houses can decay at all, their owners' accounts are backdated,
which is the same lever the seeder pulled and the same one a real shard pulls when
somebody stops playing.

Also: "Bridge Test Shop", left over from a hand-run smoke test, now gets a name like
every other vendor.

Both of the site-side asymmetries above are recorded as product observations in the
file rather than patched from here.

Test scaffolding, in tools/, never deployed — deploy.ps1 copies overlay/ only.
Verified against the local ServUO tree: four guilds and their rosters reached the
website over a real sidecar, and two houses reached "Houses in danger".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 09:35:53 -05:00
8cf995f27f Merge pull request 'chore(tools): dress a seeded world so it can be screenshotted' (#16) from chore/demo-world-dressing into main
All checks were successful
Release overlay / release (push) Successful in -57s
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-25 00:34:55 +00:00
158c0596d8 chore(tools): dress a seeded world so it can be screenshotted
BridgeSeeder builds a world at realistic scale, which is what the bridge needed;
it never needed the world to look like anything. So a vendor is `seed vendor`
trading as `Seed Shop 810`, a character is `Seed004A` and a house sign reads
`Seed House 12` — and every one of those strings travels the whole bridge and
lands on the marketplace, the guild roster and the housing pages of the website.
That is fine for a protocol test and wrong for the marketing site's screenshots
(runicgateway.com PLAN.md §13 phase 9, D42/D46).

BridgeDemoDress renames them in place and seeds nothing: prices, listing counts,
decay stages, fame and skills stay exactly as the seeder left them, so the data
keeps its provenance and only the strings a human reads change. Names come from
fixed tables hashed off each object's serial, so a re-run reproduces the same
world and screenshots can be retaken later and still match.

It also does two things the screenshots needed and nothing else provides:

- stages a few condemned houses back into the last decay levels, because decay
  is a live process and "Houses in danger" is empty by the time anyone looks
- sets a known password on seed_000, because logging a character in is the only
  way to make the online roster non-empty and the seeder assigns a random GUID.
  The password is read from Bridge.cfg, never compiled in.

Test scaffolding, in tools/, never deployed — deploy.ps1 copies overlay/ only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 19:22:46 -05:00
0d9ac7fda8 Merge pull request 'ci(release): show the error body, retry the POST, and sweep for orphan tags' (#15) from ci/release-post-retry-and-error-body into main
All checks were successful
Release overlay / release (push) Successful in 9s
Reviewed-on: #15
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 19:42:41 +00:00
8195454201 ci(release): show the error body, retry the POST, and sweep for orphan tags
installer#22's release run built every artifact, pushed its tag, then took a 500
from POST /releases one second later and exited 22 -- leaving the tag orphaned
with no binaries. Re-running published it unchanged, so the 500 was a race with
the tag push rather than a bad request.

This repo's release step has the same two gaps verbatim, and it is where the
whole problem was first seen.

`curl -sSf` prints no response body on an error status, so such a failure leaves
only "curl: (22) ... error: 500" in the log and the cause has to be inferred
from timestamps. Every call now captures the body and prints it on failure.

Nothing retried, so a transient 5xx became a permanent orphan. The POST now
retries five times with a 5/10/15/20s backoff. 4xx is deliberately not retried:
a bad token or a malformed body will not improve by being sent again.

The asset upload gets the same treatment. That matters more here than anywhere
else: this release ships an overlay tarball and a SHA256SUMS, and a release
whose checksums do not cover the tarball they advertise is worse than no
release, because that file is the trust anchor and the installer verifies
against it.

The third gap is the one this repo proves. The orphan-tag recovery in the plan
step is VERSION-SCOPED -- it computes VERSION from the newest tag plus the bump,
then only checks refs/tags/v${VERSION}. That recovers an orphan on the very next
run and is useless afterwards, because once any releasable commit lands the next
run computes a NEW version and never looks at the old tag again.

v0.1.0 was the proof, and the proof is pointed: the commit that ADDED that
recovery was itself typed "fix(release): preflight credentials and recover the
orphaned v0.1.0 tag", so it bumped the version to v0.1.1 -- and the run that
introduced the recovery stepped straight past the tag it was written to rescue.
The tag stayed orphaned from 2026-08-04 until today.

So the plan step now sweeps every v* tag and warns about any without a release.
It warns rather than recovers, deliberately: publishing an old version would
mean building today's tree and shipping it under a tag whose tree it is not,
which is worse than the inconsistency it fixes. It never fails the run either --
a sweep that can break a good release is a sweep someone will delete.

v0.1.0 itself is deleted, on the org lead's decision. Nothing referenced it: it
is three releases behind, and no published bundle names it -- not even
bundle-2026.08.04, because the tag never had a release for a bundle to point at.
It was 724262548b, the merge of #7, recorded here
so the tag can be recreated if that turns out to be wrong.

Verified by extracting every run block from the YAML: bash -n clean across all
of them, the YAML parses, no empty template token, the asset loop still the
tarball-and-checksums pair rather than link's three binaries, the retry loop
exercised against a stubbed curl across seven cases, and the sweep run against
the real repositories -- reporting v0.1.0 before the deletion and clean after.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no artifact, so a release here would be an empty one. That is
the same rule the fix commit above tripped over.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:43:24 -05:00
79cc611ee0 Merge pull request 'feat(bridge)!: protocol 4 — guild rosters and per-member leaves (Teams cutover 1/6)' (#14) from edge into main
All checks were successful
Release overlay / release (push) Successful in 13s
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-19 08:54:43 +00:00
3 changed files with 819 additions and 8 deletions

View File

@@ -176,6 +176,39 @@ jobs:
fi
fi
# ── Orphan sweep ────────────────────────────────────────────────
#
# The check above is VERSION-SCOPED: it only ever asks about the one
# version this run computed. That is enough to recover an orphan on
# the very next run, and useless afterwards — once any releasable
# commit lands, the next run computes a NEW version, never looks at
# the old tag again, and the orphan becomes permanent and silent.
#
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
# commit that ADDED the recovery above was itself typed
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
# v0.1.1 — and the run that introduced the recovery stepped straight
# past the tag it was written to rescue. That tag is still orphaned.
#
# So every v* tag is checked, and anything missing a release is
# WARNED about. Deliberately not recovered: publishing an old version
# would mean building today's tree and shipping it under a tag whose
# tree it is not, which is worse than the inconsistency it fixes.
# A human decides whether to recover or drop it.
#
# Never fails the run. A sweep that can break a good release is a
# sweep someone will delete.
ORPHANS=""
for T in $(git tag -l 'v*' --sort=-v:refname); do
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
done
if [ -n "${ORPHANS}" ]; then
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
@@ -477,18 +510,73 @@ jobs:
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# installer#22's release run failed exactly here: it landed one second
# after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running published the same artifacts
# untouched, so it was a race, not a bad request — but the tag sat
# orphaned until a human noticed.
#
# Two things made that worse than it needed to be.
#
# 1. `curl -sSf` prints NO response body on an error status, so all the
# log carried was "curl: (22) ... error: 500" and the cause had to be
# inferred from timestamps. Capture the body and print it.
# 2. Nothing retried, so a transient 5xx became a permanent orphan.
#
# 4xx is deliberately NOT retried: a bad token or a malformed body does
# not improve by being sent again, and retrying only turns a clear
# failure into a slow one.
REL_ID=""
for attempt in 1 2 3 4 5; do
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" || echo 000)"
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
REL_ID="$(jq -r '.id' /tmp/rel.json)"
break
fi
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
echo "--- response body ---"
cat /tmp/rel.json || true
echo
echo "---------------------"
case "$HTTP" in
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
esac
if [ "$attempt" = 5 ]; then
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
exit 1
fi
sleep $(( attempt * 5 ))
done
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
echo "::error::Release created but no id came back; refusing to upload assets blind."
exit 1
fi
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${TARBALL}" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
# Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every artifact it advertises, which is
# worse than no release at all -- that file is the trust anchor.
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
-F "attachment=@dist/${f}" || echo 000)"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::uploading ${f} returned HTTP ${HTTP}"
cat /tmp/asset.json || true
exit 1
fi
echo " uploaded ${f}"
done

View File

@@ -0,0 +1,687 @@
using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
using Server.Guilds;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Gives a BridgeSeeder world presentable names, so a shard standing behind a public
/// screenshot does not read as test data.
///
/// Test scaffolding. Not part of the bridge. Never deployed — see tools/README.md.
///
/// WHY THIS EXISTS
/// ---------------
/// BridgeSeeder builds a world at realistic SCALE, which is what the bridge needed:
/// 50 accounts, 150 characters, 30 houses, 30 vendors, 1,200 listings. It never needed
/// the world to look like anything, so a vendor is "seed vendor" trading as
/// "Seed Shop 810" and a character is "Seed004A". Every one of those names travels the
/// whole bridge — plugin, sidecar, website — and lands on the marketplace, the guild
/// roster and the housing pages, which are exactly the pages a screenshot wants.
///
/// This pass renames what is already there rather than seeding anything new. That
/// matters: the data keeps its provenance. The prices, the listing counts, the decay
/// stages, the fame and the skill sheets are all still whatever BridgeSeeder produced
/// and whatever the shard has done to them since — only the strings a human reads are
/// replaced. Nothing here invents shard state that the game did not produce.
///
/// IDEMPOTENT, AND DETERMINISTIC
/// -----------------------------
/// Names come from fixed tables indexed by the object's own serial, so the same vendor
/// draws the same shop name on every run against the same save — screenshots retaken
/// later still match. A second run is therefore a no-op, and a world half-dressed by an
/// interrupted run finishes cleanly.
///
/// Shop and house names are also re-dressed when they are names THIS pass produced, so
/// a change to the tables or to the hash can be applied to a world that has already been
/// through here once. Character names are not: a person's name is an ordinary string
/// with no closed set to recognise it by, so once dressed it is left alone.
///
/// WHAT IT ALSO DOES, AND WHY EACH IS HERE
/// ---------------------------------------
/// - Walks a few houses into IDOC, in two passes with a wait between them, because the
/// website only records a collapse it watched happen. Decay is a live process: by the
/// time anybody looks the stages have moved on and "Houses in danger" is empty. Empty
/// is a true state and a poor screenshot, so this stages a handful — see PrimeIdoc.
/// - Sets a known password on one seeded account. Logging a character in is the only
/// way to make the online roster non-empty, and it needs a client, and a client needs
/// a password. The seeder gives every account a random GUID nobody kept.
/// - BUILDS GUILDS, which is the one thing here that creates rather than renames. The
/// seeder never made any, so a shard behind these screenshots has an empty guild
/// board and — because the website's Teams are reconciled from that board — no teams
/// either. There is nothing to rename: a guild has to exist before it can be called
/// something. Members are drawn from characters the seeder already made, so the only
/// invention is the association itself.
///
/// A NOTE ON THE GUILD BOARD, FOUND WHILE BUILDING THIS
/// ---------------------------------------------------
/// `BridgeSocial.Signature()` folds name, abbreviation, leader serial, member count and
/// alliance — not member NAMES — and the roster is only re-emitted when the member SET
/// changes. So renaming a guild member never reaches the site: the board keeps the name
/// the member had when the roster was last emitted. Dressing a world that was already
/// published therefore leaves stale rosters behind, and creating the guilds after the
/// rename (as this does) is what avoids it. Raised as a product observation, not fixed
/// here — a rename is rare in a real shard, and the fix belongs in the plugin.
///
/// Flag: `DemoDressOnStart=True` in Config/Bridge.cfg. In game: `[demodress`.
/// </summary>
public static class BridgeDemoDress
{
private const string Prefix = "seed_";
/// <summary>The account whose password is set, so a character can be logged in.</summary>
private const string LoginAccount = "seed_000";
/// <summary>
/// Read from Config/Bridge.cfg (`DemoDressPassword`) so a password never lands in
/// source control. Absent means the account is left alone.
/// </summary>
private static string LoginPassword
{
get { return Config.Get("Bridge.DemoDressPassword", default(string)); }
}
/// <summary>How many condemned houses to put back into the last two decay stages.</summary>
private const int IdocHouses = 4;
/// <summary>
/// How long after boot the second IDOC pass runs. See <see cref="PrimeIdoc"/> —
/// the delay is the whole point, not a politeness.
/// </summary>
private static int IdocDelaySeconds
{
get { return Config.Get("Bridge.DemoDressIdocDelaySeconds", 150); }
}
// ── Name tables ────────────────────────────────────────────────────────────────
//
// Ordinary fantasy given names and English trade-sign nouns. Deliberately dull: the
// point is that a reader's eye passes over them, which is what a real roster does.
private static readonly string[] Given =
{
"Alaric", "Bess", "Corwin", "Dagna", "Edric", "Fenna", "Garrick", "Halle",
"Ivo", "Jessa", "Kellen", "Lira", "Marek", "Nessa", "Orrin", "Perrin",
"Quill", "Rowan", "Sera", "Tamsin", "Ulric", "Vera", "Wendel", "Xanthe",
"Yorick", "Zara", "Bram", "Caitrin", "Doran", "Elspeth"
};
private static readonly string[] Family =
{
"Ashdown", "Bellweather", "Crowe", "Dunmore", "Eastgate", "Fairbourne",
"Grimsby", "Hollowell", "Ironwood", "Larkspur", "Mosswick", "Thornbury"
};
private static readonly string[] ShopFirst =
{
"The Copper", "The Silver", "The Gilded", "The Iron", "The Rusted", "The Amber",
"The Quiet", "The Crooked", "The Old", "The Wandering", "The Salted", "The Ember"
};
private static readonly string[] ShopSecond =
{
"Anvil", "Kettle", "Lantern", "Compass", "Bellows", "Flask", "Ledger",
"Wagon", "Tankard", "Whetstone", "Sextant", "Coffer"
};
private static readonly string[] HouseNames =
{
"Ashwood Cottage", "Bramblegate", "Candlewick House", "Dovecote",
"Eastmarch", "Fernhollow", "Greywater", "Hearthstone",
"Ivyfall", "Kestrel Lodge", "Longmeadow", "Millrace",
"Northrest", "Oakenshaw", "Pinefall", "Quarrystone",
"Riverwatch", "Stonebrook", "Thistledown", "Umberley",
"Vinesend", "Westbarrow", "Yewcross", "Almsgate",
"Brightmoor", "Coldspring", "Duskvale", "Elmshade",
"Foxhollow", "Gravensward"
};
/// <summary>
/// One guild to build, and how many of the seeded characters to put in it.
///
/// Four rather than one, and four of different sizes, because every screen that
/// shows guilds shows a LIST: a board with one row proves nothing about sorting,
/// member counts or the online column. The sizes are the shape a small shard
/// actually has — one large guild, one middling, two small.
/// </summary>
private struct GuildPlan
{
public readonly string Name;
public readonly string Abbr;
public readonly int Size;
public GuildPlan(string name, string abbr, int size)
{
Name = name;
Abbr = abbr;
Size = size;
}
}
private static readonly GuildPlan[] GuildsToBuild =
{
new GuildPlan("The Ashen Compact", "ASH", 14),
new GuildPlan("Hollowell Rangers", "HOL", 9),
new GuildPlan("The Quiet Ledger", "QLG", 6),
new GuildPlan("Wardens of Northrest", "WRD", 4)
};
/// <summary>
/// The first two guilds are allied, because `/uo/guilds` promises "rosters,
/// alliances and who's online" and an alliance column that is empty on every row
/// reads as a feature that does not work.
/// </summary>
private const string AllianceName = "The Northern Compact";
public static void Initialize()
{
CommandSystem.Register("demodress", AccessLevel.Administrator, Dress_OnCommand);
if (Config.Get("Bridge.DemoDressOnStart", false))
EventSink.ServerStarted += () => Run(null, save: true);
}
[Usage("demodress")]
[Description("Renames BridgeSeeder's synthetic world so it is presentable in screenshots.")]
private static void Dress_OnCommand(CommandEventArgs e)
{
Run(e.Mobile, save: false);
}
private static void Report(Mobile to, string text)
{
Console.WriteLine("[BridgeDemoDress] " + text);
if (to != null)
to.SendMessage(text);
}
private static void Run(Mobile to, bool save)
{
try
{
var start = DateTime.UtcNow;
int chars = DressCharacters();
int vendors = DressVendors();
int houses = DressHouses();
// After the rename, never before: the roster the bridge publishes is the one
// that exists when the guild's member set first changes, and that is here.
int guilds = BuildGuilds(to);
bool password = SetLoginPassword(to);
Report(to, String.Format(
"Dressed {0} characters, {1} vendors, {2} house signs; " +
"built {3} guilds; login password {4}. ({5:F1}s)",
chars, vendors, houses, guilds, password ? "set" : "skipped",
(DateTime.UtcNow - start).TotalSeconds));
// IDOC is two steps, and at boot the second one is LATE. See PrimeIdoc.
Report(to, "Primed " + PrimeIdoc() + " houses for decay.");
if (save)
Timer.DelayCall(
TimeSpan.FromSeconds(IdocDelaySeconds),
() => Report(to, "Staged " + StageIdoc() + " houses into IDOC."));
else
Report(to, "Staged " + StageIdoc() + " houses into IDOC.");
if (save)
{
Report(to, "Saving world...");
World.Save();
Report(to, "Save complete.");
}
}
catch (Exception ex)
{
Report(to, "FAILED: " + ex);
}
}
/// <summary>
/// A stable index for a world object, salted so that two names drawn for the SAME
/// object land in unrelated places in their tables.
///
/// Serial is the only identifier that survives a save and is identical on every
/// load, which is what makes the naming reproducible. But serials are dense and
/// sequential, so a weak mix hands neighbouring objects neighbouring names. The
/// first attempt derived the second word from `serial / 5`, which is constant
/// across five consecutive serials — twenty-seven vendors came out as four
/// Flasks, four Lanterns and three Anvils in a row. Salting and re-mixing per
/// draw is what fixes that: each word is an independent hash of the pair.
/// </summary>
private static int Pick(int serial, int salt, int modulus)
{
unchecked
{
uint h = (uint)serial ^ ((uint)salt * 0x9E3779B1u);
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
return (int)(h % (uint)modulus);
}
}
private static string PersonName(int serial)
{
return Given[Pick(serial, 1, Given.Length)] + " " + Family[Pick(serial, 2, Family.Length)];
}
private static string ShopSign(int serial)
{
return ShopFirst[Pick(serial, 3, ShopFirst.Length)] + " " +
ShopSecond[Pick(serial, 4, ShopSecond.Length)];
}
private static bool LooksSeeded(string name, string marker)
{
return name != null && name.StartsWith(marker, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// True when a name is one this pass could have produced.
///
/// Dressing has to be re-runnable in both directions: a first pass renames what the
/// seeder left, and a later pass — after the tables or the hash change — has to be
/// able to rename its own earlier output. A name is recognised by MEMBERSHIP of the
/// closed tables rather than by a marker on the object, because the object is a
/// PlayerVendor whose name is a plain string with nowhere to hide a flag, and a
/// name that is not in the tables was set by a person and is left alone.
/// </summary>
private static bool IsOurs(string name, string[] first, string[] second)
{
if (String.IsNullOrEmpty(name))
return false;
foreach (var a in first)
{
if (!name.StartsWith(a + " ", StringComparison.Ordinal))
continue;
var rest = name.Substring(a.Length + 1);
foreach (var b in second)
{
if (rest == b)
return true;
}
}
return false;
}
private static bool IsOurHouseName(string name)
{
foreach (var h in HouseNames)
{
if (h == name)
return true;
}
return false;
}
private static int DressCharacters()
{
int n = 0;
foreach (Account acct in Accounts.GetAccounts())
{
if (!acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
continue;
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m == null || !LooksSeeded(m.Name, "Seed"))
continue;
// Offset by the slot so an account's three characters are three people
// rather than three spellings of one.
m.Name = PersonName(m.Serial.Value + i * 101);
n++;
}
}
return n;
}
private static int DressVendors()
{
int n = 0;
if (PlayerVendor.PlayerVendors == null)
return 0;
// PlayerVendors is a live collection; the rename does not add or remove members,
// but copy anyway so an unrelated vendor placement mid-pass cannot invalidate it.
var vendors = new List<PlayerVendor>(PlayerVendor.PlayerVendors);
foreach (var vendor in vendors)
{
bool touched = false;
// "Bridge Test Shop" is not the seeder's — it is left over from a hand-run
// smoke test — and it reaches the marketplace exactly like the rest.
if (LooksSeeded(vendor.ShopName, "Seed Shop") ||
LooksSeeded(vendor.ShopName, "Bridge Test") ||
IsOurs(vendor.ShopName, ShopFirst, ShopSecond))
{
var sign = ShopSign(vendor.Serial.Value);
if (sign != vendor.ShopName)
{
vendor.ShopName = sign;
touched = true;
}
}
if (LooksSeeded(vendor.Name, "seed vendor"))
{
vendor.Name = PersonName(vendor.Serial.Value + 7919);
touched = true;
}
if (touched)
n++;
}
return n;
}
private static int DressHouses()
{
int n = 0;
foreach (var house in BaseHouse.AllHouses)
{
if (house.Sign == null)
continue;
if (!LooksSeeded(house.Sign.Name, "Seed House") && !IsOurHouseName(house.Sign.Name))
continue;
var name = HouseNames[Pick(house.Serial.Value, 5, HouseNames.Length)];
if (name == house.Sign.Name)
continue;
house.Sign.Name = name;
n++;
}
return n;
}
/// <summary>
/// The houses this run picked to walk into IDOC, held between the two passes so the
/// second one moves the same houses the first one primed.
/// </summary>
private static readonly List<BaseHouse> _idocPicks = new List<BaseHouse>();
/// <summary>
/// Picks the houses that will collapse and puts them at a MIDDLE decay stage.
///
/// Only houses that CAN decay are touched — an active owner's AutoRefresh house is
/// left alone, because forcing one into IDOC would be inventing a state the game
/// would never produce and the next refresh would undo it anyway.
///
/// WHY THE STAGING IS TWO PASSES, WITH A WAIT BETWEEN THEM
/// ------------------------------------------------------
/// The website's "Houses in danger" page reads a column the ingest only writes when
/// the plugin reports a house CHANGING decay stage (`house.decay`). The richer
/// `house.update` registry frame carries the stage as well, but the ingest
/// deliberately leaves that column to the transition feed so the two cannot clobber
/// each other. A house that is ALREADY in IDOC when the site connects therefore
/// never appears: the plugin's baseline records IDOC as the starting state and no
/// transition is ever emitted. The first run of this pass hit exactly that — the
/// shard plainly had two collapsing houses and the page said none.
///
/// So: prime now, collapse later. The sweep takes its baseline at the middle stage
/// and then sees a real move to IDOC, which is the event the page is built to show.
/// The underlying asymmetry is a product observation, raised rather than patched
/// from here.
/// </summary>
private static int PrimeIdoc()
{
_idocPicks.Clear();
foreach (var house in BaseHouse.AllHouses)
{
if (_idocPicks.Count >= IdocHouses)
break;
if (house == null || house.Deleted || !house.CanDecay)
continue;
_idocPicks.Add(house);
}
// Most of the world cannot decay at all: a house whose owner's account is active
// is AutoRefresh, and AutoRefresh reports Ageless forever. The seeder condemned
// its houses by backdating the owner's last login, which is the same lever a real
// shard pulls when somebody stops playing — so where there are not enough
// candidates, condemn a few more the same way rather than forcing a stage that
// the next refresh would undo.
if (_idocPicks.Count < IdocHouses)
{
foreach (var house in BaseHouse.AllHouses)
{
if (_idocPicks.Count >= IdocHouses)
break;
if (house == null || house.Deleted || house.CanDecay || house.Owner == null)
continue;
var acct = house.Owner.Account as Account;
// Never the account somebody is about to log in with: an inactive account
// is exactly what this is making, and logging in would undo it anyway.
if (acct == null || acct.Username == LoginAccount)
continue;
acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(365);
if (house.CanDecay)
_idocPicks.Add(house);
}
}
foreach (var house in _idocPicks)
{
house.SetDynamicDecay(DecayLevel.Fairly);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
}
return _idocPicks.Count;
}
/// <summary>Collapses the primed houses. See <see cref="PrimeIdoc"/> for the two-step.</summary>
private static int StageIdoc()
{
int n = 0;
foreach (var house in _idocPicks)
{
if (house == null || house.Deleted)
continue;
// Alternating, so the page shows a stage column doing something rather than
// four identical rows.
house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
n++;
}
return n;
}
/// <summary>
/// Builds the guilds in <see cref="GuildsToBuild"/> out of seeded characters that
/// are not in a guild already, and allies the first two.
///
/// Idempotent by NAME: a guild that already exists is left exactly as it is, so a
/// second run adds nobody and a guild somebody has since edited in game is not
/// stamped back to the table. A character already in a guild is never moved, which
/// is what keeps a re-run from shuffling the world between screenshots.
///
/// Ranks are set rather than left at the default, because the roster the site draws
/// shows a rank per member and a page where every row says the same word tells a
/// reader nothing about what ranks are for. Real guilds are mostly members with a
/// couple of officers, so that is what this makes.
/// </summary>
private static int BuildGuilds(Mobile to)
{
var pool = UnguildedSeedCharacters();
var cursor = 0;
var made = 0;
var built = new List<Guild>();
foreach (var plan in GuildsToBuild)
{
var existing = FindGuild(plan.Name);
if (existing != null)
{
built.Add(existing);
continue;
}
if (cursor >= pool.Count)
{
Report(to, "Ran out of unguilded characters — " + plan.Name + " not built.");
break;
}
var leader = pool[cursor++];
var guild = new Guild(leader, plan.Name, plan.Abbr);
for (int i = 1; i < plan.Size && cursor < pool.Count; i++)
{
var member = pool[cursor++];
guild.AddMember(member);
var pm = member as PlayerMobile;
if (pm == null)
continue;
// Two officers per guild, then members. RankDefinition.Ranks is
// { Ronin, Member, Emissary, Warlord, Leader } — Ronin is the default a
// fresh member gets, and a board of Ronins looks like nobody has ever
// touched the guild.
pm.GuildRank =
i == 1 ? RankDefinition.Ranks[3] :
i == 2 ? RankDefinition.Ranks[2] :
RankDefinition.Member;
}
built.Add(guild);
made++;
}
if (built.Count >= 2 && built[0].Alliance == null && built[1].Alliance == null)
{
try
{
var alliance = new AllianceInfo(built[0], AllianceName, built[1]);
alliance.TurnToMember(built[1]);
}
catch (Exception ex)
{
Report(to, "Alliance not formed: " + ex.Message);
}
}
return made;
}
/// <summary>
/// Every seeded character with no guild, in a stable order: account name, then
/// character slot. Stable ordering is what makes the same person lead the same
/// guild on every run against the same save.
/// </summary>
private static List<Mobile> UnguildedSeedCharacters()
{
var accounts = new List<Account>();
foreach (Account acct in Accounts.GetAccounts())
{
if (acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
accounts.Add(acct);
}
accounts.Sort((a, b) => String.CompareOrdinal(a.Username, b.Username));
var chars = new List<Mobile>();
foreach (var acct in accounts)
{
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m == null || m.Deleted || m.Guild != null)
continue;
chars.Add(m);
}
}
return chars;
}
private static Guild FindGuild(string name)
{
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
if (g != null && !g.Disbanded && g.Name == name)
return g;
}
return null;
}
/// <summary>
/// Sets a known password on one seeded account so a character can be logged in with
/// a real client. The seeder assigns a random GUID, which nobody kept.
/// </summary>
private static bool SetLoginPassword(Mobile to)
{
var password = LoginPassword;
if (String.IsNullOrEmpty(password))
return false;
var acct = Accounts.GetAccount(LoginAccount) as Account;
if (acct == null)
{
Report(to, "No account " + LoginAccount + " — password not set.");
return false;
}
acct.SetPassword(password);
// The seeder backdates some accounts past InactiveDuration to condemn their
// houses. This one has to be able to log in, so bring it back to the present.
acct.LastLogin = DateTime.UtcNow;
return true;
}
}
}

View File

@@ -13,6 +13,7 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
| `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`. |
| `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`. |
## Deploy overwrites Bridge.cfg
@@ -34,6 +35,41 @@ Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.c
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
## Dressing a seeded world for screenshots
`BridgeSeeder` builds a world at realistic **scale**, which is all the bridge ever needed. It does not
build one that looks like anything: a vendor is `seed vendor` trading as `Seed Shop 810`, a character
is `Seed004A`, a house sign says `Seed House 12`. Those strings travel the whole bridge and land on
the marketplace, the guild roster and the housing pages of the website — fine for a protocol test,
wrong for a screenshot.
`BridgeDemoDress.cs` renames them in place. It seeds nothing: prices, listing counts, decay stages,
fame and skills stay exactly as the seeder left them and as the shard has moved them since, so the
data keeps its provenance and only the strings a human reads change. Names are drawn from fixed
tables by a hash of each object's serial, so a re-run reproduces the same world, and shop and house
names are re-dressed when they are names the pass itself produced — so a change to the tables can be
applied to a world that has already been through here.
```ini
DemoDressOnStart=True
DemoDressPassword=<a password you choose>
```
Boot once, then set `DemoDressOnStart=False`. The password is written to `seed_000` so a real client
can log a character in — the only way to make the website's online roster non-empty — and it is read
from the config rather than compiled in, so it never lands in source control.
**It dresses seeded objects only, which means your own characters keep their names.** That is the
right behaviour for a test shard and a thing to remember before pointing a camera at one: a dev
world usually also holds the accounts, characters, guilds and houses of whoever built it, and those
are real identifiers on a page that may end up public.
**The sidecar's board is cached, so the website lags a rename.** A shop name reaches the site on the
next market sweep, and a sweep advances `MarketSweepBatch` vendors per tick — 27 vendors at the
defaults is two ticks. Allow a couple of minutes before concluding that a rename failed. This cost a
debugging detour once: the shard had the new names all along and the sidecar was still serving the
previous ones.
## Back up `Saves/` first
`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.