41 Commits

Author SHA1 Message Date
956e3fb0b4 Merge pull request 'fix(release): ship server/commands, and check that the bundle is complete' (#18) from fix/bundle-ships-commands into main
All checks were successful
Release / release (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m2s
Reviewed-on: #18
2026-08-19 18:27:26 +00:00
3c179e3338 fix(release): ship server/commands, and check that the bundle is complete
All checks were successful
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m38s
v1.0.0 installed and then died on every boot:

  module "uo" failed to load — {"stage":"register","reason":"Cannot find
  module './commands/guild.command'"}

`server/commands/` arrived with the Teams cutover (2d1d91e, `/guild`). The
release assembles the tarball from an include list, that list was hardcoded in
release.yml, and it was never told about the new directory — so the bundle
shipped without it and the module was dead on the operator's box.

Nothing caught it, and that is the more interesting half. Every PR check runs
against the whole repo — `frozen-manifest` even installs the module into core by
tarring the entire tree — but a release is a SUBSET of the repo, and the subset
exists nowhere except the release. The pre-publish check in release.yml only
stats the paths `module.json` declares, and a file reached by a require inside
`register()` is named in none of them, so it passed on a bundle that could not
load.

The include list stays an include list — release.yml's header makes that case
and it still holds. What changes is that it is declared ONCE, in ci/bundle.json,
with two readers instead of one:

  • release.yml assembles from it (via jq) rather than from its own copy.
  • server/scripts/checkBundle.js asks, in PR checks, whether it still covers
    everything `server/index.js` reaches — following requires transitively and
    through function bodies, which is where index.js deliberately puts them.

And the release gains a real loadability check: `checkBundle.js --bundle` walks
the ASSEMBLED tree and asserts every relative require resolves inside it. Asked
of the artifact rather than the source, so it also catches a half-failed copy or
a list naming a path that has moved.

Requiring the entry point would not have worked as a check: index.js requires
inside `register()` because require order is load-bearing (`core.init(ctx)` must
run before anything under `router/`), so requiring it evaluates one line and
reports success on a bundle missing every router it has.

Both modes were verified against the real defect — each fails with `commands`
removed and passes with it present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
2026-08-19 13:25:57 -05:00
16cfbe194d Merge pull request 'ci(release): derive the version from commit subjects, and add a manual backdoor' (#17) from feature/release-per-merge into main
All checks were successful
Release / release (push) Successful in -30s
SonarQube / analysis (push) Successful in 1m47s
Reviewed-on: #17
2026-08-19 18:07:55 +00:00
8ec21086b5 ci(release): derive the version from commit subjects, and add a manual backdoor
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 56s
A bundle used to cost a second pull request whose entire content was a number.
The workflow published only when a merge to `main` left `module.json` at a
version with no release yet, so a merge that did not touch that line released
nothing. Measured rather than argued: v0.3.0 (2026-08-12) is the only release
this repo has ever cut, while the nine Teams phases and the cutover landed on
`main` in the week since.

Adopt the engine `link` and `installer` already run - feat!/BREAKING CHANGE ->
major, feat -> minor, fix|perf -> patch, nothing releasable -> no release. The
number that ships is the tag, and CI writes it into the `module.json` inside the
bundle, with an assertion that the rewrite happened: a bundle carrying the wrong
version would install under a number that is not the one it was released as.

`module.json`'s version is kept as a floor rather than deleted - a version above
the newest tag still releases at that version - so the declared model survives as
the special case it always was, and is still how a `coreApi` bump overrules the
subjects. `workflow_dispatch` covers what the rules cannot reach: leave `version`
blank to bump the newest tag by `bump`, or name an exact version. Where the log
and the input disagree the larger bump wins, because a button pressed on a log
full of `feat:` would otherwise publish its `patch` default over a minor's worth
of work.

Two things carried over from `link` at the same time: a tag pushed without a
release behind it is now recovered rather than making that state permanent, and
the changelog moved into the plan step (so the assemble step clears `$OUT`, not
`dist/`, which now holds it).

On this repo's `main` the engine computes v0.4.0. The workflow still never
writes to a branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 13:01:31 -05:00
637121bce3 Merge pull request 'feat(guilds): a UO guild is a Team (Teams cutover 5/6)' (#16) from edge into main
All checks were successful
SonarQube / analysis (push) Successful in 2m18s
Release / release (push) Successful in 7s
Reviewed-on: #16
2026-08-19 09:02:38 +00:00
fe176920c5 ci(core-ref): pin to the Teams cutover, not a phase 3 edge sha
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / frozen-manifest (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 8m40s
The pin said "edge @ Teams phase 3" and edge is about to stop existing. More to
the point, this module has since gained the Team provider, three declared slots,
the /guild command and the contribution names on those slots - none of which the
pinned core knew about, so the frozen-manifest job has been proving this module
against a core older than half of it.

routes.manifest.json does not move: the job compares core-with-module against
core-without-module, so core's own Teams routes cancel out and what is left is
this module's 73, unchanged. Verified locally against the cutover core before
moving the pin rather than after.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:00:01 -05:00
d98f0c1a3d Merge pull request 'feat(guilds): name the core contribution each declared slot wants' (#15) from feature/teams-slot-contributions into edge
Reviewed-on: #15
2026-08-19 06:19:52 +00:00
1a13f680f5 feat(guilds): name the core contribution each declared slot wants
Some checks failed
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Failing after 47s
PR Checks / server-tests (pull_request) Failing after 13m23s
Core no longer fills a slot by name - it offers a contribution and the module
that owns the page says where each one goes (MODULE_API 1.6.0, amended). The
three slot names are unchanged and stay this module's own vocabulary; what is
new is the second argument saying which of core's three contributions belongs
in each place.

Nothing here worked differently before. The change is for every game that is
not this one: core used to fill the literal name uo.guild.detail, so a second
module declaring a place under its own id got an empty page and no error.

The registration fake gained the same validation core does, including the
contribution catalogue - written down rather than imported, since this suite
runs against the built chunk with no core in the process, which makes it a
claim about core that has to be re-read when core's list changes.

42 client tests, 437 server tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:15:49 -05:00
7d0378842b Merge pull request 'feat(guilds): /guild — the module's own chat command' (#14) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #14
2026-08-19 00:16:11 +00:00
466842c6f2 fix(guilds): do not offer linking where linking cannot reach
Some checks failed
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 35s
Found on the live rig, with the shard's guild feature gated to staff: the
refusal still read "link your account — this shard shows guild information to
linked players". Signing in reaches `logged_in` and linking a game account
reaches `player`; `staff` and `admin` are roles an operator grants, and no
amount of linking earns them. Inviting someone to do something that changes
nothing is worse than plainly saying no.

Also drops the host name from the list embed's title. `ctx.site` carries a base
URL and no brand name, so naming the deployment there could only ever mean
printing its hostname into a title on the shard's own Discord server.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:22 -05:00
2d1d91e372 feat(guilds): /guild, the module's own chat command
The first command through `api.registerSlashCommands` (MODULE_API 1.6.0,
TEAMS.md §7.1). The definition and the handler both live here; the bot pulls the
definition and runs no line of this module.

`/guild` and not `/team`, deliberately. Core does not own the word for a Team —
that is what deleted its Team pages in phase 3 — so it does not publish the noun
in a channel either. Core ships the dispatcher and zero commands.

The audience rungs are re-resolved in the handler rather than assumed: a shard
that gates guilds to staff does not become public because the question arrived
over Discord. The provider's own staleness guard is honoured too, so a stale
board answers "not connected" instead of reporting what it still holds, and
`resolveUserId` is exported rather than copied so "linked" means here what it
means on the roster.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:45 -05:00
990a50b491 Merge pull request 'feat(guilds): a third place on the guild page, and where that page lives' (#13) from feature/teams-phase6-notifications into edge
Reviewed-on: #13
2026-08-18 23:10:02 +00:00
c57310c505 feat(guilds): a third place on the guild page, and where that page lives
All checks were successful
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 34s
Two lines only core cannot supply for itself.

`uo.guild.header` is a third declared slot, at the top of the page, for core's
per-Team notification control. A third rather than a corner of the feed because a
slot holds one component and the first fill wins: the control is an action ON this
page and the other two are content IN it, and separate slots are what let this
module say so.

`pageUrlTemplate` tells core where a guild page actually is. Teams are a contract
primitive with no core surface — core owns the tables and the access rules, this
module owns the word "guild" and therefore the page — which leaves core unable to
write a link to one. A notification email that cannot take you to the thread it is
about is most of the way to useless. Core substitutes `{externalId}` and does
nothing else with it; a template naming its own host is refused at registration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:36 -05:00
7ce78e303c Merge pull request 'feat(guilds): declare a second place on the guild page, for core's forum' (#12) from feature/teams-phase4-forum-slot into edge
Reviewed-on: #12
2026-08-18 14:17:46 +00:00
46e3f5a127 ci(core-ref): bump the pin past registerTeamProvider
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 42s
`frozen-manifest` has been failing since Teams phase 2, on this PR and on #11
before it, for a reason that has nothing to do with either: the pinned core
(website#140, the module-system de-UO slice) predates `api.registerTeamProvider`,
which this module has called since phase 1 of its Teams work. The module therefore
fails to LOAD in the pinned checkout — "api.registerTeamProvider is not a function"
— and a module that does not load adds no routes, which the job correctly reports
as the module having removed everything it serves.

So the red was real and was pointing at the pin, exactly as the pin's own comment
says it should: core moves for reasons that have nothing to do with this module,
and a bump is a deliberate commit saying which core the module was last proved
against.

Bumped to `edge` at Teams phase 3 (website#152) — the first core that has both
`registerTeamProvider` and the roster projection this module now implements.
Reproduced the whole job locally against that core: core's own manifest is current
at the new pin, the module loads, and the difference is 73 routes, all documented.
`routes.manifest.json` is unchanged and needed no regeneration, which is the
expected result for a client-only change.

Not bumped to the phase 4 core, deliberately: that is website#153 and is not merged
yet. Nothing in this module needs it — the second slot is a client-side
declaration, invisible to the route manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:31:58 -05:00
9d0a197008 feat(guilds): declare a second place on the guild page, for core's forum
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m40s
The mirror of the activity feed, one phase later. Core owns the Team forum —
membership, manual grants and the member/guest split are all core's rules, and a
module reimplementing any of them would be reimplementing a security boundary — but
core publishes no Team page, because it does not own the word "guild". So this
module declares the place and core puts the forum in it.

TWO declarations rather than one, and that is the interesting part. A slot holds one
component and the first fill wins, so folding the forum into `uo.guild.detail`
alongside the feed would hand core the decision about where each of its two
contributions sits on a page this module owns. Separate slots also keep them
independent: with the forum switched off, the feed renders exactly as before.

The registration test now asserts the set of declared slots and that EVERY one of
them is rendered by the page that owns it, rather than naming a single slot twice.
A slot nothing renders is a slot core fills into the void.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:37 -05:00
eb30e4ae37 Merge pull request 'feat(guilds): the roster projection, a guild detail page, and the slot core fills' (#11) from feat/teams-phase3-projection-slot into edge
Reviewed-on: #11
2026-08-18 02:12:18 +00:00
dda0e32dd3 feat(guilds): a guild detail page, and the slot core puts the feed in
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Failing after 33s
PR Checks / server-tests (pull_request) Successful in 8m46s
The module's half of the org lead's correction: Teams is the contract, guilds
are the presentation, and the presentation is this module's.

Adds `/uo/guilds/:id` — the detail view the board never had — with the roster
from this module's OWN board, which is the same data it answers core's Team
provider from. Reading core's projection of our own answer back would be a round
trip through a staler copy of it.

The page declares `uo.guild.detail` and core fills it with the Team activity
feed. That is the one part of this page core cannot hand over: only core can
resolve whether the viewer is inside the Team, and the public/members split on
that feed is a security boundary. The guild is named in OUR terms — core maps
its own Team from the module id and the external id — so this module never holds
core's row id or slug.

`TeamOverviewStrip` is deleted with the core Team page it filled.
`team.member.row` is not declared here either: the useful thing to put in a
roster row is a link to the character behind it, and nothing core could supply
identifies one.

`GET /public/shard/guilds/:id` backs the page, gated and projected through the
same `guilds` feature as the board — so an operator who raises that audience
raises this too, and the locked acct/webId fields never survive below admin. A
roster is where those appear in bulk, which makes this the endpoint where
getting the projection wrong would matter most.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:58:30 -05:00
d4aa5ade12 feat(teams): project rosters by audience rung, and add to the Team page
The module's half of TEAMS.md phase 3.

`projectRoster` is the optional fourth provider method and the only one core
calls on a request path. Core holds the roster and owns its public shape; the
question that is this module's is who is allowed to look, because the audience
rungs and their configuration live here.

The answer is all-or-nothing, which is the honest translation rather than a
shortcut: a rung is a property of the FEATURE, and there is no configuration in
which some members of a guild are public and others are not.

The refusal semantics INVERT here, and the tests say so. For the other three
methods a refusal means "change nothing" and an empty array would be
destructive. Core fails CLOSED on this one, so the dangerous answer is the
opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff. Every path that cannot reach a
confident answer refuses, including the catch.

The anonymous case is answered directly rather than by handing `viewerLevel` a
synthetic request. Given one with no `req.user` it falls through to
`auth.getUserFromRequest`, which expects real cookies and throws on a fake — and
that throw would have become a refusal, so every anonymous visitor would have
been served an empty roster on a shard whose guilds are public. Caught by the
tests, not by reading.

`team.overview` gets a live population reading beside core's stored one. Core's
number comes from the last roster sync and is coarse by construction; this is
the `presence.online` feed this module already holds. It is explicitly not a
per-Team presence figure — the shard publishes a global aggregate and no
per-guild breakdown exists on the wire, so claiming one would be inventing a
number — and it renders nothing at all when it has nothing true to say.

`team.member.row` is left unfilled. The useful thing to put there is a link to
the character behind a row, and the props core can supply do not identify one:
the member key and the site account id are withheld from every public roster.
An empty cell beats a guess.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:16:22 -05:00
0d618599cf Merge pull request 'feat(teams): ingest guild rank, and report every leader rather than one' (#10) from feat/protocol4-guild-rank into edge
Reviewed-on: #10
2026-08-17 22:48:54 +00:00
76b2321f25 Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:48:20 +00:00
99d1ca25a7 feat(teams): ingest guild rank, and report every leader rather than one
Some checks failed
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Failing after 34s
The module half of the Protocol 4 rank amendment (servuo-plugins, same wire
version -- Protocol 4 is unreleased on `edge`, so it is amended rather than
bumped).

`shard_guild_members` gains `rank`, `rank_cliloc` and `rank_name`. The provider
then answers the question it previously could not: `getTeamLeaders()` returns
EVERY member at rank 4, not just the board's single `leader_serial`. That
limitation was the whole reason the wire grew a per-member rank -- TEAMS.md §2.5
treats multiple leaders as the normal case and core has always supported them.

The board's `leader_serial` is folded in as a floor rather than replaced. It
comes from a different frame, so on a shard whose roster has not been re-emitted
since the amendment it is the only leadership signal there is, and moving to
ranks must not lose it.

## NULL rank is a real state, and it is load-bearing

The shard withholds the rank for a staff account, because ServUO's
`PlayerMobile.GuildRank` reports Leader for anyone at GameMaster or above
whatever their actual rank. Every layer here preserves that:

  - the ingest stores NULL rather than defaulting to 0, which would be a
    demotion this code invented;
  - `leader` requires an integer rank >= 4, so absence is never leadership;
  - the leaders query compares on `rank`, and NULL is excluded by the comparison.

Reading a missing rank as either 0 or "leader" would republish the exact lie the
shard went out of its way not to send.

## Rank labels

Three sources, in order: a custom rank's literal string, then the operator's
cliloc table, then the five standard names. The last exists because the cliloc
table is populated only if someone ran the client-file extraction, and a roster
on a shard that has not should still read "Warlord" rather than nothing. A
failing lookup falls back rather than failing the roster -- a label is decoration,
and losing it must not lose the data.

`rank` is backticked everywhere it is written, like `int` on shard_online: it is
reserved in MySQL 8 and merely a keyword in MariaDB, so it parses bare here and
must not be relied on to.

The schema fragment carries ALTERs as well as the CREATE. No production install
has this table -- it is new in an unreleased protocol -- but `edge` deployments do,
from the roster work that landed before the amendment, and CREATE TABLE IF NOT
EXISTS adds a table and never a column. Same gap the sidecar's own store hit when
`guilds.members` was added.

## Verification

The unit tests stub the db layer, so the round trip was proved separately: the
VERBATIM roster frame captured from the live ServUO run was fed through the real
ingest into MariaDB and then read back through the provider.

  stored:   0x1F5 rank=4  0x1F6 rank=3  0x1F7 rank=2  0x1F8 rank=1
            0x1F9 rank=NULL (the GameMaster)  0x2E0 rank=4
  provider: leaders = [0x1F5, 0x2E0]   <- two, which the board alone cannot express
            labels  = Leader / Warlord / Emissary / Member, with no cliloc table
            0x1F9   = not a leader, no label

9/9 checks. Suite 413 -> 421 tests, all passing.

Refs docs/link/v4.md §2.3, docs/website/TEAMS.md §2.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:42:32 -05:00
c6929c6bae fix(teams): take query from the core facade, not a core.db that does not exist
Some checks failed
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Failing after 40s
The Team provider's db layer built its query helper as `core.db.query(...)`. The
facade has no `db` member -- every other *.db.js in this module destructures
`query` from it directly -- so every call threw `Cannot read properties of
undefined (reading 'query')`.

The failure mode is the bad part. That throw is caught by the provider's own
error handling and turned into `{ ok: false, reason: 'roster unreadable: …' }`,
which is a perfectly valid refusal -- so core would have accepted it, held the
projection it had, and reported staleness. A provider that answers correctly and
never returns data, forever, with nothing in any log louder than a warning.

Invisible to the unit tests because they stub every db function, so the helper
was never called. Found by running a real roster frame through the ingest and
then asking the provider what it saw, against the real database.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:41:22 -05:00
51e58104bf Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:19:01 +00:00
268449f2a6 feat(teams): answer core's Team provider from the guild board
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 44s
A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.

`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.

The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.

**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.

A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.

Two limitations, both honest and both in the code as comments:

  - **`rankLabel` is null.** The wire's roster member is the standard actor object
    (`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
    Inventing a label from the leader flag would be core displaying something this
    module made up.

  - **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
    `GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
    on the wire, so the only leadership visible here is the board's single
    `leader_serial`. Raising it to the full set is a protocol change, not
    something this module can fix.

`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.

`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.

18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.

`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.

411 -> 413 tests, all passing.

Refs docs/website/TEAMS.md §2.3, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:31:58 -05:00
e93361aa48 Merge pull request 'feat(shard): ingest guild rosters and departures (protocol 4)' (#8) from feat/teams-phase1-guild-roster into edge
Reviewed-on: #8
2026-08-17 19:29:05 +00:00
2fa4d87a40 feat(shard): ingest guild rosters and departures (protocol 4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00
97e2fddfcd Merge pull request 'chore(ci): scan this repo with SonarQube (phase 4, slice 0)' (#7) from chore/sonarqube into main
All checks were successful
Release / release (push) Successful in 8s
SonarQube / analysis (push) Successful in 1m54s
Reviewed-on: #7
2026-08-12 07:37:39 +00:00
62c8ee68b4 chore(ci): scan this repo with SonarQube (phase 4, slice 0)
All checks were successful
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 8m50s
Until now this was the one part of the platform that had never been scanned.
The 75 files here arrived in the Phase 3 extraction and left their Sonar
history behind in core's project, so a whole module's worth of shipped code
has no dashboard at all.

Adds sonar-project.properties (project key Module-uo) and a sonarqube.yml
mirroring website's: push to main, never a PR gate, nothing waiting on the
quality gate.

Two things differ from core's config, both because this repo is shaped
differently:

  - There is no src/ to point sonar.sources at — the server half keeps
    boot.js/core.js/index.js at server/ root beside its subdirectories — so
    the whole tree is included and the non-source parts are excluded. That
    direction is deliberate: a new top-level server directory is scanned by
    default rather than silently unscanned.
  - server/scripts and client/scripts are IN. checkImports.js and
    checkExternals.js are the enforcement of MODULE_API.md 5.1 and 3.6, they
    carry their own test suites, and both have already shipped defects a
    reviewer missed. Build code that decides whether a release is allowed out
    is not throwaway code.

The workflow builds the client chunk before running either suite, for the
reason pr-checks.yml already calls load-bearing: build.test.js and
registration.test.js read dist/entry.js and SKIP without it, so the other
order reports coverage for a suite that quietly asked less than it looks like
it did.

Both suites run from the repo root rather than with --prefix, so the LCOV SF:
paths come out repo-root-relative and resolve against sonar.sources. That is
why the server suite's --require is spelled out here instead of reusing
`npm test --prefix server`, whose path is relative to server/.

Verified locally: 385 server test cases across 57 covered files and 40 client
cases, both LCOV and Generic Test Execution XML well-formed with
repo-root-relative paths.

Needs one-time setup in the Gitea UI before it can run — secret SONAR_TOKEN
and variable SONAR_HOST_URL, same as the other repos.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 02:36:11 -05:00
e81b61d044 Merge pull request 'feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)' (#6) from feature/close-phase3 into main
All checks were successful
Release / release (push) Successful in 24s
Reviewed-on: #6
2026-08-12 04:11:29 +00:00
a0c24456c7 ci: retry npm registry reads before failing a check
All checks were successful
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 33s
The frozen-manifest job read ETIMEDOUT from the registry installing the client
deps, after it had already cloned core at the pin and proved core's own manifest
regenerates — a red X that meant nothing about this PR. There are five `npm ci`
calls across the three jobs and the runner is shared, so this will recur.

npm's own retry, turned up at the workflow level so every install gets it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 23:08:48 -05:00
044211fd41 fix(docs): repair the escaped apostrophes, and document this module's env vars
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m49s
Nineteen `#swagger` descriptions carried a `\'` inside a single-quoted string.
That is correct JavaScript and wrong here: swagger-autogen does not evaluate the
annotation as JS, so the backslash survives into the spec and Swagger UI renders
"the shard\'s published ruleset" to a reader. Replaced with a typographic
apostrophe, which the same files already use elsewhere.

Found by opening /api/docs in a browser against a real core with this module
installed — the fragment was valid JSON, the paths were right, every test passed,
and it was still wrong on screen. Nothing that reads the artifact can see this;
only reading the rendered page can.

Also documents the four environment variables this module reads
(UOLINK_BASE_URL / _WS_URL / _PROTOCOL, TOWNCRIER_DURATION_SEC). Core's
.env.example is dropping them in the paired website PR: they were never core's,
and a half-copy in two repos goes stale silently.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:57:00 -05:00
5cdcf0fbb6 feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 8m49s
The three artifacts that make this module installable and checkable, closing
phase 3's extraction. Nothing about what the module serves changes: the same 72
URLs, the same behaviour.

**The OpenAPI fragment (MODULE_API.md §2.8, §6.1a) was never built, on either
side.** The 417 `#swagger` annotations came across in slice 1 and went nowhere,
and core's /api/docs.json merged nothing — so every route this module serves was
in no spec at all, which is core's standing rule ("never ship a route that isn't
in the spec") being broken by the extraction rather than by a route.

`server/scripts/swaggerFragment.js` generates it. The prefixes are DERIVED: the
script runs the module's own `register()` against a recording api and asks
`require.cache` which file each router came from, so a mount prefix exists in one
place — `server/index.js` — and not in a table beside it. The 31 schemas moved
here from core's swagger.js, namespaced `Uo…` because core wins every key
collision in the merge; `Error` and `ValidationError` stay referenced by core's
names, since they resolve in the merged document.

**The frozen route manifest (§5.3)** is derived too, and by subtraction: CI
clones core at the ref pinned in ci/core-ref.json, generates its manifest without
this module and then with it, and the difference is what this module serves. That
buys the half of §5.3 that matters most for free — a module that shadowed or
displaced one of core's routes shows up as a REMOVAL, not merely as an addition
elsewhere. The same job checks the fragment against ground truth: every route
must have an operation and every operation must be a route.

**The release workflow** publishes `module-uo-<version>.tar.gz` plus a manifest
carrying its sha256. The version is declared in module.json rather than computed
from commit subjects, and the workflow never writes to a branch — it tags and
publishes — so `main` needs no push exception. The bundle is assembled from an
include list, because an exclude list ships whatever it forgot.

Four annotation defects, inherited from core and never visible until something
generated a spec from these files: two `requestBody` literals a brace short (the
route documented with an empty body), and two descriptions whose inner quoting
swagger-autogen cannot survive — it re-quotes `"` and a backtick to `'` before
evaluating, so either inside a single-quoted description ends the string early
and the annotation is dropped. It reports each one and then prints Success in
green, so the generator now captures its diagnostics and makes them fatal.

Also fixed while writing it: passing one shared `doc` to swagger-autogen six
times. It renders components.schemas from an EXAMPLE object and writes the result
back into what it was handed, so each pass re-wrapped the last and the fragment
came out at 484 MB.

- 409 server tests (+21), 40 client tests unchanged
- swagger-fragment.json: 69 paths covering all 72 routes
- routes.manifest.json: 72 routes; core's own surface unchanged, 0 removals
- verified end to end by assembling the bundle exactly as CI will, unpacking it
  into a real core and regenerating the manifest

Refs: docs/website/MODULE_SYSTEM.md §2.7.1, MODULE_API.md §2.8, §5.3, §6.1a

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:40:21 -05:00
e468bbd3b9 Merge pull request 'fix(db): own the two settings seeds, and repair the protocol-3 one-shot (phase 3, slice 4)' (#5) from feature/own-settings-seeds into main
Reviewed-on: #5
2026-08-12 03:01:32 +00:00
d70e5e10d0 test(client): assert the URLs this module calls
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 8m45s
Five assertions that stayed behind in core's `apiClient.test.js` when the
bindings moved in slice 1 — the atlas-vs-shard path split, the query-string
filtering, the slug encoding, the admin atlas methods — plus a new one pinning
the seven admin URLs the shipped Android app calls by name. They were asserting
UO URLs from inside core's suite, which is the boundary Phase 3 removes, and
core's slice-4 deletion of those bindings would otherwise have deleted the
coverage with them.

The fake `window.__rg` carries the REAL react/react-dom/router rather than
stubs: `src/core.js` compares its imported bindings against the published ones
and logs a "bundled its own copy" error when they differ, so stubs make every
run of this file print the exact wording of a real defect.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:31:49 -05:00
f7bb3d912e fix(db): own the two settings seeds, and repair the protocol-3 one-shot
Core seeded `game_account_signup` and `uo_link_protocol_3_migrated`, two keys
that name a game concept. That made core's schema declare a module's settings,
which is the structural half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1,
slice 4). Both INSERTs move here. The keys are deliberately unchanged: they are
live rows on every existing install and renaming one silently resets an
operator's choice to the default.

The marker is not just a tidy-up. It and the `UPDATE uo_link_config SET
protocol = 3` it makes one-shot were adjacent in core's schema.sql until slice 1
moved the UPDATE here and left the INSERT behind — and the two files do not run
together: core's schema is replayed in full before any module fragment. So the
marker existed before the UPDATE ever read it, the NOT EXISTS guard was false on
every boot of an upgraded install, and the migration could never fire. An
install carrying a protocol-2 row would have stayed pinned at 2 against a v3
sidecar, 409ing every REST call — the exact failure the migration prevents.
Latent rather than live: it bites only an install that first boots a
post-slice-1 build while already holding a uo_link_config row, and `edge` has
not cut over.

`schemaFragment.test.js` asserts the order, plus the fragment rules core
validates at load time (leading-verb allowlist, IF NOT EXISTS, grandfathered
table prefixes) — restated here for the same reason manifest.test.js restates
the manifest rules. Its statement splitter is a character walk, because a
comment in this file contains quotes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:27:52 -05:00
ba092efd8c Merge pull request 'feat(client): the whole client half (phase 3, slice 3)' (#4) from feature/module-extract-client into main
Reviewed-on: #4
2026-08-12 00:35:33 +00:00
9d559091c5 fix(client): give the portal row an icon, and assert every gated nav has one
All checks were successful
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / client-build (pull_request) Successful in 8m49s
The §7.7 smoke, running the pair together: registering the player row without an
`icon` blanked the whole portal with React error #130, because core's
PlayerPortalLayout rendered `<n.icon />` unguarded. Core is guarded now
(website), and this is the other half — the row had an icon before it moved and
should have kept one.

A second glyph rather than reusing IconShard: these two rows sit in different
navs and each matched its neighbours before the extraction. The admin sidebar's
UO rows were gems; the portal's Characters row was a person beside Appeals'
shield and Account's gear. Matching the nav a row lands in is the whole reason
`icon` is in the contract.

`registration.test.js` now asserts it for admin AND player rows, which is the
cheap place to catch the next one. The public header is text buttons and is
deliberately excluded.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:54:42 -05:00
e4af7dd9a8 test(client): check what the chunk registers, and fix two defects in the checks
Three things, all about checks that had never met a real chunk.

`checkExternals.js` rejected slice 3's build outright, naming a fragment of
minified JSX as an imported specifier: a button reading "Approve and import"
puts the token immediately before a quote, and no regexp can tell that from a
statement. Same wall the server's `checkImports.js` hit, answered the same way —
a character walk. A mask rather than a rewrite, because a real import has its
keyword outside a string and its specifier inside one.

Writing the test for that false positive found the false NEGATIVE underneath
it: the pattern required whitespace after `import`, so it could not see
`import{useState}from"react"` — the one shape a minified build actually emits,
and the most likely way for a missed alias to reach production. It has never
been able to see it.

`registration.test.js` is new: stand up a fake `window.__rg` with a recording
registry and the real React, import the BUILT chunk, and read back what it
asked for. No DOM, because nothing renders. It holds the agreement that rots
quietly — every nav row points at a route this module actually registered —
rather than restating both lists.

CI now builds before it tests, because both of those read `dist/entry.js` and
skip without it. Run the other way round they are green and asking nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:00:50 -05:00
493cf296ab fix(server): own game-account signup, and repair the gate slice 1 broke
`POST /player/shard/account` and its staff twin have answered 500 for every
caller since slice 1: the ported controller called
`settings.isGameAccountSignupEnabled()`, which is a member of core's settings
model and not of `ctx.settings` — three functions, deliberately. The call was
`undefined(...)`, the TypeError landed in the catch, and no test reached the
branch.

The gate now lives on the side that uses it (`utils/gameSignup.js`), which is
also where the policy belongs: the setting's own help text names Bridge.cfg and
says the shard's SignupMode must agree, and core cannot own a sentence about a
UO shard. The admin field moves to this module's Shard page and the derived
flag onto `/public/shard/features`, beside the visibility flags the same
callers already read.

The setting KEY is unchanged. Renaming `game_account_signup` would silently
reset every configured instance to `disabled` on upgrade, with players
reporting broken signup as the only clue — the same grandfathering as
`spawn_atlas_servuo_path` and the seven stream ids.

Both regression tests were shown to fail against the bug before it was fixed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:00:37 -05:00
28f4b9afe2 feat(client): the whole client half (phase 3, slice 3)
The 35 files behind twelve public pages, seven admin views, two player views
and three core-page extensions, ported onto `window.__rg`. Every one of them
imports exactly the seven kit members plus `lib/format.js`, which is the
finding §2.7.1 predicted and this confirms.

`client/src/core.js` is the port mechanism, and unlike the server's it is a
plain read: `window.__rg` is published before any module chunk evaluates, so
there is no gap to defer around and a ported component keeps its ordinary
import shape. `client/src/api.js` rebuilds the UO namespaces over the request
primitive — same URLs, because §1.2 freezes the API surface.

SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the
admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a
clean break being the only moment that is free.

`shim/rg.js` becomes the single reader of the global, so the "core did not
publish its dependencies" message is reachable from whichever module the
bundler happens to touch first rather than from whichever one is imported
first — a guarantee that used to last until someone sorted the imports.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:00:25 -05:00
103 changed files with 20632 additions and 223 deletions

View File

@@ -16,6 +16,17 @@
# tree works right up until core moves a file, and the whole boundary is
# worth exactly as much as this check is (§5.1).
#
# • `server: check:bundle` — the release ships everything the entry point can
# reach. Every other job here runs against the whole repo, but a release is a
# SUBSET of it (release.yml assembles from the include list in
# `ci/bundle.json`), and nothing compared the two. On 2026-08-19 they
# disagreed: `server/commands/` arrived with the Teams cutover, the include
# list did not learn about it, and v1.0.0 installed and then died at the
# register stage on the operator's box with "Cannot find module
# './commands/guild.command'". Green here, broken there — because the subset
# only exists in the release. This asks, on the PR that adds the directory,
# whether the list still covers what index.js reaches.
#
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
# failure is invisible in source: `import { useState } from 'react'` is
# correct in every file, and whether it becomes core's React or a bare
@@ -27,11 +38,29 @@
# Building the chunk in CI is not only a check: it is how the chunk that ships is
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
#
# Not here yet, deliberately, because there is nothing for them to act on until
# the extraction is further along: the release workflow (the
# `module-uo-<version>.tar.gz` artifact and its sha256 manifest) and the module's
# own frozen route manifest, which needs core checked out at a pinned ref
# (MODULE_API.md §5.3). Each lands with the slice it checks.
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
# module registers, today. Core has no way to generate it: core is a prebuilt
# image, this module arrived on a volume afterwards, and it mounts through a
# call no static parser can follow. So the fragment core merges into
# `/api/docs.json` is whatever this repo committed, and a stale one documents
# a URL surface that does not exist (§2.8).
#
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
# (without this module, then with) and takes the difference. That difference
# is what this module serves, and it is checked three ways: it must match the
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
# core's own routes, and every route in it must have an operation in
# `swagger-fragment.json` — the per-module form of core's rule that a route
# which isn't in the spec doesn't ship (§5.3, §2.8).
#
# Nothing else can ask those questions. Every other check here runs against
# this repo alone, where a mount prefix is a string in `server/index.js` and a
# documented path is a string in a JSON file; whether they name the same URL
# is a fact about a running core, and this is the only job that has one.
#
# Still not here, deliberately: nothing. The release workflow is
# `.gitea/workflows/release.yml` and runs on a tag rather than on a PR.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
@@ -43,18 +72,34 @@
#
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
# Node — no Docker socket, no database.
#
# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there
# first, so gating only the `main` hop would run these checks for the first time
# at the cutover — the one moment a red build is most expensive to discover. This
# is the same call `RunicGateway/installer` made for the same reason, and it was
# taken here after a nine-PR Android workstream landed on an ungated `edge` with
# no CI at all. Adding a branch to the `branches:` list is the whole change.
name: PR Checks
on:
pull_request:
branches: [main]
branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
# often enough to matter, and there are five `npm ci` calls across these jobs — a
# red X that means "the network hiccuped" costs a reviewer more than it costs the
# runner to retry, and teaches everyone to re-run rather than read a failure.
env:
NPM_CONFIG_FETCH_RETRIES: 5
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
jobs:
server-tests:
runs-on: ubuntu-latest
@@ -79,6 +124,12 @@ jobs:
- name: Check the module boundary (MODULE_API.md §5.1)
run: npm run check:imports --prefix server
- name: Check the release ships what the module requires
run: npm run check:bundle --prefix server
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
run: npm run check:swagger --prefix server
client-build:
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -94,11 +145,83 @@ jobs:
- name: Install client deps
run: npm ci --prefix client
- name: Run client tests
run: npm test --prefix client
# The build comes FIRST, and that ordering is load-bearing as of slice 3.
# Two of the client tests read `dist/entry.js` — the chunk's externals, and
# what it registers when imported against a fake `window.__rg` — and both
# skip when there is no build. Run the other way round they skip silently
# in CI, which is the worst of both: green, and not asking the question.
- name: Build the client chunk
run: npm run build --prefix client
- name: Run client tests
run: npm test --prefix client
- name: Check the built chunk's externals (MODULE_API.md §3.6)
run: npm run check:externals --prefix client
# ── The URLs this module actually serves ──────────────────────────────────
#
# Everything above proves the module against itself. This proves it against a
# real core: the one place where "the prefix I register" and "the path I
# document" are the same fact rather than two strings that ought to agree.
#
# The module is COPIED into the core checkout, never symlinked — core's loader
# filters its scan with `entry.isDirectory()`, which reports a link as a link
# and skips it silently, so a symlinked module produces a manifest with no
# module routes in it and a diff that looks like the module registering
# nothing.
frozen-manifest:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
path: module
- uses: actions/setup-node@v4
with:
node-version: 20
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
# commit sha, and `--depth 1` can only fetch a branch tip.
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
run: |
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
REF=$(node -p "require('./module/ci/core-ref.json').ref")
echo "core: $REPO @ $REF"
git clone --quiet "$REPO" core
git -C core checkout --quiet "$REF"
- name: Install core's server deps
run: npm ci --prefix core/server
# Core alone. `--check` first, so a pin that no longer regenerates its own
# committed manifest fails HERE, naming the pin, instead of showing up below
# as this module having removed a route it never touched.
- name: Generate core's manifest without this module
run: |
npm run routes:manifest --prefix core/server -- --check
cp core/server/routes.manifest.json before.json
# The chunk has to exist before the loader will accept the module at all —
# `client.entry` is validated during the manifest step of the scan, and a
# missing one is a load failure, not a warning.
- name: Build the client chunk
run: |
npm ci --prefix module/client
npm run build --prefix module/client
- name: Install the module into core
run: |
mkdir -p core/modules/uo
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/uo -xf -
npm ci --omit=dev --prefix core/modules/uo/server
- name: Generate core's manifest with this module
run: |
npm run routes:manifest --prefix core/server
cp core/server/routes.manifest.json after.json
- name: Check the frozen manifest and the fragment's coverage
working-directory: module
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check

View File

@@ -0,0 +1,444 @@
# Build and publish the installable bundle: `module-uo-<version>.tar.gz` plus the
# manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
#
# ── What a release IS here ──────────────────────────────────────────────────
#
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
# the whole module system is shaped around). So a release is not source: it is the
# directory core's loader expects to find at `modules/uo/`, already assembled —
# the prebuilt client chunk, the one runtime dependency installed, the schema
# fragment and the OpenAPI fragment — packed as it will be unpacked. Phase 4's
# admin install downloads the tarball, verifies it against the `sha256` in the
# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way.
#
# ── The version is DERIVED, and the declaration is a floor ──────────────────
#
# This file used to release only when a merge to `main` left `module.json` at a
# version with no release yet — the version DECLARED, never computed, on the
# argument that two sources for one number is how they drift. That was true and
# it was still the wrong trade: it makes every bundle cost a second reviewed PR
# whose entire content is a number, and between 2026-08-12 and 2026-08-19 it cost
# this repo *every* bundle — v0.3.0 was the only release while nine phases of
# Teams work landed, because nothing in them touched that line.
#
# So the engine `link` and `installer` already run is adopted here (MODULE_SYSTEM
# §2.7.1, decision 19 as amended):
#
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# **The declared version is kept as a floor, not deleted.** If `module.json` names
# a version above the newest tag, that version releases — which is the old model
# exactly, surviving as the special case it always was. Raising it by hand is
# still how you say "this one is a minor, whatever the subjects imply", and it is
# still the natural place to move when a `coreApi` bump forces the question. What
# no longer happens is a merge full of `feat:` producing nothing.
#
# The number that ships is therefore the TAG, and CI writes it into the
# `module.json` inside the bundle at assembly time. The committed `module.json` is
# a floor and a starting point, not a record of the last release — `link` reached
# the same arrangement with `Cargo.toml`, for the same reason: a release engine
# that has to commit a bump back to `main` stops working the day someone protects
# the branch, and this one is protected.
#
# ── The backdoor ────────────────────────────────────────────────────────────
#
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
# new mount, a capability — with no releasable code behind it. Leave `version`
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
# version to publish that. A dispatch releases even when nothing in the log is
# releasable; that is the entire point of pressing the button.
#
# Re-running on a version that is already released is a no-op, so a rerun after an
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
# no-op — see the recovery branch in the plan step.
#
# This workflow still never writes to a branch. It tags and publishes, so `main`
# needs no push exception.
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
# and create the release.
name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.4.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
concurrency:
group: release-module-uo
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/Module-uo
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Full history: the plan step reads every tag and every subject since the
# newest one, and a shallow clone has neither.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
# 0.9.0 is exactly the comparison a string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
# The backdoor's exact form. Deliberately unvalidated against the log:
# a human typed it, and the already-released check below is the only
# guard that matters.
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable in the log still releases — that
# is what the button is for. Where the log DOES say something, the
# larger of the two wins rather than the input: pressing the button on
# a log full of `feat:` without touching the dropdown would otherwise
# publish its `patch` default over a minor's worth of work, and a
# version that undersells its own contents cannot be taken back.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
elif [ "$LEVEL" != "$BUMP" ]; then
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor. A `module.json` above the newest tag releases at that
# version even when the log says nothing and even when the log says
# patch — which is the pre-2026-08-19 model, kept as a special case.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is what happened on servuo-plugins' first release,
# where absent secrets took the release API call to 401 after the tag
# had already been pushed. Standing down on the tag alone makes that
# state permanent. Note this deliberately OVERRIDES the RELEASE=false
# above: with the tag in place there is nothing releasable after it, so
# the normal path would stand down, which is why it could never
# self-heal. Anything other than 200/404 — a network failure, a bad
# token — is not evidence of absence, and guessing "no" would publish
# over a good release, so refuse instead.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
case "$REL_HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
esac
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.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## module-uo v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`module-uo-${VERSION}.json\`, or unpack the tarball onto the modules volume"
echo "as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
# Before anything is built or tagged, so a repo without secrets fails
# legibly rather than half-publishing: the tag push can succeed on the
# credential actions/checkout left in the local git config while the
# release API call 401s, leaving the repo tagged and unreleased.
- name: Verify release credentials are configured
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
exit 1
fi
echo "Release credentials present."
- name: Build the client chunk
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
npm ci --prefix client
npm run build --prefix client
# `--omit=dev` and then PACKED: express, express-validator and swagger-autogen
# are build- and test-time only — the shipped half is handed express on `ctx`
# (MODULE_API.md §2.3) — and `ws` is the one runtime dependency. Node resolves
# it by walking up from `modules/uo/server/`, which is why it ships inside the
# tarball rather than being installed on the operator's box.
- name: Install the shipped runtime dependency
if: ${{ steps.plan.outputs.release == 'true' }}
run: npm ci --omit=dev --prefix server
# ── Assemble exactly what an operator's volume gets ──────────────────
#
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
# whatever it forgot: the day someone adds `server/tools/` with a scratch
# credential in it, an exclude list packs it and nobody finds out.
#
# The list itself lives in `ci/bundle.json`, not here, because it has a
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
# whether the list still covers everything `server/index.js` reaches. It
# was hardcoded in this file until v1.0.0 shipped without `server/commands/`
# — added by the Teams cutover, never added here — and the module died at
# the register stage on the operator's box. One declaration, two readers,
# so the next directory cannot go missing quietly.
- name: Assemble the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/module-uo-${VERSION}"
rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads — with the RELEASED version written into it.
# The committed `module.json` is a floor, not a record of the last
# release (see the header), so copying it verbatim would ship a bundle
# whose `installed_modules` row and admin screen disagree with the tag
# it came from. This is the one place the derived number becomes the
# module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The two fragments, and the licence the code is under — a bundle that
# ships GPL code without its licence is not distributable.
for f in $(jq -r '.root[]' ci/bundle.json); do
cp "$f" "$OUT/"
done
# The server half, minus what never runs inside core's process.
mkdir -p "$OUT/server"
for d in $(jq -r '.server[]' ci/bundle.json); do
cp -r "server/$d" "$OUT/server/"
done
cp -r server/node_modules "$OUT/server/"
# The client half is the BUILT chunk only. `client/src` is 5,000 lines
# of source an operator has no use for and core will never read.
mkdir -p "$OUT/client/dist"
cp client/dist/entry.js "$OUT/client/dist/"
# Prove the bundle is loadable before it is published: these are the
# paths core's loader resolves out of module.json, and a release whose
# entry point is missing fails on an operator's box with a
# `startup_failed` row instead of here. The version assertion guards the
# rewrite above — a bundle that still carries the declared version would
# install under a number that is not the one it was released as.
node -e '
const fs = require("fs"), path = require("path");
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
}
console.log("bundle contents check: ok");
' "$OUT" "$VERSION"
# ── And that it can actually LOAD ─────────────────────────────────
#
# The check above stats the paths `module.json` declares, which is a
# real question but a shallow one: v1.0.0 passed it and was still
# missing `server/commands/`, because a file reached only by a require
# inside `register()` is named nowhere in `module.json`. This resolves
# every relative require in the assembled tree and asserts the target is
# in it — asked of the artifact, so it also catches a copy that half
# failed or a list naming a path that has since moved.
#
# Run from the SOURCE tree (`server/scripts/` never ships) against the
# assembled bundle.
node server/scripts/checkBundle.js --bundle "$OUT"
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
rm -rf "$OUT"
SHA="$(sha256sum "dist/module-uo-${VERSION}.tar.gz" | cut -d' ' -f1)"
SIZE="$(stat -c%s "dist/module-uo-${VERSION}.tar.gz")"
# The install manifest. Same shape as the installer's bundle JSON — a
# per-asset sha256 fetched over HTTPS, no signatures — because that is
# the model this project already has and a second one would be a second
# thing to get right (MODULE_SYSTEM.md §1.11).
jq -n \
--arg id "$(node -p "require('./module.json').id")" \
--arg name "$(node -p "require('./module.json').name")" \
--arg version "$VERSION" \
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
--arg artifact "module-uo-${VERSION}.tar.gz" \
--arg sha256 "$SHA" \
--argjson size "$SIZE" \
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-uo-${VERSION}.tar.gz" \
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
> "dist/module-uo-${VERSION}.json"
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/module-uo-${VERSION}.json"
# Skipped on a recovery run: the tag is already there and is the thing being
# published against.
- name: Tag the release
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
git config user.name 'Runic Gateway CI'
git config user.email 'ci@whitlocktech.net'
git tag -a "$TAG" -m "module-uo ${TAG}"
git push origin "$TAG"
- name: Create the Gitea release and upload the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
VERSION="${{ steps.plan.outputs.version }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
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 "$(cat dist/CHANGELOG.md)" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}.json" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done

View File

@@ -0,0 +1,103 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
# (which gates PRs) and release.yml (which publishes the bundle) — this one only
# feeds the dashboard.
#
# Mirrors RunicGateway/website's sonarqube.yml, for the same reason pr-checks.yml
# does: this module is two npm packages shaped like that repo's `server/` and
# `client/`, and it is loaded into that repo's process. Until now it was the one
# part of the platform that had never been scanned — 75 files that arrived in the
# Phase 3 extraction with core's Sonar history left behind in core's project.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# Module-uo project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
# Node 22, where pr-checks.yml pins 20: the built-in `lcov` coverage
# reporter this job depends on needs >= 22. The version that matters for
# correctness is the one in pr-checks.yml, which matches the core process
# this module is loaded into; nothing here ships.
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install deps for both halves
run: |
npm ci --prefix server
npm ci --prefix client
# The chunk has to exist before the client suite runs: build.test.js and
# registration.test.js read `client/dist/entry.js`, and both SKIP when
# there is no build. Run the other way round they skip silently and this
# job reports coverage for a suite that quietly asked less than it looks
# like it did — the same ordering pr-checks.yml calls load-bearing.
- name: Build the client chunk
run: npm run build --prefix client
# SonarQube runs static analysis only — it never executes the test suite,
# so we must produce the coverage report ourselves and hand it to the
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
#
# Both suites are invoked from the REPO ROOT rather than with `--prefix`,
# so the LCOV `SF:` paths come out repo-root-relative (`server/router/...`,
# `client/src/...`) and resolve against sonar.sources. That is also why the
# server suite's `--require` is spelled out here instead of reusing
# `npm test --prefix server`, whose path is relative to `server/`.
- name: Generate server test coverage (LCOV)
run: |
mkdir -p server/coverage
node --test --experimental-test-coverage \
--require ./server/test/_setup.js \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
server/test/*.test.js
- name: Generate client test coverage (LCOV)
run: |
mkdir -p client/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
client/test/*.test.js
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

8
.gitignore vendored
View File

@@ -19,7 +19,13 @@ client/coverage/
*.env
!.env.example
# Release staging
# Release staging. `.gitea/workflows/release.yml` assembles the bundle under
# /dist and packs it from there. Note this is the ROOT dist only — the module's
# two committed generated artifacts, swagger-fragment.json and
# routes.manifest.json, are deliberately NOT ignored: core merges the first
# verbatim and the second is the frozen URL surface, so both have to be
# reviewable in a diff (MODULE_API.md §2.8, §5.3).
/dist/
*.tar.gz
# logs / os

View File

@@ -96,6 +96,19 @@ when someone builds on the server is not shippable.
branch and no cutover, unlike `website`, whose module work accumulates on `edge`
and reaches `main` once.
### Static analysis runs after the merge, not on the PR
`.gitea/workflows/sonarqube.yml` scans `main` on push and reports to the
self-hosted SonarQube instance under the project key **`Module-uo`**. It is
deliberately non-blocking: it never gates a pull request, and a failing quality
gate does not fail the job. Check the dashboard when you want to; the things
that must not reach `main` are gated by `pr-checks.yml` instead.
It runs both suites from the repo root to produce coverage, and builds the
client chunk first — two of the client tests read `dist/entry.js` and skip
without it, which would leave this job reporting on a suite that quietly asked
less than it appears to.
### Commit messages
We use [Conventional Commits](https://www.conventionalcommits.org/) —

135
README.md
View File

@@ -24,7 +24,7 @@ The module's **id** is `uo` — that is what appears in `module.json`, in the `i
table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`,
`/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact.
## Status: the bundle skeleton exists; the extraction has started
## Status: the extraction is complete; this repo is the UO half of the site
The design of record is
[`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
@@ -37,32 +37,50 @@ in the docs repo — **read them before opening a PR here.** Where the two diffe
| 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | ✅ done |
| 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ✅ done |
| 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ✅ done |
| 3 — extract the UO half of the site into this repo | `website`, here | 🟡 in progress |
| 3 — extract the UO half of the site into this repo | `website`, here | ✅ done |
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
Phase 3 moves the UO half of `website/` here in ten slices (`MODULE_SYSTEM.md` §2.7.1), server-first
and then client. Each slice is one PR here that adds, and one PR in `website` that deletes — this one
merging first, so `website`'s `edge` branch serves the feature from core right up to the moment core
drops it.
Phase 3 moved the UO half of `website/` here in six slices (`MODULE_SYSTEM.md` §2.7.1): the bundle
skeleton, the whole server half, core's client extension slots, the whole client half, the de-UO of
core's own copy, and this one — the artifacts that make the result installable and checkable. Each
slice was one PR here that added and one in `website` that deleted, this one merging first, so
`website`'s `edge` branch served each feature from core right up to the moment core dropped it.
**Slice 0 is the bundle skeleton, and it registers nothing on purpose.** What it proves is the
delivery path itself: core discovers the module, validates `module.json`, calls `register()`, serves
the client chunk, injects it, and reports the module `started` — and the chunk resolves React, the
renderer and the router from core's `window.__rg` rather than bundling its own. Every slice after
this one adds registrations to `server/index.js` and `client/src/entry.jsx`.
Neither half sliced by feature in the end, and for the same reason on both sides: a mount prefix is
claimed whole and a shared leaf moves with its **last** consumer, so the closure of either half is
the whole half.
**What core serves and what this repo serves is now a fact you can read**, not a claim: 72 URLs, in
[`routes.manifest.json`](routes.manifest.json), derived by loading this module into a real core and
diffing. Not one of core's own URLs moved — that is the promise `MODULE_SYSTEM.md` §1.2 makes to the
shipped Android app and the Discord bot, and it is checked on every PR.
## Working on it
```bash
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
npm run check:swagger --prefix server # is swagger-fragment.json still current?
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
```
The two `check:*` scripts are the contract's acceptance criteria rather than this module's own tests:
no import may escape the module root (`MODULE_API.md` §5.1), and no bare specifier may survive into
the built chunk (§3.6). The matching failure — a shared dependency being *bundled* — fails the build
itself, from a guard inside `vite.config.js`.
The `check:*` scripts are the contract's acceptance criteria rather than this module's own tests: no
import may escape the module root (`MODULE_API.md` §5.1), no bare specifier may survive into the
built chunk (§3.6), and the OpenAPI fragment core merges must describe the routes registered today
(§2.8). The matching build failure — a shared dependency being *bundled* — comes from a guard inside
`vite.config.js`.
**Changed a route, or its `#swagger` annotations?** `npm run swagger --prefix server` regenerates
`swagger-fragment.json`; commit it. Core cannot generate it — core is a prebuilt image and this
module mounts through a call no static parser can follow — so the file this repo commits is the one
an operator's `/api/docs` shows.
**Changed a mount prefix, or added a route?** `routes.manifest.json` is regenerated by the
`frozen-manifest` CI job, which clones core at the ref pinned in [`ci/core-ref.json`](ci/core-ref.json),
loads this module into it and takes the difference. To do it locally, check this repo out into that
core as `modules/uo` (**copy it — a symlink is silently skipped by the loader**), run core's
`npm run routes:manifest` with and without it, and hand both files to
`server/scripts/frozenManifest.js`.
Running it against a real core means checking this repo out as `website/modules/uo`, building the
client half, and booting core. The four-step browser smoke in `MODULE_API.md` §7.7 is the only thing
@@ -72,24 +90,35 @@ neither has a shape a DOM-less test runner can see.
## What it contains
One repo, one bundle: the server half and the client half live side by side and version together, so
a route and the screen that calls it can never be mismatched. A ✅ is in the tree today.
a route and the screen that calls it can never be mismatched.
```
module.json ✅ id, version, coreApi range, mounts, extensions
server/index.js ✅ the entry point — register(ctx, api), synchronous, no database
server/scripts/ ✅ checkImports.js — the §5.1 boundary check
server/test/ ✅ node --test, with a fake ctx standing in for core
server/ routers, controllers, models, utils
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge
client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider
client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
client/vite.config.js ✅ the library build, the aliases, the not-bundled guard
client/src/ route components, nav registrations, feature provider
client/dist/ ✅ PREBUILT ESM chunk, built by CI — never by an operator
module.json id, version, coreApi range, mounts, extensions
swagger-fragment.json generated · the OpenAPI core merges into /api/docs.json
routes.manifest.json generated · the 72 URLs this module serves
ci/core-ref.json the core commit the two above were proved against
server/index.js the entry point — register(ctx, api), synchronous, no database
server/router/ routers + controllers, one directory per tier
server/model/ one directory per table family; nothing crosses the boundary
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
server/config/ the push stream catalog
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge
server/scripts/ the three checks: imports, the fragment, the frozen manifest
server/test/ node --test, with a fake ctx standing in for core
client/src/entry.jsx the chunk's entry — registers routes, nav, slots, feature provider
client/src/shim/ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
client/vite.config.js the library build, the aliases, the not-bundled guard
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
```
Release artifact: `module-uo-<version>.tar.gz`, plus a manifest carrying its `sha256`.
**The three generated files are committed on purpose.** Two of them are what core reads instead of
looking at this source — it never has it — and the third records which core they were proved against.
A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a
diff.
Release artifact: `module-uo-<version>.tar.gz`, plus `module-uo-<version>.json` carrying its
`sha256`. See below.
## How it reaches an operator
@@ -103,6 +132,54 @@ The [installer](https://gitea.whitlocktech.com/RunicGateway/installer) is **not*
It deploys the *shard* side — the plugin overlay and the uo-link sidecar — and never contacts the
website. Module delivery is website-side only.
### Releases
**Every merge to `main` that carries a releasable commit publishes a bundle.** The next version is
computed from conventional-commit subjects since the newest `v*` tag, as in `link` and `installer`:
`feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch, and a `main`
that gained none of those cuts no release. The number that ships is the **tag**, and CI writes it
into the `module.json` inside the bundle.
`module.json`'s version survives as a **floor**: name a version there above the newest tag and that
version is what releases, which is how you overrule the subjects — when a `coreApi` bump forces a
minor, say. What no longer happens is a `main` full of `feat:` producing nothing because a separate
PR to move one number had not been merged yet.
For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a capability —
run the **Release** workflow by hand (Actions → Release → Run workflow). Leave `version` blank to
bump the newest tag by `bump` (default `patch`), or type an exact version to publish that.
Each release carries:
| Asset | What it is |
|---|---|
| `module-uo-<version>.tar.gz` | the directory core expects at `modules/uo/` — already assembled, with the chunk built and `ws` installed |
| `module-uo-<version>.json` | id, version, `coreApi`, the artifact's URL, size and **`sha256`** |
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before
unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a
second thing to get right.
The tarball is assembled from an **include** list, never an exclude list — an exclude list ships
whatever it forgot. Tests, scripts, `client/src` and the dev dependencies are not in it.
## Environment variables
Four, all optional, all read by this module rather than by core — which is why they are documented
here and not in core's `.env.example`. In Docker they go in the Compose `.env`, since that is what
reaches the container.
| Var | Default | What |
|---|---|---|
| `UOLINK_BASE_URL` | — | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
| `UOLINK_WS_URL` | — | Same, for the WebSocket URL. |
| `UOLINK_PROTOCOL` | `3` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
| `TOWNCRIER_DURATION_SEC` | `3600` | How long a published news post's in-game town-crier message stays up (≤ `86400`). |
**The sidecar's auth token is deliberately not here.** It is entered in Admin → Shard, encrypted at
rest with core's `SECRET_ENC_KEY`, and write-only in the API — never returned to any client.
## Compatibility
`module.json` declares a `coreApi` semver range, checked at boot against core's `MODULE_API_VERSION`.

43
ci/bundle.json Normal file
View File

@@ -0,0 +1,43 @@
{
"$comment": [
"What a release copies into the bundle, declared ONCE. Read by .gitea/workflows/release.yml",
"when it assembles the tarball, and by server/scripts/checkBundle.js when CI asks whether",
"that list still covers everything the module's entry point can reach.",
"",
"This is an INCLUDE list on purpose (release.yml's header argues the case): an exclude list",
"ships whatever it forgot, so the day someone adds server/tools/ with a scratch credential",
"in it, an exclude list packs it and nobody finds out. The cost of that choice is that a new",
"top-level directory silently drops OUT of every release instead — which is exactly what",
"happened to server/commands/ between v0.3.0 and v1.0.0, and is why checkBundle.js exists.",
"",
"server[] entries are paths under server/; root[] and generated[] are paths under the module",
"root. node_modules is not listed: the release installs it with `npm ci --omit=dev` and copies",
"it separately, so it is not a checked-in path.",
"",
"generated[] ships but is not copied — release.yml writes module.json through jq to stamp the",
"released version into it, since the committed one is a floor rather than a record of the last",
"release. It is listed because server/index.js requires it, and a check that did not know it",
"ships would report the module's own manifest as missing from the bundle."
],
"server": [
"boot.js",
"commands",
"config",
"core.js",
"data",
"db",
"index.js",
"model",
"package.json",
"router",
"utils"
],
"root": [
"swagger-fragment.json",
"LICENSE.md",
"README.md"
],
"generated": [
"module.json"
]
}

6
ci/core-ref.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
"refName": "main @ the Teams cutover (website#161)"
}

View File

@@ -27,53 +27,146 @@ import { fileURLToPath } from 'node:url'
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
if (!fs.existsSync(CHUNK)) {
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
process.exit(1)
/**
* Which characters of the chunk are inside a string, template or comment.
*
* **A check that reads code with a regexp fails on code that talks about
* itself.** The first real chunk this script ever saw — slice 3's, the first
* with any content in it — was rejected for importing `" }),\n !l && …`,
* because a button reading "Approve and import" put the token `import`
* immediately before a quote and the pattern could not tell that from a
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
* anything.
*
* The server half hit the same wall from the other side and answered it the same
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
* regexp. There is no regexp that distinguishes a keyword from the same letters
* inside a string, because that distinction is a property of the parse.
*
* A mask rather than a rewrite, because the two halves of a real import — the
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
* would take the answer with the noise.
*/
export function stringMask(src) {
const inString = new Uint8Array(src.length)
let i = 0
while (i < src.length) {
const c = src[i]
const two = src.slice(i, i + 2)
if (two === '//') {
const nl = src.indexOf('\n', i)
const end = nl === -1 ? src.length : nl
inString.fill(1, i, end)
i = end
} else if (two === '/*') {
const close = src.indexOf('*/', i + 2)
const end = close === -1 ? src.length : close + 2
inString.fill(1, i, end)
i = end
} else if (c === '"' || c === "'" || c === '`') {
// The opening quote itself stays unmasked: a specifier is read starting
// at its quote, and the regexp below anchors on that.
i += 1
while (i < src.length && src[i] !== c) {
// A backslash escapes the next character, including the closing quote.
const step = src[i] === '\\' ? 2 : 1
inString.fill(1, i, Math.min(i + step, src.length))
i += step
}
i += 1
} else {
i += 1
}
}
return inString
}
const chunk = fs.readFileSync(CHUNK, 'utf8')
const problems = []
// Static and dynamic imports that survived into the output. A relative or
// absolute specifier is a chunk that was split, which this build does not do —
// `lib` mode with one entry emits one file — so anything here is a bare name.
const IMPORTS = /(?:^|[\s;}])(?:import\s+[^'"]*?from\s*|import\s*|import\()\s*['"]([^'"]+)['"]/g
const bare = new Set()
for (const [, specifier] of chunk.matchAll(IMPORTS)) {
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
}
if (bare.size) {
problems.push(
`the chunk still imports ${[...bare].map((s) => `"${s}"`).join(', ')} — ` +
'nothing can resolve a bare specifier in the browser without an import map, ' +
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
)
//
// **This pattern used to require whitespace after `import`, and so could not see
// the one shape the build actually emits.** Minified Rollup output is
// `import{useState}from"react"`, with no space anywhere in it; the old
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
// alternative, met `{` instead of a quote and matched nothing. A bare named
// import — the most likely way for an alias to miss — would have passed this
// check silently. It was found by writing the test for the false POSITIVE above
// it, which is the argument for testing a check against both answers.
//
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
// import's parenthesis.
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
/** Every bare specifier the chunk still imports at runtime. */
export function bareImports(chunk) {
const masked = stringMask(chunk)
const bare = new Set()
for (const match of chunk.matchAll(IMPORTS)) {
// Where the `import` keyword itself starts — one past the leading delimiter,
// unless the match began at position 0.
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
const specifier = match[1]
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
}
return [...bare]
}
// Fingerprints from the shared libraries' own source. Each is a string those
// packages ship and this module has no other reason to contain.
//
// These are matched against the RAW chunk, deliberately unmasked: a bundled
// library's source arrives as code AND as its own error-message strings, and
// masking would discard half the evidence. The direction of the risk is opposite
// to the import check's — here a false positive is a fingerprint too generic,
// which is a fixable choice of probe, not a property of the parse.
const BUNDLED = [
{ what: 'react', probe: 'react.development.js' },
{ what: 'react', probe: 'Invalid hook call' },
{ what: 'react-dom', probe: 'react-dom.development.js' },
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
]
for (const { what, probe } of BUNDLED) {
if (chunk.includes(probe)) {
/** Every problem with this chunk, as sentences. Empty means it ships. */
export function problemsWith(chunk) {
const problems = []
const bare = bareImports(chunk)
if (bare.length) {
problems.push(
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
'There is exactly one React in the page and core owns it — a second copy ' +
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
'nothing can resolve a bare specifier in the browser without an import map, ' +
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
)
}
for (const { what, probe } of BUNDLED) {
if (chunk.includes(probe)) {
problems.push(
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
'There is exactly one React in the page and core owns it — a second copy ' +
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
)
}
}
return problems
}
if (problems.length) {
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
for (const p of problems) console.error(` - ${p}\n`)
process.exit(1)
// Only when run as a script. Importing this from a test must not read a chunk
// that may not have been built, and must not call process.exit.
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (!fs.existsSync(CHUNK)) {
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
process.exit(1)
}
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
if (problems.length) {
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
for (const p of problems) console.error(` - ${p}\n`)
process.exit(1)
}
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
}
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)

232
client/src/api.js Normal file
View File

@@ -0,0 +1,232 @@
// ── This module's own API bindings ─────────────────────────────────────────
//
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, `ApiError` on
// a non-2xx. The paths are ours, because the routes at the other end are ours —
// `server/router/**` in this repo serves every one of them.
//
// This file is the client half of the pair that moved in slice 1, and the two
// halves are checked against each other by nothing but review, so the ordering
// below mirrors the router tree deliberately: public, then admin, then player.
//
// **The URLs are unchanged from the ones core used to call.** MODULE_SYSTEM.md
// §1.2 freezes the API surface across the extraction — the shipped Android app
// calls `/api/v1/admin/shard/kick` and six of its neighbours — so what moved is
// which repo declares them, never what they are. Only the SPA route paths
// changed (`/uo/*`, `/admin/uo/*`, `/player/uo/*`), and those are not API URLs.
import rg from './core.js'
const { request: req, BASE } = rg.api
/** Prefix a non-empty query string with "?" — core's `withQs`, which is not in the kit. */
const withQs = (s) => (s ? `?${s}` : '')
// ── public: live shard data (uo-link) ──────────────────────────────────────
// Token-free, same-origin reads backed by the ingested feed plus a cached live
// character round-trip.
export const shard = {
status: () => req('/public/shard/status'),
feed: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.kind) qs.set('kind', opts.kind)
if (opts.limit) qs.set('limit', opts.limit)
return req(`/public/shard/feed${withQs(qs.toString())}`)
},
economy: (limit) => req(`/public/shard/economy${withQs(limit ? `limit=${limit}` : '')}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
// Protocol 3.0: the shard's published ruleset. Resolves to null when the shard
// has never published one — a real answer, not an error.
ruleset: () => req('/public/shard/ruleset'),
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
// `pointsBoard` 404s for a system the shard has never published.
points: () => req('/public/shard/points'),
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
// the page debounces its search box rather than firing per keystroke.
market: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
if (opts.map) qs.set('map', opts.map)
if (opts.region) qs.set('region', opts.region)
if (opts.sort) qs.set('sort', opts.sort)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market${withQs(qs.toString())}`)
},
marketMeta: () => req('/public/shard/market/meta'),
marketVendor: (serial, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
},
// Which shard surfaces this caller may reach, plus the audience rung they
// resolved to. Drives nav so we never render a link that would 403 — and, as
// of slice 3, also carries `gameAccountSignup`: whether this site offers
// game-account creation at all (see server/router/public/shard.controller.js).
features: () => req('/public/shard/features'),
}
// ── public: the spawn atlas (Protocol 3.0 Part C) ──────────────────────────
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
// not under /shard, because nothing here depends on the sidecar and the pages
// stay populated while the shard is offline.
export const atlas = {
creatures: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.facet) qs.set('facet', opts.facet)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
},
creature: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.points) qs.set('points', opts.points)
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
},
regions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/regions${withQs(qs.toString())}`)
},
landmarks: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
// The CONFIGURED altar roster, not the live board — see `shard.champs()` for
// "which spawn is on level 3 right now".
champions: (facet) =>
req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
meta: () => req('/public/atlas/meta'),
}
// ── admin ──────────────────────────────────────────────────────────────────
export const admin = {
// The account/character/house reads a staff member makes across the whole shard.
shard: {
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/admin/shard/accounts'),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
},
// The sidecar's own configuration and the town crier it drives.
getUoLinkConfig: () => req('/admin/uo-link/config'),
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Whether this site offers game-account creation, and in which direction.
// Core's Site Settings used to carry this; it is ours as of slice 3, because
// "the game server's own SignupMode must agree" is not a sentence core can own.
getSignupMode: () => req('/admin/uo-link/signup-mode'),
saveSignupMode: (mode) => req('/admin/uo-link/signup-mode', { method: 'PUT', body: { mode } }),
// Per-feature shard visibility: who may see which shard surface, and which
// sensitive fields within it. Admin only — it decides what ANONYMOUS visitors
// get. acct/webId are admin-only always and the API rejects any attempt to
// configure them.
getShardVisibility: () => req('/admin/shard/visibility'),
saveShardVisibility: (features) => req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
// The atlas re-derives itself from the ServUO tree on every boot; these are for
// applying a map change without a restart, and for the approve/reject decision
// on a refresh that would remove a facet.
atlas: {
status: () => req('/admin/shard/atlas'),
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
},
// In-game staff operations: write plane + support queue (admin/moderator).
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) => req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${withQs(limit ? `limit=${limit}` : '')}`),
},
/**
* One user's shard presence, for the `admin.users.detail` extension slot.
*
* A factory rather than a flat namespace because every call is scoped to the
* user whose page this is. The three that are NOT — roster, vendors, char —
* are keyed by an account or a serial the scoped calls just returned, and they
* are the same routes `admin.shard` uses; they are repeated here so the slot's
* components take one `scope` object and never reach for a second one.
*/
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/shard/accounts`),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req(`/admin/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
}
// ── player self-service ────────────────────────────────────────────────────
// Mirrors `admin.shard`, self-scoped: the server derives the caller from the
// session and never takes an account id from the client.
export const player = {
shard: {
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
}
// ── SSE endpoints ──────────────────────────────────────────────────────────
// Full paths including `/api/v1`, because `request` is fetch-only and an
// EventSource builds its own URL. `BASE` is core's — it owns where the API is
// mounted, and a module hardcoding `/api/v1` would be asserting something about
// core that core has not promised (MODULE_API.md §3.5).
//
// The admin stream carries every kind, including audit and cheat detection, and
// needs the staff session cookie.
export const shardStreamUrl = `${BASE}/public/shard/stream`
export const adminShardStreamUrl = `${BASE}/admin/uo-link/stream`
export const api = { shard, atlas, admin, player, shardStreamUrl, adminShardStreamUrl }
export default api

View File

@@ -0,0 +1,293 @@
// Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
//
// `moderation` opts in the in-game kick/ban controls for the character's account;
// they self-gate to staff (ShardAccountActions), so passing it from a page a
// player can reach is safe.
import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
// What to call an equipped item.
//
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
// to show nothing but the layer and `id 12345`. The server now resolves the
// cliloc against its own table and attaches `clilocName` (see
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
// and the layer fallback below is exactly what the sheet did before.
//
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
// should not be relabelled "hatchet" — and the server applies the same
// precedence, so this only re-states it for a profile that arrived with both.
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
// literal string.
//
// `rewardResolved` is the server's parallel array with the numeric entries turned
// into words (null where the cliloc table had nothing, or is not configured at
// all). Prefer it, and keep the literal-only path as the fallback for a profile
// served before the cliloc table existed — a numeric entry with no resolution is
// still skipped rather than shown as a raw number.
function displayTitles(titles) {
if (!titles) return []
const out = []
if (titles.fameKarma) out.push(titles.fameKarma)
if (titles.skill) out.push(titles.skill)
const raw = Array.isArray(titles.reward) ? titles.reward : []
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
const sel = typeof titles.selected === 'number' ? titles.selected : -1
// Prefer the selected reward title; fall back to the first one that resolved.
// The `??` matters: a selected title whose cliloc did not resolve must fall
// through to the fallback rather than suppress the chip entirely.
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
if (candidate) out.push(String(candidate))
return [...new Set(out.filter(Boolean))]
}
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
// the character actually holds a score in. Systems at zero are omitted by the
// shard, so an empty list means "this character has earned nothing anywhere",
// which is a normal state for a new character and renders as nothing at all.
//
// `nameString` may be null when the system's name is a cliloc; fall back to
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
// "unranked" are different, so the chip only appears when it was actually sent.
const humanisePoints = (key) =>
String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase())
function PointsRow({ entry }) {
const label = entry.nameString || humanisePoints(entry.system)
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
{label}
{Number.isFinite(entry.rank) && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
)}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{(entry.points ?? 0).toLocaleString()}
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
</span>
</div>
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
be a fraction of, and a full-width bar would imply completion. */}
{max > 0 && (
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
)}
</div>
)
}
function TitleChip({ children, tone = 'var(--muted)' }) {
return (
<span
className="sans"
style={{
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
</div>
)
}
function Vital({ label, cur, max }) {
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
return (
<div className="panel" style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
</div>
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
}
export default function CharacterSheet({ char, moderation = false }) {
if (!char) return null
const stats = char.stats || {}
const resist = stats.resist || {}
// Skills the character actually has, best first.
const skills = (char.skills || [])
.filter((s) => (s.value || s.base || 0) > 0)
.sort((a, b) => (b.value || 0) - (a.value || 0))
const equipment = char.equipment || []
// Best standing first, so the character's strongest loyalty leads. Guarded for
// an older shard plugin that sends no `points` block at all.
const points = (Array.isArray(char.points) ? char.points : [])
.filter((p) => p && (p.points || 0) > 0)
.sort((a, b) => (b.points || 0) - (a.points || 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{/* Identity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
border: '1px solid var(--line)', fontSize: '0.74rem',
color: char.online ? '#7fd0a4' : 'var(--muted)',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
{char.online ? 'Online' : 'Offline'}
</span>
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Titles + standing (guild led / governorship) — all optional */}
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
{char.governorOf && char.governorOf.map((city) => (
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
))}
{char.guild && (
<TitleChip tone="var(--accent)">
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
</TitleChip>
)}
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
</div>
)}
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
<div className="grid-3" style={{ gap: 12 }}>
<StatTile value={stats.str ?? '—'} label="Strength" />
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
<StatTile value={stats.int ?? '—'} label="Intelligence" />
</div>
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
</div>
</section>
{/* Resistances */}
{Object.keys(resist).length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{skills.map((s) => {
const cap = s.cap || 100
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
return (
<div key={s.n}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
})}
</div>
</section>
)}
{/* Loyalty & points — one entry per system this character has scored in */}
{points.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>
Loyalty &amp; points <span className="dim">({points.length})</span>
</div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{points.map((p) => (
<PointsRow key={p.system} entry={p} />
))}
</div>
</section>
)}
{/* Equipment */}
{equipment.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{equipment.map((it) => {
const label = itemName(it)
const layer = it.layer || 'Item'
// The layer only earns its own line once the headline is a real
// name; when it IS the headline, repeating it is just noise.
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
return (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
</div>
{it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
{Object.entries(it.mods).map(([k, v]) => (
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
))}
</div>
)}
</div>
)
})}
</div>
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react'
// A small stat-tile row for a "My Characters" page: total characters, how many
// are online right now, and how many game accounts are linked. `scope` is the
// shard api object (admin or player self-service). Renders nothing until an
// account is linked, so the empty/link-prompt state below it stands alone.
//
// It fetches the same rosters GameAccounts loads; for a personal page that's at
// most a couple of extra live round-trips, and keeps this presentational bit
// decoupled from GameAccounts' per-account roster loading.
function Tile({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
{label}
</div>
</div>
)
}
// Fold the settled roster results into totals. `complete` is false when any
// account's roster failed (a partial result — shown as a dash rather than a
// misleadingly low count).
function summarizeRosters(rosters) {
let chars = 0
let online = 0
let complete = true
for (const r of rosters) {
if (r.status !== 'fulfilled') {
complete = false
continue
}
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
}
return { chars, online, complete }
}
export default function CharacterStats({ scope }) {
const [stats, setStats] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const accounts = await scope.accounts()
const linked = accounts.length
if (linked === 0) {
if (!cancelled) setStats({ linked: 0 })
return
}
// Roster is a live round-trip and can be unavailable (503); tolerate a
// partial result so a restarting shard doesn't blank the whole row.
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
} catch {
if (!cancelled) setStats({ error: true })
}
})()
return () => { cancelled = true }
}, [scope])
// Hidden until we know an account is linked (or while first loading).
if (!stats || stats.error || stats.linked === 0) return null
// Counts depend on live rosters; show a dash if none came back.
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
return (
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
<Tile value={count(stats.chars)} label="Characters" />
<Tile value={count(stats.online)} label="Online now" />
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
</section>
)
}

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password you’ll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}

View File

@@ -0,0 +1,228 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import ShardAccountActions from './ShardAccountActions.jsx'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
import api from '../api.js'
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
import { ErrorState, Loading } from '../core.js'
// Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api
// object with { link, accounts, roster } (player or admin self-service); `charTo`
// maps a serial to the route for that character's sheet. `readOnly` drops the
// link forms and self-voice copy for the admin case where staff view *another*
// user's accounts (no `scope.link`) at /admin/users/:id.
function LinkForm({ scope, onLinked, compact }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function submit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!code.trim()) return
setBusy(true)
try {
const { account } = await scope.link(code.trim())
setMsg(`Linked ${account}.`)
setCode('')
await onLinked()
} catch (err) {
setError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
<label style={{ display: 'block' }}>
{!compact && <span className="field-label">Link code</span>}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="input"
autoComplete="off"
placeholder="AB12CD"
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
/>
</label>
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Linking…' : 'Link account'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</form>
)
}
function AccountRoster({ scope, account, charTo }) {
const [roster, setRoster] = useState(null)
const [error, setError] = useState('')
const [unavailable, setUnavailable] = useState(false)
const load = useCallback(async () => {
setError(''); setUnavailable(false)
try {
setRoster(await scope.roster(account))
} catch (err) {
if (err.status === 503) setUnavailable(true)
else setError(err.message || 'Could not load this account.')
}
}, [scope, account])
useEffect(() => { load() }, [load])
if (unavailable) {
return (
<div>
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
<button className="pill" onClick={load}>Retry</button>
</div>
)
}
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
const chars = roster.chars || []
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
return (
<div className="grid-2" style={{ gap: 12 }}>
{chars.map((c) => (
<Link
key={c.serial}
to={charTo(c.serial)}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
>
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
{(c.name || '?').charAt(0)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
</div>
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
</Link>
))}
</div>
)
}
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
// then calls onUnlink(account) and reloads. Errors surface inline.
function UnlinkButton({ account, onUnlink }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function go() {
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
setBusy(true); setError('')
try {
await onUnlink(account)
} catch (err) {
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
setError(byStatus[err.status] || err.message || 'Could not unlink.')
setBusy(false)
}
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
</span>
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
// Whether the site currently offers game-account creation. Only relevant for
// the self-service (non-readOnly) view with a createAccount scope.
//
// From OUR public features endpoint as of slice 3, not core's public settings:
// the flag derives from the `uo.game_account_signup` setting, which this module
// owns, because "the game server's own SignupMode must agree" is not a sentence
// core can own. Same cached call the nav gates use, so this costs no round-trip.
const signupOk = useGameAccountSignup()
const load = useCallback(async () => {
setError('')
try {
setAccounts(await scope.accounts())
} catch {
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
}
}, [scope, readOnly])
useEffect(() => { load() }, [load])
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk === true
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
// No linked accounts. In read-only (admin viewing another user) this is just an
// empty state; otherwise it's the link-your-account prompt.
if (accounts.length === 0) {
if (readOnly) {
return (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
This user has not linked a game account.
</p>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
</div>
{canCreate && (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
</div>
)}
</div>
)
}
// Linked — characters grouped by account.
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => (
<section key={a.account}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{a.account}
</div>
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
</div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
</section>
))}
{!readOnly && (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
{canCreate && (
<div style={{ marginTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
</div>
)}
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import api from '../api.js'
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
// ── This module's fill for the `player.invite.accepted` slot ───────────────
//
// Core's invite-acceptance page (`routes/player/AcceptInvite.jsx`) used to render
// this step itself: it read `gameAccountSignup` out of core's public settings and
// posted to `api.player.shard.createAccount`. Both of those are ours, and the
// page they sat on is not — an invite is a core concept and staff get invited
// too. So slice 3 declared a third extension slot rather than moving the page or
// leaving core importing a module component. MODULE_API.md §3.7.
//
// **The whole decision about whether there is a step at all is on this side.**
// Core renders the shell and a "skip" control whenever the slot is filled, and
// hands us `onDone`. If this shard does not offer website-created game accounts
// there is nothing to do here, so we call `onDone` and the invitee goes straight
// to the portal — which is exactly what core's own code did when the flag was
// off, only now the flag is not core's to read.
//
// The spinner while the answer is in flight is the honest cost of that split: the
// invitee sees core's chrome for one cached request before this either renders or
// stands aside. Rendering the form optimistically and retracting it would be
// worse, and asking core to wait on a module before painting would put a module's
// latency in front of a core page.
export default function InviteGameAccountStep({ onDone }) {
const signupOk = useGameAccountSignup()
useEffect(() => {
if (signupOk === false) onDone()
}, [signupOk, onDone])
// `null` is "not yet", not "no" — see useGameAccountSignup.
if (signupOk !== true) {
return (
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
<span className="spin" />
</div>
)
}
return (
<>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
</p>
<CreateGameAccountForm submit={api.player.shard.createAccount} onCreated={onDone} />
</>
)
}

View File

@@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { useShardFeed } from '../lib/useShardFeed.js'
import { bucketize } from '../data/regionBuckets.js'
import api from '../api.js'
import { useAsync } from '../core.js'
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
// then keeps the total + region breakdown current from the presence.online SSE
// kind. The raw byRegion map is rolled up into display buckets (see
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
const PRESENCE_KINDS = new Set(['presence.online'])
export default function PlayersOnline() {
const { loading, error, data } = useAsync(() => api.shard.presence())
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
// The freshest snapshot wins: the newest buffered presence.online event, else
// the initial fetch.
const snapshot = events[0] || data
const { total, rows } = useMemo(() => {
const count = Number(snapshot?.count) || 0
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
return { total: count, rows: bucketRows }
}, [snapshot])
return (
<section className="panel" style={{ padding: 20 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
>
<span
style={{
color: 'var(--accent)',
fontSize: '0.7rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
Players online
</span>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
{loading ? '—' : total}
</span>
</div>
{error && (
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
Population is unavailable right now.
</p>
)}
{!loading && !error && (
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
</p>
) : (
rows.map((r) => (
<div
key={r.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
fontSize: '0.9rem',
color: 'var(--ink)',
}}
>
<span>{r.label}</span>
{/* tabular figures keep the right-aligned counts in a clean column */}
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
</div>
))
)}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import api from '../api.js'
import { useAuth } from '../core.js'
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
// game account. Reused wherever a linked account or character is shown to staff:
// the admin user-detail account list and the character sheet. Self-gates on role
// (admin/moderator) so it is safe to render inside components that players also
// see — a player never gets the controls, and the API enforces the same gate.
//
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
// confirm with an optional duration + reason before it fires.
export default function ShardAccountActions({ account, style }) {
const { user } = useAuth()
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const [banOpen, setBanOpen] = useState(false)
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
// Only staff who can actually use the write plane see the controls.
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
async function run(label, fn, done) {
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
const n = r && r.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked${sessions}.`
})
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
const ban = () =>
run('ban', () =>
api.admin.shardOps.ban({
account,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
setBanOpen(false)
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned${when}.`
})
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
return (
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
{banOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (sec, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
<span className="field-label">Reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
// ── Core's fill for the `site.footer.status` extension slot ────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
// MODULE_API.md §3.7.
//
// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
// it is a file now for one reason: `/uo/shard` is a UO page, so the link goes
// when the client half goes, and core should be deleting a registration rather
// than editing its footer under extraction pressure.
//
// Note what core kept and what it handed over. Core owns the position in the row
// and the separator around it, and passes `linkStyle` so the row stays visually
// one row. The label, the destination, and the decision to render at all are
// this file's — which is exactly the division a module inherits.
import { Link } from 'react-router-dom'
export default function ShardStatusLink({ linkStyle }) {
return (
<Link to="/uo/shard" style={linkStyle}>
Shard Status
</Link>
)
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { ago } from '../lib/format.js'
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
// sales for accounts linked to the caller.
export default function VendorSales({ fetchSales }) {
const [sales, setSales] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
fetchSales()
.then((rows) => active && setSales(rows))
.catch(() => active && setError('Could not load your vendor sales.'))
return () => { active = false }
}, [fetchSales])
if (error) return null
if (!sales) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
{sales.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{sales.map((s) => (
<li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}

77
client/src/core.js Normal file
View File

@@ -0,0 +1,77 @@
// ── What core hands this module, on the client side ────────────────────────
//
// The client twin of `server/core.js`, and deliberately much simpler than it.
// Every ported page imports its layout, its state components and its hooks from
// here, so the boundary is one file and `client/scripts/checkExternals.js` has
// one place to look. The normative contract is MODULE_API.md §3.2 and §3.4.
//
// **Why this is a plain read and the server's is a lazy accessor.** On the
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
// run — so `server/core.js` has to defer resolution to call time or a router
// would capture `undefined` at file scope. There is no such gap here.
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
// and every module chunk is a deferred script the server injects *after* that
// bundle's tag, so by the time the first line of this file executes the global
// is already there. Reading it once, at module scope, is safe — and it means a
// ported component keeps the ordinary `import { PageHeader } from '…'` shape
// rather than being wrapped in an accessor that would cost it its identity.
//
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
// also goes through — the shims touch the global before this file does, so a
// check here would be unreachable.
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { Link } from 'react-router-dom'
import { rg as shared } from './shim/rg.js'
const rg = shared()
// ── The shared-dependency self-check ───────────────────────────────────────
//
// Slice 0 carried this in entry.jsx, back when nothing else imported React and
// an unexercised alias was an unproven one. The aliases are thoroughly exercised
// now — thirty-five files import React and ten import the router — so what is
// left for a runtime check to do is narrower, and worth keeping for exactly that
// reason: the two BUILD guards (`assertSharedNotBundled` at resolution time,
// `checkExternals.js` on the artifact) both reason about the chunk in isolation,
// and neither can see the one failure that only exists once the chunk meets a
// core: a `window.__rg` whose React is not the React that rendered the page.
//
// Identity is the only question worth asking. A second React satisfies every
// type check, renders its first element happily, and then throws about an invalid
// hook call somewhere unrelated.
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
console.error(
'[module-uo] the bindings this chunk imported are not the ones core published — it has bundled ' +
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
)
}
// The curated kit (§3.4). Eight members, closed: anything else this module needs
// it bundles itself, which is why `components/` next door exists at all.
export const {
PublicLayout,
PageHeader,
Loading,
ErrorState,
EmptyState,
useAsync,
useAuth,
useSite,
// Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED
// direction — this module declares a place on its own page and CORE fills it.
// Shared rather than reimplemented so core's content failing inside our page is
// contained by core's own error boundary.
Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.
export const registry = rg.registry
// The core API version this module was loaded against. Logged by entry.jsx —
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check, only something to report.
export const coreApiVersion = rg.version
export default rg

View File

@@ -0,0 +1,31 @@
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
// emoji sigil + a ring colour — enough to make the Governors board and the
// governor badge read as distinct "crests" today, swappable for real artwork
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
// public path) onto an entry and update CityCrest to prefer it.
//
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
export const CITY_CRESTS = {
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
}
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
// Look up a crest by the raw city key, tolerating spacing variants
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
export function crestFor(city) {
if (!city) return FALLBACK
const key = String(city).replace(/\s+/g, '')
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
if (crest) return crest
return { ...FALLBACK, label: String(city) }
}

View File

@@ -0,0 +1,72 @@
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
// regions) up into a handful of labelled display buckets for the "Players Online"
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
// membership) and the widget follows. Anything not matched lands in "Wilderness"
// so the bucket counts always reconcile to the true total.
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
// list rather than one giant alternation regex (simpler to read and retune).
const TOWN_PREFIXES = [
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
'wind', 'delucia', 'papua',
]
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '')
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
// membership. First matching bucket wins; the last bucket is the catch-all.
export const BUCKETS = [
{
id: 'britain',
label: 'Britain',
// Passthrough for the capital + its immediate surrounds.
match: (r) => /^britain/i.test(r),
},
{
id: 'towns',
label: 'Towns',
// The other named cities/towns.
match: (r) => {
const norm = normalizeRegion(r)
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
},
},
{
id: 'dungeons',
label: 'Dungeons',
match: (r) =>
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
r,
),
},
{
id: 'housing',
label: 'Housing',
// House regions expose themselves as named house/townhouse regions.
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
},
{
id: 'wilderness',
label: 'Wilderness',
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
match: () => true,
},
]
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
// order, dropping empty buckets, with the summed total also returned.
export function bucketize(byRegion = {}) {
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
let total = 0
for (const [region, n] of Object.entries(byRegion || {})) {
const count = Number(n) || 0
total += count
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
totals.set(bucket.id, totals.get(bucket.id) + count)
}
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
(r) => r.count > 0,
)
return { rows, total }
}

View File

@@ -1,9 +1,8 @@
// ── module-uo's client entry point ─────────────────────────────────────────
//
// This file is the whole of the chunk's top-level behaviour: core injects
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module
// registers what it has, and core renders it. The normative contract is
// MODULE_API.md §3.3.
// Core injects `dist/entry.js` as a `<script type="module" src>` before
// `</body>`, this file registers what the module has, and core renders it. The
// normative contract is MODULE_API.md §3.3.
//
// **Registration is synchronous and happens at evaluation time.** Module scripts
// are deferred, so this runs after core's bundle — which is where `window.__rg`
@@ -14,69 +13,208 @@
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
// can see it, which is why §7.7's browser smoke exists.
//
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) registers NOTHING,
// on purpose. What it proves is the delivery path itself, and the imports below
// are how it proves the hardest part of it.
// So everything below is a plain top-level call, and every page is a static
// import. Lazy-loading the routes would be the natural instinct for a chunk this
// size and it is the one thing this seam cannot have.
// These four specifiers are the whole shared-dependency contract, written the
// ordinary way — which is the point. `vite.config.js` aliases each to a shim
// that re-exports from `window.__rg`, so what ends up in the chunk is core's
// React, core's renderer and core's router, and no second copy of any of them.
// A module author writes these imports exactly as they would in any app.
import { registry, coreApiVersion } from './core.js'
import { IconShard, IconUser } from './icons.jsx'
import { useShardFlags } from './lib/useShardFeatures.js'
// Public pages — the twelve that used to live at /site/*.
import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Guild from './routes/public/Guild.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
import Atlas from './routes/public/Atlas.jsx'
import AtlasCreature from './routes/public/AtlasCreature.jsx'
import Leaderboards from './routes/public/Leaderboards.jsx'
import Market from './routes/public/Market.jsx'
import MarketVendor from './routes/public/MarketVendor.jsx'
// Admin views.
import ShardAdmin from './routes/admin/ShardAdmin.jsx'
import ShardOps from './routes/admin/ShardOps.jsx'
import ShardVisibility from './routes/admin/ShardVisibility.jsx'
import SpawnAtlas from './routes/admin/SpawnAtlas.jsx'
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
// Player-portal views.
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
// Extension-slot fills (§3.7) — module content inside a core page.
import ShardStatusLink from './components/ShardStatusLink.jsx'
import UserShardSections from './routes/admin/UserShardSections.jsx'
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
const ID = 'uo'
// ── Routes ─────────────────────────────────────────────────────────────────
//
// They are here in slice 0 rather than arriving with the first page because an
// unexercised alias is an unproven one: with nothing importing `react`, the
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam
// this whole slice exists to prove has not been touched.
import { createElement, isValidElement } from 'react'
import { createRoot } from 'react-dom/client'
import { Link } from 'react-router-dom'
// Paths are relative to this module's namespace and core prefixes them:
// `/uo/…`, `/admin/uo/…`, `/player/uo/…`. A module cannot write the segment its
// routes hang under however it spells `path`, which is the point.
//
// **These SPA paths changed and the API paths did not.** `/site/shard` is now
// `/uo/shard` and `/admin/shard-ops` is now `/admin/uo/ops` — a clean break with
// no redirects, settled in MODULE_SYSTEM.md §2.7. Every URL in `api.js` is
// byte-identical to the one core called, because §1.2 freezes the API surface
// and the shipped Android app calls seven of these routes.
//
// The admin paths lost their `shard-` prefixes on the way through: under a `/uo/`
// namespace `/admin/uo/shard-visibility` says "shard" twice, and a clean break is
// the only moment that tidy-up is free.
//
// `gate` is core's own RoleGate, applied by core. A module cannot supply an auth
// wrapper — the sidebar and the route table have to agree about who may see what.
const STAFF = { roles: ['admin', 'moderator'] }
const rg = window.__rg
registry.registerRoutes(ID, {
public: [
{ path: 'shard', element: <Shard /> },
{ path: 'shard/activity', element: <ShardActivity /> },
{ path: 'champs', element: <ChampSpawns /> },
{ path: 'guilds', element: <Guilds /> },
{ path: 'guilds/:id', element: <Guild /> },
{ path: 'governors', element: <Governors /> },
{ path: 'houses', element: <Houses /> },
{ path: 'rules', element: <Rules /> },
{ path: 'atlas', element: <Atlas /> },
{ path: 'atlas/:slug', element: <AtlasCreature /> },
{ path: 'leaderboards', element: <Leaderboards /> },
{ path: 'market', element: <Market /> },
{ path: 'market/vendors/:serial', element: <MarketVendor /> },
],
admin: [
// Admin-only: the sidecar's configuration, who may see which surface, and
// the atlas import. No `gate` on the other three because AdminLayout already
// requires staff and these carry their own role rows below.
{ path: 'link', element: <ShardAdmin /> },
{ path: 'visibility', element: <ShardVisibility /> },
{ path: 'atlas', element: <SpawnAtlas /> },
{ path: 'ops', element: <ShardOps />, gate: STAFF },
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
// Self-service, and deliberately ungated: a staff member's own characters
// are theirs to read whatever their role. Staff are a superset of players.
{ path: 'characters', element: <AdminCharacters /> },
{ path: 'characters/:serial', element: <AdminCharacter /> },
],
player: [
{ path: 'characters', element: <PlayerCharacters /> },
{ path: 'characters/:serial', element: <PlayerCharacter /> },
],
})
// A module that cannot see the global is a module core did not load — which
// means the injection or the ordering broke, not the module. Say so, once,
// rather than throwing a TypeError about a property of undefined three frames
// deep in a component.
if (!rg) {
console.error('[module-uo] window.__rg is missing — core did not publish its shared dependencies before this chunk evaluated.')
} else {
// JSX, so the `react/jsx-runtime` alias is exercised too. That one is the
// easiest of the four to get wrong and the hardest to notice: Vite's
// object-form alias prefix-matches, so a `react` key silently captures
// `react/jsx-runtime` as well, and the failure surfaces as `jsx is not a
// function` in whichever component happens to render first.
const probe = <span>module-uo</span>
// ── Nav ────────────────────────────────────────────────────────────────────
//
// Rows interleave into CORE groups rather than appending as a "UO" block, which
// is what keeps the extraction invisible in the sidebar (MODULE_SYSTEM.md §1.4).
//
// `feature` names a flag resolved by the provider registered below — by THIS
// module, so the strings are the bare names they have always been and nothing
// parses a namespace out of them.
registry.registerNav(ID, {
area: 'public',
items: [
{ label: 'Shard', to: '/uo/shard', feature: 'status' },
{ label: 'Champions', to: '/uo/champs', feature: 'champs' },
{ label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
{ label: 'Governors', to: '/uo/governors', feature: 'governors' },
{ label: 'Houses', to: '/uo/houses', feature: 'houses' },
{ label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
{ label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
{ label: 'Market', to: '/uo/market', feature: 'market' },
],
})
// The self-check: are the bindings this chunk imported the SAME objects core
// published? Identity is the only question worth asking. A bundled second
// React satisfies every type check, renders its first element happily, and
// then throws about an invalid hook call somewhere unrelated.
const shared = [
['react', createElement === rg.react.createElement],
['react/jsx-runtime', isValidElement(probe)],
['react-dom/client', createRoot === rg.reactDom.createRoot],
['react-router-dom', Link === rg.router.Link],
]
const bundled = shared.filter(([, ok]) => !ok).map(([name]) => name)
registry.registerNav(ID, {
area: 'admin',
items: [
// Moderation: no `order`, because these two are last in that group today and
// "append after core's rows" is exactly that — and stays that way if core
// adds a moderation row later, which an explicit index would not.
{ label: 'In-Game Ops', to: '/admin/uo/ops', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
{ label: 'Houses', to: '/admin/uo/houses', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
// System: these three sit MID-list, between Discord Bot and Web Bot Activity.
// Core's rows are keyed by their index and an explicit `order` beats a
// coincidental one at a tie, so all three asking for 8 — Web Bot Activity's
// index once the UO rows are gone — lands them ahead of it, in this order.
{ label: 'Shard (uo-link)', to: '/admin/uo/link', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
{ label: 'Shard Visibility', to: '/admin/uo/visibility', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
{ label: 'Spawn Atlas', to: '/admin/uo/atlas', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
// No group: a trailing untitled group of its own, below core's Account row
// rather than beside it (§3.3). One position lower than it sits today, and
// the alternative — letting a module into core's furniture groups — is worse.
{ label: 'My Characters', to: '/admin/uo/characters', icon: IconShard },
],
})
if (bundled.length) {
console.error(
`[module-uo] ${bundled.join(', ')} did not come from window.__rg — the chunk has bundled its own copy. ` +
'Check the aliases in vite.config.js (MODULE_API.md §3.6).',
)
} else {
// Registrations land here, slice by slice:
//
// rg.registry.registerRoutes('uo', { public: [...], admin: [...], player: [...] })
// rg.registry.registerNav('uo', { area: 'public', items: [...] })
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures)
//
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi`
// before this file is ever served, so there is nothing to re-check here. It
// is logged because a mismatch between the core that validated the manifest
// and the core that published this global would otherwise be invisible from
// the browser, which is where the client half actually fails.
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`)
}
}
registry.registerNav(ID, {
area: 'player',
// Order 0: Characters is the portal's first row today, and with the module
// installed it is also what core's `/player` index resolves to.
items: [{ label: 'Characters', to: '/player/uo/characters', icon: IconUser, order: 0 }],
})
// ── Feature provider ───────────────────────────────────────────────────────
//
// Core keeps a generic flag context and owns none of the semantics. Until this
// slice core registered this same hook itself under owner id `core`, so that the
// seam was exercised by real content from the day it was built; the registration
// moves here and core's is deleted.
registry.registerFeatureProvider(ID, ID, useShardFlags)
// ── Extension slots ────────────────────────────────────────────────────────
//
// Three core pages have a piece of this module in them. Each was core's own fill
// under owner id `core` until this slice, so all three are a swap rather than an
// addition — and each throws rather than failing open if the slot is unknown or
// already filled, which is how a slice that forgot to delete core's half finds
// out immediately instead of rendering core's content forever (§3.7).
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// ── The inverted slot: this module DECLARES, core fills ────────────────────
//
// The other three above are core's slots that this module fills. This one is the
// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
// populates, but core does not own the word "guild" and publishes no Team page of
// its own — so the page is ours and core contributes the activity feed to it.
//
// Declared under this module's own namespace, which core enforces. The second
// argument is what gets core's content into the place: **core offers a
// CONTRIBUTION and never names a slot**, so this module says where each one goes
// and keeps its own word for the place. Core's fills are applied after every
// module chunk has evaluated, so declaring here is early enough; on a core that
// knows nothing of Teams the slot simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
// declarations rather than one, because a slot holds one component and this module
// wants to decide where each of core's two contributions sits on its own page —
// the feed reads as part of the guild's story, the forum is a room you go into.
// Neither knows the other exists, and a core that fills only one leaves the other
// empty.
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// And a THIRD, at the top of the same page, for core's per-Team notification
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
// muting a guild is an action ON this page, so it sits with the page's heading
// rather than after its content. Core resolves whether this viewer is in the
// Team at all — this module neither knows nor asks.
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a
// mismatch between the core that validated the manifest and the core that
// published this global would otherwise be invisible from the browser, which is
// where the client half actually fails.
console.info(`[module-uo] registered against core API ${coreApiVersion}`)

66
client/src/icons.jsx Normal file
View File

@@ -0,0 +1,66 @@
// The nav glyph for this module's sidebar rows.
//
// `icon` is part of the nav-item contract as of MODULE_API 1.3.0 (§3.3): core
// renders whatever component the row carries, exactly as it renders its own
// rows' icons. Before that it did not, and the six UO rows would have extracted
// as the only text-only entries in a sidebar where everything else has a glyph —
// which reads as breakage rather than as a design.
//
// The wrapper matches core's own `Icon` (AdminLayout.jsx) — 18px, currentColor,
// 1.6 stroke — deliberately and by copy, not by import. It is four attributes of
// presentation, not a component: putting it in the shared kit would freeze core's
// icon sizing into the contract, where changing it later would be a MAJOR bump.
// A module that wants to look like the sidebar it is in matches the sidebar.
const Icon = ({ children }) => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
)
/** A faceted gem — the glyph core used for all six of these rows before they moved. */
export const IconShard = () => (
<Icon>
<path d="M12 2l7 6-7 14-7-14z" />
<path d="M5 8h14" />
</Icon>
)
/**
* A figure — the glyph core used for the portal's "Characters" row.
*
* A second icon rather than reusing IconShard, because these two rows sit in
* different navs and each matched its neighbours before the extraction: the
* admin sidebar's UO rows were all gems, and the portal's Characters row was a
* person beside Appeals' shield and Account's gear. Copied from core's
* PlayerPortalLayout, which uses a 16px frame and a heavier stroke than the
* admin one — matching the nav a row lands in is the whole reason `icon` exists.
*/
export const IconUser = () => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
)
export default IconShard

36
client/src/lib/format.js Normal file
View File

@@ -0,0 +1,36 @@
// A vendored copy of the one helper this module uses from core's
// `client/src/lib/format.js`.
//
// **Vendored rather than added to the kit, and trimmed rather than copied
// whole.** The kit is curated and closed (MODULE_API.md §3.4): every member
// added to it is a minor version bump core can never take back, and a date
// formatter is not the kind of thing a module should be unable to write. Copying
// all six of core's helpers to get one would leave five with no consumer here
// and a standing question about which copy is authoritative.
//
// The vendoring line, from the server half of the extraction (slice 1): **pure
// leaf helpers may be copied, security controls may not.** This is a pure leaf.
// Core's HTML sanitiser sits two files away and stays exactly where it is.
//
// The two copies will drift, and that is correct — core's is core's to change.
// Nothing here reads a shared format.
function parse(value) {
if (!value) return null
const d = new Date(value)
return isNaN(d.getTime()) ? null : d
}
/** "3m ago". Coarse on purpose: the live feed's timestamps are approximate. */
export function ago(value) {
const d = parse(value)
if (!d) return ''
const secs = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000))
if (secs < 60) return `${secs}s ago`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
return `${days}d ago`
}

View File

@@ -0,0 +1,128 @@
// Shared formatting for shard events — used by the public Shard page, the
// Activity feed, and the admin live feed. One place decides how each kind reads
// and which category/badge it belongs to.
function nameOf(who) {
if (!who) return 'Someone'
if (typeof who === 'string') return who
return who.name || who.acct || 'Someone'
}
const n = (v) => Number(v || 0).toLocaleString()
// A one-line human description of each event kind, keyed by kind. Each formatter
// takes the payload and returns a string. Conditional suffixes are pulled into
// locals so no template literal is nested inside another.
const DESCRIBERS = {
'vendor.sale': (p) => {
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
},
'player.death': (p) => {
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
return `${nameOf(p.who)} was slain${by}`
},
'player.murdered': (p) => {
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
return `${nameOf(p.victim)} was murdered${by}`
},
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
'skill.gain': (p) => {
const base = p.base != null ? ` (${p.base})` : ''
return `${nameOf(p.who)} gained ${p.skill}${base}`
},
'fame.change': (p) => `${nameOf(p.who)}’s fame changed to ${n(p.new)}`,
'karma.change': (p) => `${nameOf(p.who)}’s karma changed to ${n(p.new)}`,
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}”`,
'house.decay': (p) => {
const region = p.region ? ` — ${p.region}` : ''
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
},
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
'server.shutdown': () => 'Shard shut down',
'server.crashed': (p) => {
const err = p.error ? `: ${p.error}` : ''
return `Shard crashed${err}`
},
'champ.update': (p) => {
const where = p.name || p.type || 'A champion spawn'
if (p.status === 'active' && p.bossUp) {
const boss = p.boss ? ` (${p.boss})` : ''
return `${where}: boss is up${boss}`
}
if (p.status === 'active') {
const level = p.level != null ? ` — level ${p.level}` : ''
return `${where} is active${level}`
}
if (p.status === 'cooldown') return `${where} is on cooldown`
return `${where} is ${p.status || 'idle'}`
},
'champ.remove': () => `A champion spawn ended`,
// Support (help-page) queue + in-game moderation (admin channel only)
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
'page.updated': (p) => {
const claimed = p.handled ? ' (claimed)' : ''
return `Help page from ${nameOf(p.sender)} updated${claimed}`
},
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
'admin.audit': (p) => {
const on = p.target ? ` on ${p.target}` : ''
const origin = p.origin ? ` [${p.origin}]` : ''
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
},
// Staff / sensitive (admin channel only)
'audit.set': (p) =>
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`,
'audit.command': (p) => {
const args = p.args ? ` ${p.args}` : ''
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
},
'cheat.fastwalk': (p) => {
const ip = p.ip ? ` (${p.ip})` : ''
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
},
'account.login.attempt': (p) => {
const ip = p.ip ? ` from ${p.ip}` : ''
return `Login attempt: ${p.acct}${ip}`
},
'gold.change': (p) => {
const sign = p.delta >= 0 ? '+' : ''
return `${p.acct}: gold ${sign}${n(p.delta)} → ${n(p.new)}`
},
}
// A one-line human description of an event. Accepts either a stored event
// (with .payload) or a raw live frame (fields at top level).
export function describe(ev) {
const fmt = DESCRIBERS[ev.kind]
return fmt ? fmt(ev.payload || ev) : ev.kind
}
// Category grouping for the filter tabs.
// Vendor sales are intentionally NOT a public category — they are owner-private
// (a linked player sees their own under the portal). The admin live feed still
// describes vendor.sale via describe() below.
export const CATEGORIES = [
{ id: 'all', label: 'All', kinds: null },
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
]
const CATEGORY_OF = (() => {
const m = {}
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
return m
})()
export function categoryOf(kind) {
return CATEGORY_OF[kind] || 'other'
}
// Short badge label for a kind (the part after the dot, title-cased-ish).
export function kindLabel(kind) {
return String(kind || '').replace(/[._]/g, ' ')
}

View File

@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react'
import api from '../api.js'
// Which shard surfaces the current viewer may reach, from
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
// Visibility), so the nav can't be a static list any more.
//
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
// and an out-of-rung one 403s whether or not the link is rendered. So while the
// answer is still in flight we return `null` and callers show their default set
// — better a link that briefly 403s than a nav that flickers in on every load.
//
// Cached module-level: the answer is per-viewer but stable for a session, and
// every consumer would otherwise refetch it on mount.
let cached = null
let inFlight = null
export function resetShardFeatures() {
cached = null
inFlight = null
}
export function useShardFeatures() {
const [features, setFeatures] = useState(cached)
useEffect(() => {
if (cached) return undefined
let alive = true
inFlight =
inFlight ||
api.shard
.features()
.then((data) => {
cached = {
level: data.level,
set: new Set(data.features || []),
// Not a visibility flag and deliberately carried alongside them: it
// is the same per-viewer, once-a-session answer from the same
// endpoint, and GameAccounts asking for it separately would be a
// second round-trip for a field already on the wire.
gameAccountSignup: Boolean(data.gameAccountSignup),
}
return cached
})
.catch(() => {
// A failed lookup must not blank the nav — fall back to "show
// everything" and let the server do the gating.
cached = null
inFlight = null
return null
})
inFlight.then((result) => {
if (alive) setFeatures(result)
})
return () => {
alive = false
}
}, [])
return features
}
// Convenience: true when `name` is visible, or when we don't know yet.
export function canSee(features, name) {
return !features || features.set.has(name)
}
// The same answer in the shape core's generic feature seam takes: a Set-like of
// the flags this viewer may see, or null while we do not know yet
// (core's modules/featureGate.js). This module registers it as the provider for
// the `uo` namespace in entry.jsx, and the nine shard-gated rows in the public
// header are ours to gate as of slice 3.
//
// It used to be core that registered this hook, under owner id `core`, so that
// the seam was exercised from the day it was built. That prediction held exactly
// — this slice deleted a registration and a file rather than rewriting a header.
export function useShardFlags() {
const features = useShardFeatures()
return features ? features.set : null
}
/**
* Does this site offer game-account creation right now?
*
* `null` while unknown, which callers must treat as "not yet" rather than "no":
* the form it guards would 403 anyway, and flashing it in and out is worse than
* arriving a beat late. Unlike the visibility flags above this one fails CLOSED
* on a lookup error — showing a create-account form on a shard that refuses them
* is a dead end the player cannot tell from a bug, whereas a hidden nav row has
* another way round.
*/
export function useGameAccountSignup() {
const features = useShardFeatures()
return features ? features.gameAccountSignup : null
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react'
import api from '../api.js'
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
// of the most recent events. The browser talks to our own /public/shard/stream
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
// stays server-side and it works through any reverse proxy.
//
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
// Set of kinds, optional) limits which events are buffered. `max` caps the
// buffer length.
export function useShardFeed({ url, filter, max = 40 } = {}) {
const [events, setEvents] = useState([])
const [connected, setConnected] = useState(false)
// Keep the latest filter in a ref so re-renders don't tear down the stream.
const filterRef = useRef(filter)
filterRef.current = filter
const streamUrl = url || api.shardStreamUrl
useEffect(() => {
// EventSource isn't available during SSR / very old browsers — degrade to
// "no live feed" rather than throwing.
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
const es = new EventSource(streamUrl, { withCredentials: true })
es.onopen = () => setConnected(true)
es.onerror = () => setConnected(false) // EventSource will retry on its own
es.onmessage = (msg) => {
let event
try {
event = JSON.parse(msg.data)
} catch {
return
}
if (!event || !event.kind) return
const f = filterRef.current
if (f && !f.has(event.kind)) return
setEvents((prev) => {
// Tag with a stable-ish local id for React keys (events carry t but can
// collide within a ms) and cap the buffer.
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
return next.slice(0, max)
})
}
return () => es.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [max, streamUrl])
return { events, connected }
}

View File

@@ -0,0 +1,28 @@
import { useParams, Link } from 'react-router-dom'
import CharacterSheet from '../../components/CharacterSheet.jsx'
import api from '../../api.js'
import { ErrorState, Loading, useAsync } from '../../core.js'
// A staff member's own character sheet inside the admin shell. Owner-checked —
// the endpoint only returns a sheet for a character on the caller's linked account.
export default function AdminCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div style={{ maxWidth: 760 }}>
<p style={{ margin: '0 0 18px' }}>
<Link to="/admin/uo/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
← Back to my characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} moderation />}
</div>
)
}

View File

@@ -0,0 +1,18 @@
import CharacterStats from '../../components/CharacterStats.jsx'
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import api from '../../api.js'
// Staff link their OWN in-game account and view their characters — the same
// shared component players use, pointed at the staff self-service endpoints.
// Sits inside the Admin shell, which supplies the "My Characters" page header;
// stat tiles bring it to parity with the Player Portal's Characters page.
export default function AdminCharacters() {
return (
<section style={{ maxWidth: 760 }}>
<CharacterStats scope={api.admin.shard} />
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/uo/characters/${serial}`} />
<VendorSales fetchSales={api.admin.shard.sales} />
</section>
)
}

View File

@@ -0,0 +1,118 @@
import { useMemo, useState } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, useAsync } from '../../core.js'
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
// live from the admin SSE channel (house.update / house.remove).
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
function DecayBadge({ decay, isIdoc }) {
const label = isIdoc ? 'IDOC' : decay
if (!label) return null
const tone = DECAY_TONE[label] || 'var(--muted)'
return (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
export default function HousesAdmin() {
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
// Full registry deltas ride the admin SSE channel (never the public one).
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
const [q, setQ] = useState('')
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev.serial) continue
if (ev.kind === 'house.update') {
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
} else if (ev.kind === 'house.remove') {
map.delete(ev.serial)
} else if (ev.kind === 'house.decay') {
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
}
}
return [...map.values()]
}, [data, events])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
)}
</section>
)
}

View File

@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe, kindLabel } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
import { ErrorState, Loading } from '../../core.js'
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
// cheat detection and login attempts that the public channel never carries.
function AdminLiveFeed() {
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
{events.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
// uo-link sidecar control panel. The auth token is write-only over this API —
// stored encrypted, never returned — same convention as the Discord bot token.
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
// effect immediately with no redeploy.
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
const STATUS_COLOR = {
connected: '#7fd0a4',
reconnecting: '#e0b070',
error: '#d98b84',
disconnected: 'var(--muted)',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
const ingest = config.ingest || {}
const health = config.health || {}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'disconnected'}
</span>
</div>
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
</div>
</div>
)
}
// ── Game-account signup ─────────────────────────────────────────────────────
//
// This field lived in core's Site Settings until slice 3 of the extraction. It
// moved here rather than being deleted or left behind, because its help text has
// always described an agreement between this site and a ServUO shard — and half
// of that agreement is configured in Bridge.cfg, which core has never heard of.
//
// The setting key and value are unchanged (`game_account_signup`), so an
// instance that had this configured finds it here, set to what it was.
const SIGNUP_MODES = [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
]
function GameSignup() {
const [mode, setMode] = useState(null)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let active = true
api.admin.getSignupMode()
.then((r) => active && setMode(r.mode))
.catch(() => active && setError('Could not load the signup mode.'))
return () => { active = false }
}, [])
async function save(next) {
const previous = mode
setMode(next); setBusy(true); setMsg(''); setError('')
try {
await api.admin.saveSignupMode(next)
setMsg('Saved.')
} catch (err) {
setMode(previous) // the select must not show a mode the server did not take
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Game-account creation</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
Whether players can create a GAME account (for the game client) from the site. The game server’s own
SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.
When enabled, a “Create a game account” form appears in the player portal and after an invite is accepted.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Mode</span>
<select
value={mode ?? ''}
onChange={(e) => save(e.target.value)}
disabled={busy || mode === null}
className="input"
style={{ maxWidth: 420 }}
>
{mode === null && <option value="">Loading…</option>}
{SIGNUP_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', minHeight: 20 }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</section>
)
}
// ── Town crier ──────────────────────────────────────────────────────────────
function TownCrier() {
const [id, setId] = useState('')
const [text, setText] = useState('')
const [durationSec, setDurationSec] = useState(3600)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function post() {
setBusy(true); setMsg(''); setError('')
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
if (!id.trim() || lines.length === 0) {
setBusy(false)
return setError('An id and at least one line are required.')
}
try {
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
setMsg(`Posted “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not post.')
} finally {
setBusy(false)
}
}
async function remove() {
if (!id.trim()) return setError('Enter the id to remove.')
setBusy(true); setMsg(''); setError('')
try {
await api.admin.deleteTownCrier(id.trim())
setMsg(`Removed “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message id</span>
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Lines (one per line)</span>
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (seconds)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</section>
)
}
export default function ShardAdmin() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(3)
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [saveError, setSaveError] = useState('')
const pollRef = useRef(null)
const initializedRef = useRef(false)
const load = useCallback(async () => {
try {
const c = await api.admin.getUoLinkConfig()
setConfig(c)
// Seed the editable fields once; later polls only refresh the status panel
// so they never clobber what the admin is mid-typing.
if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 3)
setEnabled(c.enabled)
initializedRef.current = true
}
} catch {
setError('Could not load uo-link config.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 5000)
return () => clearInterval(pollRef.current)
}, [load])
async function save() {
setBusy(true); setMsg(''); setSaveError('')
try {
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
if (token) body.token = token
const saved = await api.admin.saveUoLinkConfig(body)
setConfig(saved)
setToken('')
setMsg('Saved.')
} catch (err) {
setSaveError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!config) return <Loading />
return (
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
<StatusPanel config={config} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
<label style={{ display: 'block' }}>
<span className="field-label">Base URL (REST)</span>
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">WebSocket URL (feed)</span>
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Auth token</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Protocol</span>
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
</div>
<GameSignup />
<TownCrier />
<AdminLiveFeed />
</section>
)
}

View File

@@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
// unban) and the help-page support queue, plus a live audit log. Open to admins
// and moderators. The acting staff member (`actor`) is attached server-side from
// the session — nothing here sends it — so every action is attributable.
function Flash({ ok, err }) {
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
return null
}
// ── Broadcast ────────────────────────────────────────────────────────────────
function Broadcast() {
const [text, setText] = useState('')
const [hue, setHue] = useState('')
const [busy, setBusy] = useState(false)
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
async function send() {
if (!text.trim()) return setErr('Enter a message.')
setBusy(true); setOk(''); setErr('')
try {
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
setOk('Broadcast sent.')
setText('')
} catch (e) {
setErr(e.message || 'Could not broadcast.')
} finally {
setBusy(false)
}
}
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
function AccountActions() {
const [account, setAccount] = useState('')
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const acct = account.trim()
function guard() {
if (!acct) {
setErr('Enter an account name.')
return false
}
return true
}
async function run(label, fn, done) {
if (!guard()) return
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
const n = r?.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked ${acct}${sessions}.`
})
const ban = () =>
run(
'ban',
() =>
api.admin.shardOps.ban({
account: acct,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned ${acct}${when}.`
},
)
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Support (help-page) queue ────────────────────────────────────────────────
function PageRow({ page, onDone }) {
const [message, setMessage] = useState('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function respond(close) {
if (!message.trim()) return setErr('Enter a reply first.')
setBusy(close ? 'respond-close' : 'respond'); setErr('')
try {
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
onDone()
} catch (e) {
setErr(e.message || 'Could not send.')
setBusy('')
}
}
async function close() {
setBusy('close'); setErr('')
try {
await api.admin.shardOps.closePage(page.pageId)
onDone()
} catch (e) {
setErr(e.message || 'Could not close.')
setBusy('')
}
}
return (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
function SupportQueue() {
const [pages, setPages] = useState(null)
const [err, setErr] = useState('')
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
setPages(await api.admin.shardOps.pages())
} catch {
setErr('Could not load the support queue.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 7000)
return () => clearInterval(pollRef.current)
}, [load])
let queueBody
if (pages == null) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
} else if (pages.length === 0) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
} else {
queueBody = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{queueBody}
</section>
)
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Seeded from the stored admin.audit history, then kept live from the admin SSE
// channel (which carries every kind — we filter to admin.audit here).
function AuditLog() {
const [seed, setSeed] = useState([])
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
useEffect(() => {
api.admin.shardOps
.audit(50)
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
.catch(() => setSeed([]))
}, [])
// Live events on top; fall back to the seed for anything older than the live tail.
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState } from 'react'
import api from '../../api.js'
import { ErrorState, Loading } from '../../core.js'
// ── Admin · Shard visibility ────────────────────────────────────────────────
//
// Who may see which shard surface, and which sensitive fields within it.
// Admin-only, because this decides what ANONYMOUS visitors get.
//
// Two things the UI must communicate honestly, because they are not negotiable
// server-side (see docs/link/v3.md §3.4):
// • acct / webId are admin-only always and are not listed as editable fields.
// • an event kind the server doesn't know about never reaches anyone below
// admin, whatever is set here.
//
// Defaults reproduce the behavior the site had before this panel existed, so a
// fresh install shows "everything as it was" rather than an empty form.
const RUNG_LABEL = {
anonymous: 'Everyone',
logged_in: 'Signed in',
player: 'Linked players',
staff: 'Staff',
admin: 'Admins only',
}
const RUNG_HINT = {
anonymous: 'Visible to anyone, signed in or not.',
logged_in: 'Any signed-in account, linked or not.',
player: 'Accounts with a linked game account. Staff always qualify.',
staff: 'Admins and moderators.',
admin: 'Admins only.',
}
const FEATURE_LABEL = {
status: 'Shard status',
activity: 'Activity feed',
champs: 'Champion spawns',
guilds: 'Guilds',
governors: 'Town governors',
houses: 'Houses / IDOC',
presence: 'Players online',
ruleset: 'Shard rules',
atlas: 'Spawn atlas',
leaderboards: 'Leaderboards',
market: 'Marketplace',
}
const FEATURE_HINT = {
status: 'Connection state, online count, gold-supply series.',
activity: 'Deaths, kills, skill gains, quests, logins.',
champs: 'The live champion / mini-champ / sea-boss board.',
guilds: 'Guild rosters, alliances and leaders.',
governors: 'City Loyalty governors, elections and term history.',
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
presence: 'Population aggregate and the staff-online widget.',
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
leaderboards: 'Point and loyalty standings across every points system.',
market: 'The shard-wide player-vendor index.',
}
const FIELD_LABEL = {
owner: 'House owner',
price: 'House price',
location: 'In-game location (map + coordinates)',
connect: 'Server connect address',
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
// projection matches literal JSON keys, so the rule cannot be spelled after the
// field's meaning. The label is what carries the meaning to the admin.
name: 'Character names on leaderboards',
ownerName: 'Vendor owner name',
// One rule, one key — `location` is a nested object on both the wire frame and
// the stored read model precisely so that hiding it takes the facet, the
// coordinates, the region and the house together.
ownerSerial: 'Vendor owner character id',
}
function RungSelect({ value, onChange, ladder, disabled }) {
return (
<select
className="input"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{ maxWidth: 200 }}
>
{ladder.map((rung) => (
<option key={rung} value={rung}>
{RUNG_LABEL[rung] || rung}
</option>
))}
</select>
)
}
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
const fields = Object.entries(settings.fields || {})
const changed =
defaults &&
(settings.enabled !== defaults.enabled ||
settings.audience !== defaults.audience ||
settings.stream !== defaults.stream ||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
return (
<div
style={{
border: '1px solid var(--line)',
borderRadius: 10,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
opacity: settings.enabled ? 1 : 0.62,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{FEATURE_LABEL[name] || name}
{changed && (
<span
className="sans"
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
>
changed
</span>
)}
</h3>
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
{FEATURE_HINT[name]}
</p>
</div>
<label
className="sans"
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
>
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Who can see it</span>
<RungSelect
value={settings.audience}
ladder={ladder}
disabled={!settings.enabled}
onChange={(audience) => onPatch(name, { audience })}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
{RUNG_HINT[settings.audience]}
</span>
</label>
<label
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
>
<input
type="checkbox"
checked={settings.stream}
disabled={!settings.enabled}
onChange={(e) => onPatch(name, { stream: e.target.checked })}
/>
Live updates
</label>
</div>
{fields.length > 0 && (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
Sensitive fields
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
{fields.map(([field, rung]) => (
<label key={field} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
{FIELD_LABEL[field] || field}
</span>
<RungSelect
value={rung}
ladder={ladder}
disabled={!settings.enabled}
onChange={(level) =>
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
}
/>
</label>
))}
</div>
</div>
)}
</div>
)
}
export default function ShardVisibility() {
const [config, setConfig] = useState(null)
const [defaults, setDefaults] = useState(null)
const [ladder, setLadder] = useState([])
const [lockedFields, setLockedFields] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.getShardVisibility()
setConfig(data.features)
setDefaults(data.defaults)
setLadder(data.ladder || [])
setLockedFields(data.lockedFields || [])
} catch (err) {
setError(err.message || 'Could not load visibility settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function patch(name, changes) {
setMsg('')
setConfig((prev) => {
const next = { ...prev[name], ...changes }
// `fieldRules` in the API is `fields` in the effective config.
if (changes.fieldRules) {
next.fields = changes.fieldRules
delete next.fieldRules
}
return { ...prev, [name]: next }
})
}
async function save() {
setSaving(true)
setMsg('')
setError('')
try {
const body = {}
for (const [name, s] of Object.entries(config)) {
body[name] = {
enabled: s.enabled,
audience: s.audience,
stream: s.stream,
fieldRules: s.fields || {},
}
}
const data = await api.admin.saveShardVisibility(body)
setConfig(data.features)
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setSaving(false)
}
}
function resetToDefaults() {
setMsg('')
setConfig(structuredClone(defaults))
}
if (loading) return <Loading />
if (error && !config) return <ErrorState message={error} onRetry={load} />
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Shard visibility
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
Choose who can see each shard surface on the public site, and how much detail they get.
Turning a feature off hides it entirely — its pages return “not found” rather than
revealing that it exists. “Live updates” controls whether the feature streams changes in
real time; the pages still work without it, they just refresh on load.
</p>
{lockedFields.length > 0 && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
game account names and website user ids are never shown below admin, on any surface. They
aren’t visible in game either, so publishing them would disclose something the shard
itself doesn’t.
</p>
)}
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{Object.entries(config).map(([name, settings]) => (
<FeatureRow
key={name}
name={name}
settings={settings}
defaults={defaults?.[name]}
ladder={ladder}
onPatch={patch}
/>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
{saving ? 'Saving…' : 'Save changes'}
</button>
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
Restore defaults
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}

View File

@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from 'react'
import api from '../../api.js'
import { ErrorState, Loading } from '../../core.js'
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
//
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
// this panel exists for the three things a restart cannot do:
//
// • point it at a different tree,
// • apply a map change without restarting, and
// • answer a refresh that was parsed but deliberately NOT applied because it
// would remove a facet.
//
// That last one is the reason the panel is worth building. Losing a facet looks
// exactly like a half-copied or mid-update tree, and boot cannot tell them
// apart — so it stages the decision for a human instead of guessing. Until
// someone decides here, the site keeps serving the atlas it already had.
// A refresh reports its outcome rather than throwing (the boot path must never
// be stopped by a bad tree), so these are answers, not errors — the panel says
// what happened in the shard's terms instead of showing a failure box.
const OUTCOME = {
imported: (r) =>
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
unchanged: (r) =>
r.reason === 'refresh previously rejected'
? 'Unchanged — this exact tree was already reviewed and declined.'
: 'Unchanged — the tree matches what is already loaded.',
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
rejected: () => 'Declined. It will not be offered again until the tree changes.',
}
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
function Row({ label, children }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 16,
padding: '7px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim">{label}</span>
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
</div>
)
}
function PendingReview({ pending, busy, onApprove, onReject }) {
const declined = pending.status === 'rejected'
return (
<section
style={{
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
borderRadius: 10,
padding: 16,
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
}}
>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
</h3>
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
{declined ? (
<>
This tree was reviewed and declined, so it is not offered again until the files change.
Approving now applies it anyway.
</>
) : (
<>
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
</strong>
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
not applied. Approving re-parses the tree as it is right now — if you have since fixed the
mount, what lands is the corrected import.
</>
)}
</p>
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
Approve and import
</button>
{!declined && (
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
Keep the current atlas
</button>
)}
</div>
</section>
)
}
export default function SpawnAtlas() {
const [status, setStatus] = useState(null)
const [path, setPath] = useState('')
const [force, setForce] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.atlas.status()
setStatus(data)
setPath(data.path || '')
} catch (err) {
setError(err.message || 'Could not load atlas status.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
// Every mutating action shares this: run it, report what it said, then reload
// status so the panel reflects the world rather than what we assumed happened.
async function run(action, fn) {
setBusy(true)
setMsg('')
setError('')
try {
const result = await fn()
setMsg(describe(result))
const fresh = await api.admin.atlas.status()
setStatus(fresh)
setPath(fresh.path || '')
} catch (err) {
setError(err.message || `Could not ${action}.`)
} finally {
setBusy(false)
}
}
async function savePath() {
setBusy(true)
setMsg('')
setError('')
try {
const fresh = await api.admin.atlas.setPath(path.trim())
setStatus(fresh)
setPath(fresh.path || '')
setMsg(
fresh.path === ''
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
: fresh.treeReadable
? 'Saved. The tree is readable — import when you are ready.'
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
)
} catch (err) {
setError(err.message || 'Could not save the path.')
} finally {
setBusy(false)
}
}
if (loading) return <Loading />
if (error && !status) return <ErrorState message={error} />
const counts = status?.counts || null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Spawn atlas
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
It refreshes itself on every server start; everything here is for the times you don’t want
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
shard state, and stays complete while the shard is down.
</p>
</header>
{status?.pending && (
<PendingReview
pending={status.pending}
busy={busy}
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
/>
)}
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
What is loaded
</h3>
<Row label="Imported">
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
</Row>
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
{counts && (
<>
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
<Row label="Regions / landmarks">
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
</Row>
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
</>
)}
<Row label="Tree readable">
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
</Row>
<Row label="Tree changed since import">
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
</Row>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
ServUO tree
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
mount can move without a redeploy. Leave it blank to turn the atlas off.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="input"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/srv/servuo"
style={{ flex: '1 1 320px', minWidth: 0 }}
/>
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
Save path
</button>
</div>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
Re-import
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Applies a map change without restarting. An unchanged tree costs nothing — the source files
are hashed first and skipped when they match. A refresh that would remove a facet still
comes back here for approval rather than being applied.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy || !status?.configured}
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
>
{busy ? 'Working…' : 'Import now'}
</button>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
Re-import even if the tree is unchanged
</label>
</div>
</section>
{(msg || error) && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,163 @@
// ── Core's fill for the `admin.users.detail` extension slot ────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
// is UO, and every one of them leaves core with the client half in slice 3 —
// this file exists so that when they do, core deletes a registration and a file
// instead of unpicking a page.
//
// Core registers it through the same seam a module uses
// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
// server's `registries.registerCore()` and the same trick `useShardFlags`
// already uses for the feature seam. The mechanism is therefore exercised by
// core's own content from the day it lands, rather than first proved by the
// change that depends on it.
//
// The slot hands over `userId` and nothing else — deliberately, not `scope`.
// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
// that passed it would be handing a module something core is about to delete.
// An extension builds its own client for the routes it registered at the other
// end (MODULE_API.md §3.5), and this file does exactly what the module will.
import { useMemo } from 'react'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
import CharacterStats from '../../components/CharacterStats.jsx'
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import { useAsync } from '../../core.js'
// Its own copy, not an export from UserDetail.jsx: six lines of presentational
// furniture that is not in the §3.4 kit, so a module filling this slot would
// vendor the same thing. Core's copy stays behind with core's own security
// panel, which is the other caller.
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
export default function UserShardSections({ userId }) {
// Memoized so the child components' effects (keyed on `scope`) don't refetch
// on every render — the same reason UserDetail memoized it before this moved.
const scope = useMemo(() => api.admin.userShard(userId), [userId])
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/uo/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}

View File

@@ -0,0 +1,28 @@
import { useParams, Link } from 'react-router-dom'
import CharacterSheet from '../../components/CharacterSheet.jsx'
import api from '../../api.js'
import { ErrorState, Loading, useAsync } from '../../core.js'
// A player's character sheet inside the portal. Owner-checked: the endpoint only
// returns a sheet for a character on an account linked to the caller.
export default function PlayerCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div>
<p style={{ margin: '0 0 18px' }}>
<Link to="/player/uo/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
← Back to characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import api from '../../api.js'
import { useAsync } from '../../core.js'
// The logged-in player's characters. Shows the link prompt when no game account
// is linked, otherwise their characters grouped by account (shared component),
// plus their own home status and recent vendor sales.
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
// The caller's own houses (home status). Only their own — never anyone else's.
function MyHouses() {
const { data } = useAsync(() => api.player.shard.houses(), [])
if (!data || data.length === 0) return null
return (
<section style={{ marginTop: 30 }}>
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => {
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
return (
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{label && (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
{label}
</span>
)}
</div>
)
})}
</div>
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
</p>
</section>
)
}
export default function PlayerCharacters() {
return (
<div>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/uo/characters/${serial}`} />
<MyHouses />
<VendorSales fetchSales={api.player.shard.sales} />
</div>
)
}

View File

@@ -0,0 +1,307 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api.js'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// ── The spawn atlas ─────────────────────────────────────────────────────────
//
// What the shard CONTAINS, as opposed to what it is doing: which creatures
// spawn, where, and which champion altars are configured. There is no live feed
// here and no `connected` indicator, deliberately — this is parsed from the
// shard's own files and stays complete while the shard is down.
//
// Facet names come from the shard's data, never from a list in this file. A
// shard running custom maps gets its own names in the filter with no code
// change (docs/link/v3.md §6.1 R2).
const PAGE = 50
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
const TABS = [
{ key: 'creatures', label: 'Creatures' },
{ key: 'champions', label: 'Champion altars' },
{ key: 'places', label: 'Places' },
]
function Chip({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function CreatureCard({ creature }) {
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
return (
<Link
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
className="panel"
style={{
padding: '13px 15px',
display: 'flex',
alignItems: 'center',
gap: 14,
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{
fontSize: '0.98rem',
color: 'var(--head)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{creature.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{facets.length === 0
? '—'
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
{num(creature.points)} spawners
</div>
</div>
</Link>
)
}
// The creature list owns its own paging rather than going through useAsync: a
// "load more" appends to what is already on screen, which a hook that resets to
// `{ loading: true, data: null }` on every dependency change cannot express.
function Creatures({ q, facet }) {
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
const [more, setMore] = useState(false)
const load = useCallback(
async (offset) => {
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
return page
},
[q, facet],
)
useEffect(() => {
let alive = true
setState({ loading: true, error: null, items: [], total: 0 })
load(0)
.then((page) => {
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
})
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
return () => {
alive = false
}
}, [load])
const loadMore = async () => {
setMore(true)
try {
const page = await load(state.items.length)
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
} catch {
// A failed "load more" leaves what is already on screen alone; the button
// simply stays available to retry.
} finally {
setMore(false)
}
}
if (state.loading) return <Loading />
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
if (state.items.length === 0) {
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
}
return (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.items.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.items.map((c) => (
<CreatureCard key={c.slug} creature={c} />
))}
</div>
{state.items.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)
}
// The CONFIGURED altar roster — where the altars are and what each summons. The
// live board ("it is on level 3 right now") is a different page, /uo/champs,
// fed by the sidecar. Both exist; they are not the same thing.
function Champions({ facet }) {
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the champion altars right now." />
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((champ) => (
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
{champ.label || champ.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{champ.facet}
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
{champ.randomType ? 'Random champion' : champ.type || '—'}
</span>
</div>
))}
</div>
)
}
// Regions and landmarks together: both answer "where is that?", and splitting
// them into two tabs would make the visitor guess which list a name lives in.
function Places({ q, facet }) {
const { loading, error, data } = useAsync(
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
[q, facet],
)
const rows = useMemo(() => {
if (!data) return []
const [regions, landmarks] = data
return [
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
].sort((a, b) => a.name.localeCompare(b.name))
}, [data])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load places right now." />
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.map((row) => (
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
</div>
))}
</div>
)
}
export default function Atlas() {
const [tab, setTab] = useState('creatures')
const [input, setInput] = useState('')
const [q, setQ] = useState('')
const [facet, setFacet] = useState('')
const meta = useAsync(() => api.atlas.meta())
// Debounced: typing "lizardman" should be one request, not nine.
useEffect(() => {
const timer = setTimeout(() => setQ(input.trim()), 250)
return () => clearTimeout(timer)
}, [input])
const facets = meta.data?.facets || []
const counts = meta.data?.counts || null
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Bestiary"
title="Spawn atlas"
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
/>
{/* The atlas is only as good as its placement rate, so the page states
it rather than implying every spawner resolved to a named place. */}
{counts && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
{num(counts.creatures)} creatures across {num(counts.points)} spawners
{Number.isFinite(counts.unresolvedPoints) && counts.points
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
: ''}
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
</p>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{TABS.map((t) => (
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label}
</Chip>
))}
</div>
{tab !== 'champions' && (
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
style={{ width: '100%', marginBottom: 12 }}
/>
)}
{facets.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
<Chip active={facet === ''} onClick={() => setFacet('')}>
All facets
</Chip>
{facets.map((f) => (
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
{f}
</Chip>
))}
</div>
)}
{meta.error && <ErrorState message="Could not load the atlas right now." />}
{!meta.error && !meta.loading && !imported && (
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
)}
{!meta.error && imported && (
<>
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
{tab === 'champions' && <Champions facet={facet} />}
{tab === 'places' && <Places q={q} facet={facet} />}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,198 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import api from '../../api.js'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// One creature: where it spawns, and what spawns alongside it.
//
// `places` is the point of the page — the aggregate that turns 62 raw
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
// available underneath for the reader who actually wants a coordinate, but they
// are secondary and collapsed by default.
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
function delay(min, max) {
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
if (min === max) return fmt(min)
return `${fmt(min)}–${fmt(max)}`
}
function Panel({ title, right, children }) {
return (
<section className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
{title}
</h2>
{right}
</div>
{children}
</section>
)
}
function Places({ places }) {
if (places.length === 0) {
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
}
return (
<div>
{places.map((place) => (
<div
key={`${place.facet}:${place.label}`}
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '6px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
<span className="dim" style={{ flex: 'none' }}>
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
{num(place.maxAlive)} at once
</span>
</div>
))}
</div>
)
}
function Spawners({ spawners, truncated }) {
const [open, setOpen] = useState(false)
if (spawners.length === 0) return null
return (
<Panel
title="Individual spawners"
right={
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
>
{open ? 'Hide' : `Show ${num(spawners.length)}`}
</button>
}
>
{open && (
<div style={{ overflowX: 'auto' }}>
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
</tr>
</thead>
<tbody>
{spawners.map((s) => (
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
</tr>
))}
</tbody>
</table>
{truncated && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
Only the largest spawners are listed.
</p>
)}
</div>
)}
</Panel>
)
}
export default function AtlasCreature() {
const { slug } = useParams()
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
// A 404 here means "no such creature in this atlas", which is a real answer
// and not a failure — a visitor following a stale link deserves to be told
// that plainly rather than shown a generic error box.
const missing = error?.status === 404 || error?.message === 'Not Found'
const facets = useMemo(
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
[data],
)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p className="sans" style={{ marginBottom: 8 }}>
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
← Spawn atlas
</Link>
</p>
{loading && <Loading />}
{error && !missing && <ErrorState message="Could not load that creature right now." />}
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
{!loading && !error && data && (
<>
<PageHeader
eyebrow="Bestiary"
title={data.name}
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel
title="Where it spawns"
right={
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</span>
}
>
<Places places={data.places || []} />
</Panel>
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
{data.alsoHere?.length > 0 && (
<Panel title="Shares a spawner with">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{data.alsoHere.map((other) => (
<Link
key={other.slug}
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
className="sans"
style={{
fontSize: '0.78rem',
padding: '4px 11px',
borderRadius: 999,
border: '1px solid var(--line)',
color: 'var(--muted)',
textDecoration: 'none',
}}
>
{other.name} <span className="dim">×{num(other.shared)}</span>
</Link>
))}
</div>
</Panel>
)}
</div>
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,199 @@
import { useMemo } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
// families share the board, split by category into their own sections.
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
const SECTIONS = [
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
]
const STATUS_STYLE = {
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
}
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms)) return ''
if (ms <= 0) return 'due'
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
return `in ${hrs}h`
}
function StatusBadge({ status }) {
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
return (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.68rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
padding: '3px 9px',
borderRadius: 999,
color: s.fg,
background: s.bg,
border: `1px solid ${s.border}`,
}}
>
{s.label}
</span>
)
}
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
function Meter({ value, max, tone = 'var(--accent)' }) {
if (!max) return null
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
return (
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
</div>
)
}
// Category-specific middle line + meter for one spawn.
function ChampDetail({ s }) {
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
if (s.category === 'sea') {
return (
<>
<div className="sans" style={line}>
<span>{s.boss || s.type}</span>
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
</div>
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
</>
)
}
if (s.category === 'mini') {
return (
<div className="sans" style={line}>
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
</div>
)
}
// champion
let progress = ''
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
else if (s.status === 'active') {
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
}
return (
<>
<div className="sans" style={line}>
<span>
Level {s.level ?? 0}
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
</span>
<span>{progress}</span>
</div>
{s.status === 'active' && (
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
)}
</>
)
}
function ChampCard({ s }) {
return (
<div className="panel" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.name || s.type || 'Spawn'}
</strong>
<StatusBadge status={s.status} />
</div>
<ChampDetail s={s} />
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
</div>
</div>
)
}
export default function ChampSpawns() {
const { loading, error, data } = useAsync(() => api.shard.champs())
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
// buffered events oldest → newest (the buffer is newest-first) so live wins.
const board = useMemo(() => {
const map = new Map()
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev || !ev.serial) continue
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
}
return [...map.values()]
}, [data, events])
const byCategory = (id) =>
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
const activeCount = board.filter((s) => s.status === 'active').length
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the champion board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
{activeCount} active · {board.length} tracked
</p>
{SECTIONS.map((sec) => {
const rows = byCategory(sec.id)
if (rows.length === 0) return null
return (
<section key={sec.id} style={{ marginBottom: 28 }}>
<div style={{ marginBottom: 12 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
</div>
<div className="grid-2" style={{ gap: 12 }}>
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
</div>
</section>
)
})}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,184 @@
import { useMemo, useState } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { crestFor } from '../../data/cityCrests.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
// kept live by merging city.update deltas by city. Empty on shards without the
// City Loyalty system. Each city card links to its term history (look-back).
const GOV_KINDS = new Set(['city.update'])
const PHASE = {
none: null,
nominate: { label: 'Nominations open', color: '#7f8fd0' },
vote: { label: 'Voting', color: '#e6c26a' },
pending: { label: 'Result pending', color: '#c9a24b' },
}
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms) || ms <= 0) return ''
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `in ${hrs}h`
return `in ${Math.round(hrs / 24)}d`
}
function fmtDate(ms) {
if (ms == null) return ''
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function CityCrest({ city, size = 44 }) {
const c = crestFor(city)
return (
<span
aria-hidden="true"
style={{
flex: 'none', width: size, height: size, borderRadius: '50%',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
}}
>
{c.sigil}
</span>
)
}
// Collapsible term history for one city, fetched on demand from the ledger.
function TermHistory({ city }) {
const [open, setOpen] = useState(false)
const { loading, error, data } = useAsync(
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
[open, city],
)
return (
<div style={{ marginTop: 12 }}>
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
>
{open ? 'Hide past governors' : 'Past governors →'}
</button>
{open && (
<div style={{ marginTop: 8 }}>
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
{data && data.length === 0 && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
)}
{data && data.length > 0 && (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
{data.map((t) => (
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.governor?.name || 'Vacant'}
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
function CityCard({ c }) {
const phase = PHASE[c.electionPhase] || null
const gov = c.governor
const candidatePlural = c.candidates === 1 ? '' : 's'
return (
<div className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<CityCrest city={c.city} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
{crestFor(c.city).label || c.city}
</strong>
{phase && (
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
{phase.label}
</span>
)}
</div>
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
{gov ? (
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
) : (
'Seat vacant'
)}
</div>
</div>
</div>
{c.electionPhase && c.electionPhase !== 'none' && (
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
</div>
)}
<TermHistory city={c.city} />
</div>
)
}
export default function Governors() {
const { loading, error, data } = useAsync(() => api.shard.governors())
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
const board = useMemo(() => {
const map = new Map()
for (const c of data || []) if (c && c.city) map.set(c.city, c)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
}
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the governor board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
City Loyalty governance is not enabled on this shard.
</p>
</section>
) : (
<div className="grid-2" style={{ gap: 12 }}>
{board.map((c) => <CityCard key={c.city} c={c} />)}
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,122 @@
import { useParams, Link } from 'react-router-dom'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
// One guild: its roster, and the place core puts the Team activity feed.
//
// **This page is the reason the extension-slot direction inverts**
// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this
// module is what populates them — but core does not own the word "guild", so it
// publishes no Team page of its own. The page is this module's; the activity feed
// on it is core's, because only core can resolve whether the viewer is inside the
// Team, and the public/members split on that feed is a security boundary.
//
// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a
// core that does not know about Teams the slot is simply never filled and this
// page renders its roster alone, which is the same tolerance every other slot has.
//
// The roster comes from this module's OWN board — the same data it answers core's
// Team provider from — rather than from core's Team API. That is deliberate: the
// board is the authoritative copy here, and reading core's projection of our own
// answer back would be a round trip through a staler copy of our own data.
function rankOf(m) {
// Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for
// staff, because ServUO reports GameMaster-and-above as Leader whatever their
// real rank — emitting that verbatim would publish every staff member in a
// guild as one of its leaders (docs/link/v4.md).
if (m.rankName) return m.rankName
return null
}
function MemberRow({ m }) {
const rank = rankOf(m)
const linked = m.webId != null || m.acct != null
return (
<tr style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '9px 10px', color: 'var(--head)' }}>
{m.name || 'Unknown'}
{m.rank === 4 && (
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
)}
</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>{rank || '—'}</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>
{linked ? 'Linked' : '—'}
</td>
</tr>
)
}
export default function Guild() {
const { id } = useParams()
const { loading, error, data } = useAsync(() => api.shard.guild(id), [id])
const roster = (data && data.roster) || []
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p style={{ marginBottom: 14 }}>
<Link to="/uo/guilds">← All guilds</Link>
</p>
{loading && <Loading />}
{error && <ErrorState message="Could not load this guild right now." />}
{!loading && !error && data && (
<>
<PageHeader
eyebrow={data.abbr ? `[${data.abbr}]` : 'Guild'}
title={data.name || 'A guild'}
/>
<p className="sans dim" style={{ fontSize: '0.88rem' }}>
{data.members ?? roster.length} members
{data.online != null && ` · ${data.online} online`}
{data.alliance && ` · ${data.alliance}`}
</p>
{/* A third place for core, up here rather than below the roster: core
puts this guild's notification control in it, and a control that
acts on the page belongs beside the page's title and not after its
content. Empty for a visitor with no membership, and on a core
that fills nothing. */}
<Slot name="uo.guild.header" externalId={String(id)} moduleId="uo" />
{roster.length > 0 && (
<div style={{ overflowX: 'auto', marginTop: 18 }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Name</th>
<th style={{ padding: '8px 10px' }}>Rank</th>
<th style={{ padding: '8px 10px' }}>Account</th>
</tr>
</thead>
<tbody>
{/* Keyed by serial: two characters can share a display name,
which this shard's own world actually contains. */}
{roster.map((m) => <MemberRow key={m.serial} m={m} />)}
</tbody>
</table>
</div>
)}
{roster.length === 0 && (
<p className="sans dim" style={{ marginTop: 18 }}>No roster has been received for this guild yet.</p>
)}
{/* Core's Team activity feed lands here. Nothing renders on a core
that does not fill it, or when there is nothing to show. The guild
is named in OUR terms — core maps its own Team from these two. */}
<Slot name="uo.guild.detail" externalId={String(id)} moduleId="uo" />
{/* And the Team forum, in its own place below the feed. Core resolves
who may read it — membership and manual grants are core's rules —
so this module renders the room and never its door policy. */}
<Slot name="uo.guild.forum" externalId={String(id)} moduleId="uo" />
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,170 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// The guild board. Loaded once from /public/shard/guilds, then kept live by
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
// joined" strip on top of the board.
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
function Leader({ leader }) {
if (!leader || !leader.name) return <span className="dim">—</span>
return <span>{leader.name}</span>
}
function GuildRow({ g }) {
return (
// A link now, because the board gained a detail page: the roster and core's
// Team activity feed live there (docs/website/TEAMS.md Part 3).
<Link
to={`/uo/guilds/${encodeURIComponent(g.id)}`}
className="panel"
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
{g.abbr && (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.72rem',
letterSpacing: '0.06em',
color: 'var(--accent)',
border: '1px solid rgba(201,162,75,0.4)',
borderRadius: 5,
padding: '1px 6px',
}}
>
{g.abbr}
</span>
)}
<strong
className="display"
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{g.name || 'A guild'}
</strong>
</div>
{g.alliance && (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{g.alliance}
</div>
)}
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
<div>
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
<span className="dim"> / {g.members ?? 0}</span>
</div>
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
<Leader leader={g.leader} />
</div>
</div>
</Link>
)
}
export default function Guilds() {
const { loading, error, data } = useAsync(() => api.shard.guilds())
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
const [q, setQ] = useState('')
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
const board = useMemo(() => {
const map = new Map()
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
}
return [...map.values()]
}, [data, events])
// Recent joins strip (newest first, deduped, capped).
const joins = useMemo(
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
[events],
)
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((g) =>
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
)
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the guild board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
</section>
) : (
<>
{joins.length > 0 && (
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
Recently joined
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{joins.map((j) => (
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
<span className="dim"> joined </span>
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
</div>
))}
</div>
</section>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
{board.length} guilds · {totalMembers.toLocaleString()} members
</p>
<input
className="input sans"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search guilds…"
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
</div>
{filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
)}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,88 @@
import { useMemo } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
// price, decay detail and the full registry are staff-only (admin Houses view).
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
// house entering IDOC appears, one leaving it drops off.
const HOUSE_KINDS = new Set(['house.decay'])
function HouseRow({ h }) {
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<span
aria-hidden="true"
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.region || 'The wilderness'}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
IDOC
</span>
</div>
)
}
export default function Houses() {
const { loading, error, data } = useAsync(() => api.shard.houses())
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
// adds/updates the row; anything else (refreshed, collapsed) drops it.
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind !== 'house.decay' || !ev.serial) continue
if (String(ev.to).toUpperCase() === 'IDOC') {
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
} else {
map.delete(ev.serial)
}
}
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the houses board right now." />}
{!loading && !error && (
board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
{board.length} in danger
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
</>
)
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,236 @@
import { useMemo, useState } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useSite } from '../../core.js'
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
// players build over months, and none of them visible anywhere but an in-game gump
// until now.
//
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
// system, so live frames are merged over the fetched set by system key rather than
// replacing it.
const POINTS_KINDS = new Set(['points.board'])
// A board's display name may arrive as a literal (`nameString`), a cliloc id
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
// table on the site, so a cliloc-only board falls back to humanising its own
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
// "Clean Up Britannia"). Better than showing a bare number.
const humanise = (key) =>
String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase())
const boardTitle = (b) => b.nameString || humanise(b.system)
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
// Merge live frames over the fetched boards. Newest frame per system wins; a
// system that has never appeared in either is simply absent.
function mergeBoards(fetched, events) {
const bySystem = new Map()
for (const b of Array.isArray(fetched) ? fetched : []) {
if (b && b.system) bySystem.set(b.system, b)
}
// Events arrive newest-first, so walk backwards and let the newest land last.
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]
if (ev && ev.system) bySystem.set(ev.system, ev)
}
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
}
function Medal({ rank }) {
// Gold / silver / bronze for the podium, plain for the rest.
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
return (
<span
className="display"
style={{
flex: 'none', width: 26, textAlign: 'right', color: tone,
fontSize: rank <= 3 ? '1rem' : '0.86rem',
}}
>
{rank}
</span>
)
}
// One ranked player. `name` is absent rather than empty when an admin has gated
// the leaderboards `name` field above this viewer's rung — the row still renders,
// because the standing itself is the point.
function Entry({ entry, best }) {
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<Medal rank={entry.rank} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<span
className="sans"
style={{
color: entry.name ? 'var(--ink)' : 'var(--muted)',
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{entry.name || 'Name hidden'}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{num(entry.points)}
</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
</div>
)
}
function Board({ board }) {
const { siteTitle } = useSite()
const top = Array.isArray(board.top) ? board.top : []
// Bars are relative to the board leader, not to maxPoints: most systems have no
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
// which would render every bar as a stub.
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
return (
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
{boardTitle(board)}
</h2>
{Number.isFinite(board.players) && (
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
{num(board.players)} ranked
</span>
)}
</div>
{top.length === 0 ? (
// A board nobody has scored on still gets a row, so the page reads as a set
// of standings waiting to be filled rather than a stack of blanks. It is
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
// a score goes — because a placeholder that looked like a real standing would
// be a fabricated one. The first real entry replaces it.
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
<span
className="sans"
style={{
color: 'var(--muted)', fontSize: '0.86rem',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{siteTitle}
</span>
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>&mdash;</span>
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Nobody has earned points here yet.
</p>
</div>
) : (
<div>
{top.map((entry) => (
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
))}
</div>
)}
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
Maximum {num(board.maxPoints)} points
</span>
)}
</section>
)
}
export default function Leaderboards() {
const { loading, error, data } = useAsync(() => api.shard.points())
// Buffer generously: a single sweep can emit a frame for every system at once,
// and a board dropped from the buffer would silently revert to its fetched copy.
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
const [query, setQuery] = useState('')
const boards = useMemo(() => mergeBoards(data, events), [data, events])
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return boards
// Match the board name, the raw system key, or any ranked player on it — the
// last is what makes the filter useful ("where do I appear?").
return boards.filter(
(b) =>
boardTitle(b).toLowerCase().includes(q) ||
String(b.system).toLowerCase().includes(q) ||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
)
}, [boards, query])
return (
<PublicLayout section="website">
<div className="shell page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader
eyebrow="Live"
title="Leaderboards"
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
/>
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the leaderboards right now." />}
{!loading && !error && boards.length === 0 && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published any leaderboards yet.
</p>
</section>
)}
{!loading && !error && boards.length > 0 && (
<>
<input
className="input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by board or player name…"
aria-label="Filter leaderboards"
style={{ maxWidth: 340, marginBottom: 14 }}
/>
{shown.length === 0 ? (
<p className="sans dim">No board or ranked player matches “{query}”.</p>
) : (
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
{shown.map((board) => (
<Board key={board.system} board={board} />
))}
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,322 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api.js'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// ── The player-vendor marketplace ───────────────────────────────────────────
//
// What every player vendor on the shard is selling, for how much, and where it
// is standing — the same index the in-game Vendor Search gump reads, honouring
// the same per-vendor opt-out, reachable without logging in to the game.
//
// Three things this page must be honest about, all of them consequences of how
// the data is gathered (docs/link/v3.md §8):
//
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
// A page that implied live prices would send people across the world to a
// vendor whose item sold twenty minutes ago.
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
// publishes only the first N, and saying so beats presenting a partial shop
// as complete.
// • **An item may have no name.** On a shard whose operator has not converted
// a cliloc table, `displayName` is null and the honest render is the item id
// — not an invented name.
//
// There is deliberately no live feed here. The market feature's SSE stream ships
// disabled: a firehose of whole vendor inventories would be the site's single
// biggest bandwidth consumer, and nothing on this page needs it.
const PAGE = 50
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
const SORTS = [
{ key: 'price_asc', label: 'Cheapest' },
{ key: 'price_desc', label: 'Priciest' },
{ key: 'recent', label: 'Recently seen' },
]
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
// this is a worst case rather than an average — which is the number worth
// showing, because the one stale shop is the one that wastes a trip.
function staleness(staleAt) {
if (!staleAt) return null
const ms = Date.now() - new Date(staleAt).getTime()
if (!Number.isFinite(ms) || ms < 0) return null
const mins = Math.round(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.round(mins / 60)
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${Math.round(hours / 24)} days ago`
}
// The item's name, or an honest statement that we do not have one. Never a
// fabricated label — "Item 3922" would be indistinguishable from a real name.
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
function Chip({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function ListingRow({ listing }) {
const v = listing.vendor || {}
// `location` is one field the admin can gate away wholesale, so everything
// that reads from it has to tolerate its absence rather than assuming a map.
const loc = v.location || null
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
return (
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
{itemLabel(listing)}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{v.serial ? (
<Link to={`/uo/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
{v.shopName || 'an unnamed shop'}
</Link>
) : (
v.shopName || 'an unnamed shop'
)}
{v.ownerName ? ` · ${v.ownerName}` : ''}
{where ? ` · ${where}` : ''}
{/* Priced by the container it sits in, exactly as the in-game search
reports it — the price buys the whole container, not this item. */}
{listing.child ? ' · sold with its container' : ''}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
</div>
</div>
)
}
export default function Market() {
const [input, setInput] = useState('')
const [q, setQ] = useState('')
const [map, setMap] = useState('')
const [region, setRegion] = useState('')
const [sort, setSort] = useState('price_asc')
const [minPrice, setMinPrice] = useState('')
const [maxPrice, setMaxPrice] = useState('')
// Applied prices are separate from the typed ones so the search fires when the
// user is done, not on every digit of "250000".
const [prices, setPrices] = useState({ min: '', max: '' })
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
const [more, setMore] = useState(false)
const meta = useAsync(() => api.shard.marketMeta())
// Debounced: typing "vanquishing" should be one request, not eleven — and the
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
useEffect(() => {
const timer = setTimeout(() => setQ(input.trim()), 300)
return () => clearTimeout(timer)
}, [input])
useEffect(() => {
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
return () => clearTimeout(timer)
}, [minPrice, maxPrice])
const load = useCallback(
(offset) =>
api.shard.market({
q,
map,
region,
sort,
minPrice: prices.min,
maxPrice: prices.max,
limit: PAGE,
offset,
}),
[q, map, region, sort, prices],
)
useEffect(() => {
let alive = true
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
load(0)
.then((page) => {
if (!alive) return
setState({
loading: false,
error: null,
listings: page.listings || [],
total: page.total || 0,
staleAt: page.staleAt || null,
})
})
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
return () => {
alive = false
}
}, [load])
const loadMore = async () => {
setMore(true)
try {
const page = await load(state.listings.length)
setState((s) => ({
...s,
listings: [...s.listings, ...(page.listings || [])],
total: page.total ?? s.total,
staleAt: page.staleAt ?? s.staleAt,
}))
} catch {
// A failed "load more" leaves what is on screen alone; the button stays
// available to retry.
} finally {
setMore(false)
}
}
const maps = meta.data?.maps || []
const regions = meta.data?.regions || []
const age = staleness(state.staleAt)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Marketplace"
title="Player vendors"
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
/>
{/* Not decoration. The sweep is round-robin, so the index is inherently
up to one full cycle old and the page has to say so. */}
{age && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
Prices last refreshed {age}
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
</p>
)}
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search listings…"
style={{ width: '100%', marginBottom: 10 }}
/>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
type="number"
min="0"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Min price"
style={{ maxWidth: 140 }}
/>
<input
className="input"
type="number"
min="0"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder="Max price"
style={{ maxWidth: 140 }}
/>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
{SORTS.map((s) => (
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
{s.label}
</Chip>
))}
</div>
{/* Facet and region names come from the shard's own data, never a list in
this file — a shard running custom maps gets its own names here with
no code change (docs/link/v3.md §6.1 R2). */}
{maps.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
{maps.map((m) => (
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
))}
</div>
)}
{regions.length > 0 && (
<select
className="input"
value={region}
onChange={(e) => setRegion(e.target.value)}
style={{ width: '100%', marginBottom: 18 }}
>
<option value="">Anywhere</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
)}
{state.loading && <Loading />}
{state.error && <ErrorState message="Could not load the marketplace right now." />}
{!state.loading && !state.error && state.listings.length === 0 && (
<EmptyState>
{meta.data?.vendors
? 'Nothing on the shard matches that.'
: 'No player vendors have been indexed yet.'}
</EmptyState>
)}
{!state.loading && !state.error && state.listings.length > 0 && (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.listings.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.listings.map((l) => (
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
))}
</div>
{state.listings.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,99 @@
import { Link, useParams } from 'react-router-dom'
import api from '../../api.js'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// One player vendor: where to find it and everything it is selling.
//
// The page a search result points at. Two states it has to render honestly and
// which the search list cannot (docs/link/v3.md §8):
//
// • `truncated` — the shop holds more than the shard publishes per frame. A
// commodity reseller with thousands of stacks is a real thing, and showing
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
// which case there is nothing to render and the page says so rather than
// showing an empty coordinate.
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
export default function MarketVendor() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
if (loading) {
return (
<PublicLayout section="website">
<div className="shell-narrow page-body"><Loading /></div>
</PublicLayout>
)
}
if (error || !data) {
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
<p style={{ marginTop: 16 }}>
<Link to="/uo/market" className="sans">← Back to the marketplace</Link>
</p>
</div>
</PublicLayout>
)
}
const loc = data.location || null
const items = data.items || []
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
title={data.shopName || 'An unnamed shop'}
lead={
loc
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
: 'This shard does not publish vendor locations.'
}
/>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
{data.truncated
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
</p>
{items.length === 0 ? (
<EmptyState>This shop has nothing priced for sale.</EmptyState>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{items.map((i) => (
<div
key={i.serial}
className="panel"
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
{itemLabel(i)}
{i.child ? <span className="dim"> · sold with its container</span> : null}
</span>
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
{num(i.price)}
</span>
</div>
))}
</div>
)}
<p style={{ marginTop: 20 }}>
<Link to="/uo/market" className="sans">← Back to the marketplace</Link>
</p>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,338 @@
import { useMemo } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
// there is nothing to merge — latest wins).
//
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
// it cannot drift the way a hand-written rules page does. That is the whole point
// of the feature, and the page says so.
const RULESET_KINDS = new Set(['world.ruleset'])
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
// skill. Showing the raw number would be actively misleading.
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
// The systems block is a flat bag of booleans; these are their display names, and
// the order here is the order they render. A key the shard sends that we don't
// know about still renders, humanised, rather than being silently dropped — a new
// plugin must not go invisible against an older client.
const SYSTEM_LABELS = {
cityLoyalty: 'City Loyalty (governors)',
vvv: 'Vice vs Virtue',
factions: 'Factions',
siege: 'Siege ruleset',
chat: 'In-game chat',
store: 'Ultima Store',
dailyRares: 'Daily rares',
honesty: 'Honesty virtue',
shadowguard: 'Shadowguard',
treasureMaps: 'Treasure maps',
vetRewards: 'Veteran rewards',
testCenter: 'Test Center',
}
const humanise = (key) =>
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
function Panel({ title, children }) {
return (
<section className="panel" style={{ padding: 18 }}>
<h2
className="display"
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
>
{title}
</h2>
{children}
</section>
)
}
// A label/value row. Rows whose value is null are dropped by the caller, so a
// block never renders a dangling label for something the shard didn't publish.
function Row({ label, value }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '5px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
</div>
)
}
function Rows({ items }) {
const rows = items.filter(([, value]) => value !== null && value !== undefined)
if (rows.length === 0) return null
return (
<div>
{rows.map(([label, value]) => (
<Row key={label} label={label} value={value} />
))}
</div>
)
}
function SystemPill({ label, on }) {
const color = on ? '#8fdcae' : 'var(--muted)'
return (
<span
className="sans"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
fontSize: '0.8rem',
padding: '5px 11px',
borderRadius: 999,
color,
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
}}
>
<span
aria-hidden="true"
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
/>
{label}
</span>
)
}
function Systems({ systems }) {
// Known keys first in their declared order, then anything the shard added that
// this build doesn't know about.
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
const keys = [...known, ...extra]
if (keys.length === 0) return null
return (
<Panel title="Systems">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{keys.map((k) => (
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
))}
</div>
</Panel>
)
}
function Caps({ caps }) {
return (
<Panel title="Skill & stat caps">
<Rows
items={[
['Individual skill cap', tenths(caps.skill)],
['Total skill cap', tenths(caps.totalSkill)],
['Total stat cap', num(caps.stat)],
['Strength cap', num(caps.str)],
['Dexterity cap', num(caps.dex)],
['Intelligence cap', num(caps.int)],
['Strength max', num(caps.strMax)],
['Dexterity max', num(caps.dexMax)],
['Intelligence max', num(caps.intMax)],
]}
/>
</Panel>
)
}
function AccountsAndHousing({ accounts, housing, vetRewards }) {
const items = []
if (accounts) {
items.push(['Accounts per IP', num(accounts.perIp)])
items.push(['Character slots', num(accounts.charSlots)])
items.push([
'In-game account creation',
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
])
}
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
if (vetRewards?.enabled) {
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
? `${vetRewards.rewardIntervalDays} days`
: null])
}
if (items.length === 0) return null
return (
<Panel title="Accounts & housing">
<Rows items={items} />
</Panel>
)
}
function Champions({ champions }) {
const t = champions.rankThresholds
return (
<Panel title="Champion spawns">
<Rows
items={[
['Power scrolls per spawn', num(champions.powerScrolls)],
['Stat scrolls per spawn', num(champions.statScrolls)],
['Scroll drop chance', pct(champions.scrollChance)],
['Transcendence chance', pct(champions.transcendenceChance)],
[
'Red skulls per rank',
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
],
]}
/>
</Panel>
)
}
function Felucca({ loot }) {
return (
<Panel title="Felucca bonuses">
<Rows
items={[
['Luck bonus', num(loot.feluccaLuckBonus)],
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
['Max item properties', num(loot.feluccaMaxProps)],
]}
/>
</Panel>
)
}
function Vendors({ vendors }) {
return (
<Panel title="Vendors">
<Rows
items={[
['Restock delay', vendors.restockDelayMinutes
? `${vendors.restockDelayMinutes} min`
: null],
['Max items sold at once', num(vendors.maxSell)],
['Economy stock amount', num(vendors.economyStockAmount)],
]}
/>
</Panel>
)
}
function Pvp({ vvv }) {
return (
<Panel title="Vice vs Virtue">
<Rows
items={[
['Starting silver', num(vvv.startSilver)],
['Enhanced rules', vvv.enhancedRules === undefined
? null
: vvv.enhancedRules ? 'On' : 'Off'],
]}
/>
</Panel>
)
}
function Schedule({ schedule }) {
const items = []
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
} else if (schedule.autoSaveEnabled === false) {
items.push(['World save', 'Disabled'])
}
if (schedule.autoRestartEnabled) {
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
items.push(['Automatic restart', `${h}:${m} server time`])
if (schedule.autoRestartFrequencyHours) {
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
}
}
if (items.length === 0) return null
return (
<Panel title="Save & restart schedule">
<Rows items={items} />
</Panel>
)
}
export default function Rules() {
const { loading, error, data } = useAsync(() => api.shard.ruleset())
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
// The newest world.ruleset on the feed wins outright over the fetched copy —
// the frame is a complete ruleset, not a delta.
const ruleset = useMemo(() => events[0] || data || null, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader
eyebrow="Live"
title="Shard ruleset"
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
/>
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the shard ruleset right now." />}
{!loading && !error && !ruleset && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published its ruleset yet.
</p>
</section>
)}
{!loading && !error && ruleset && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel title="Shard">
<Rows
items={[
['Name', ruleset.shard || null],
['Expansion', ruleset.expansion || null],
['Connect', ruleset.connect || null],
]}
/>
</Panel>
{ruleset.systems && <Systems systems={ruleset.systems} />}
{ruleset.caps && <Caps caps={ruleset.caps} />}
<AccountsAndHousing
accounts={ruleset.accounts}
housing={ruleset.housing}
vetRewards={ruleset.vetRewards}
/>
{ruleset.champions && <Champions champions={ruleset.champions} />}
{ruleset.loot && <Felucca loot={ruleset.loot} />}
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
</div>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,250 @@
import { Link } from 'react-router-dom'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
import PlayersOnline from '../../components/PlayersOnline.jsx'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useAuth } from '../../core.js'
// Flavor line under the online/offline banner: online, configured-but-down, or
// not configured yet.
function statusMessage(online, enabled) {
if (online) return 'The gate to Britannia stands open.'
if (enabled) return 'The link to the game world is down — checking back automatically.'
return 'Live shard data is not configured yet.'
}
// ── Gold-supply sparkline ───────────────────────────────────────────────────
function Sparkline({ series }) {
if (!series || series.length < 2) return null
const w = 320
const h = 56
const golds = series.map((s) => Number(s.gold) || 0)
const min = Math.min(...golds)
const max = Math.max(...golds)
const span = max - min || 1
const pts = series
.map((s, i) => {
const x = (i / (series.length - 1)) * w
const y = h - ((Number(s.gold) || 0) - min) / span * h
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
function Stat({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
{label}
</div>
</div>
)
}
export default function Shard() {
const { loading, error, data } = useAsync(() =>
Promise.all([
api.shard.status(),
api.shard.idoc(),
api.shard.economy(60),
api.shard.online(),
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
const { user } = useAuth()
// Staff in-game location is privileged: only admins/moderators see it. Players
// and the public see that staff are online but not where. The server enforces
// this too (it omits the location fields entirely for non-privileged callers).
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
const status = data?.status
const online = status?.pluginConnected
const gold = status?.economy?.gold
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard" />
{loading && <Loading />}
{error && <ErrorState message="Could not load shard data right now." />}
{!loading && !error && data && (
<>
<ConnectionBanner online={online} status={status} />
{/* Stat tiles */}
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
</section>
{/* Live players-online breakdown (total + region buckets) */}
<div style={{ marginBottom: 24 }}>
<PlayersOnline />
</div>
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
{/* Economy sparkline */}
{data.economy && data.economy.length > 1 && (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
Gold supply over time
</div>
<Sparkline series={data.economy} />
</section>
)}
<div style={{ marginBottom: 24 }}>
{/* Latest IDOC */}
<FeedList
title="Houses in danger (IDOC)"
empty="No houses are collapsing right now."
items={data.idoc.map((h) => {
const region = h.region ? ` — ${h.region}` : ''
return {
id: h.serial,
text: `${h.name || 'A house'}${region}`,
when: h.updatedAt,
}
})}
/>
</div>
{/* Live ticker */}
<section className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
Live feed
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<Link to="/uo/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
View all activity →
</Link>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
Waiting for something to happen in the world…
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{events.map((ev) => (
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
</PublicLayout>
)
}
// Online/offline banner with the flavor line under it.
function ConnectionBanner({ online, status }) {
return (
<section
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '24px 26px',
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
borderRadius: 10,
background: online
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
marginBottom: 24,
}}
>
<span
style={{
flex: 'none',
width: 12,
height: 12,
borderRadius: '50%',
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
}}
/>
<div>
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
{online ? 'The shard is online' : 'The shard is offline'}
</strong>
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
{statusMessage(online, status?.enabled)}
</span>
</div>
</section>
)
}
// Linked staff accounts currently online; in-game location is admin/mod-only.
function StaffOnline({ list, canSeeLocation }) {
return (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
Staff online
</div>
{(!list || list.length === 0) ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((p) => (
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
{p.name || p.serial}
</span>
{canSeeLocation && (
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
</span>
)}
</div>
))}
</div>
)}
</section>
)
}
function FeedList({ title, items, empty }) {
return (
<section className="panel" style={{ padding: 20 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{title}
</div>
{items.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((it) => (
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -0,0 +1,78 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// Public activity feed: the full shard event log, filterable by category, with a
// live tail that prepends new events as they happen.
export default function ShardActivity() {
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
const { events: live } = useShardFeed({ max: 60 })
const [cat, setCat] = useState('all')
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
const merged = useMemo(() => {
const seen = new Set()
const out = []
for (const e of [...live, ...(data || [])]) {
const key = `${e.kind}-${e.t}`
if (seen.has(key)) continue
seen.add(key)
out.push(e)
}
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
}, [live, data])
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard Activity" />
<p style={{ marginTop: -8, marginBottom: 18 }}>
<Link to="/uo/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
</p>
{/* Category tabs */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
{CATEGORIES.map((c) => (
<button
key={c.id}
onClick={() => setCat(c.id)}
className="pill"
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
>
{c.label}
</button>
))}
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the activity feed right now." />}
{!loading && !error && (
filtered.length === 0 ? (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{filtered.map((e) => (
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
{kindLabel(e.kind)}
</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)
)}
</div>
</PublicLayout>
)
}

View File

@@ -7,7 +7,9 @@
// itself, only harder to see, because it shows up as a hook dispatcher error in
// a component that looks fine.
const jsxRuntime = window.__rg.jsxRuntime
import { rg } from './rg.js'
const jsxRuntime = rg().jsxRuntime
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime

View File

@@ -5,7 +5,9 @@
// react-dom, and one that resolved to a bundled copy would put a second
// renderer in the page.
const reactDom = window.__rg.reactDom
import { rg } from './rg.js'
const reactDom = rg().reactDom
export default reactDom.default ?? reactDom

View File

@@ -5,7 +5,9 @@
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
// instead of the SPA, on a page that otherwise renders perfectly.
const router = window.__rg.router
import { rg } from './rg.js'
const router = rg().router
export default router.default ?? router

View File

@@ -13,7 +13,9 @@
// compiles to a named import, and a module with only a default export would fail
// at link time in the browser with a message about the binding, not about this.
const react = window.__rg.react
import { rg } from './rg.js'
const react = rg().react
export default react.default ?? react

29
client/src/shim/rg.js Normal file
View File

@@ -0,0 +1,29 @@
// The one place this module reads `window.__rg`, and the one place that says
// something useful when it is not there.
//
// Every shim beside this file, and `src/core.js`, go through here. That is not
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
// modules evaluate dependencies in the source order of their import statements,
// so "put the friendly check in the file that is imported first" is a guarantee
// that survives exactly until someone sorts the imports. Whichever module the
// bundler happens to reach first, it reaches `window.__rg` through this.
//
// A missing global means core did not publish its shared dependencies before
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
// §3.1), not a fault in this module. Without this, the first symptom is
// "Cannot read properties of undefined (reading 'react')" thrown from a file
// called react.js, which reads like the module bundled React wrong — the
// opposite of what happened.
export function rg() {
const shared = window.__rg
if (!shared) {
throw new Error(
'[module-uo] window.__rg is missing — core did not publish its shared dependencies before this ' +
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
'fault in this module.',
)
}
return shared
}
export default rg

139
client/test/api.test.js Normal file
View File

@@ -0,0 +1,139 @@
// ── The URLs this module calls ─────────────────────────────────────────────
//
// `src/api.js` binds the paths whose routes live in `server/router/**`, and the
// interesting assertions about it are the ones that encode a DECISION rather
// than a spelling. Three of these came across from core's `apiClient.test.js`
// in slice 4: they had stayed behind when the bindings moved, still asserting
// UO URLs from inside core's suite, which is the boundary this phase removes.
//
// What is NOT re-tested here is the fetch wrapper itself — status mapping, empty
// bodies, FormData, cookie inclusion. That is `req`, core's primitive, and core
// tests it. A module asserting core's contract back at it is a second copy that
// drifts.
//
// The chunk reads its shared bindings off `window.__rg` at module scope
// (src/core.js), so the fake global has to be in place before `src/api.js` is
// imported — hence the dynamic import below rather than a static one.
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
import * as jsxRuntime from 'react/jsx-runtime'
const BASE = '/api/v1'
let calls = []
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
return {
ok: status >= 200 && status < 300,
status,
statusText,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
}
}
// Core's `req`, close enough for a path assertion: the only property this file
// cares about is the URL it was handed. Recording it here rather than mocking
// global.fetch keeps the test honest about the boundary — a module never sees
// fetch, it sees the primitive.
function request(path, opts = {}) {
calls.push({ url: BASE + path, opts })
return Promise.resolve(reply({ body: {} }).text().then(() => ({})))
}
// The REAL react/react-dom/router go in, not stubs: `src/core.js` compares the
// bindings it imported against the ones here and logs a "bundled its own copy"
// error when they differ. With stubs that error fires on every run of this file
// — a false alarm in the exact words of a real defect, which is how a check
// gets ignored.
globalThis.window = globalThis.window || {}
globalThis.window.__rg = {
react, reactDom, router, jsxRuntime,
api: { request, BASE },
ui: {},
registry: { registerRoutes() {}, registerNav() {}, registerFeatureProvider() {}, registerExtension() {} },
}
const { shard, atlas, admin } = await import('../src/api.js')
beforeEach(() => {
calls = []
})
afterEach(() => {
calls = []
})
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
// content parsed from the shard's own files, so it must not look sidecar-backed.
// Asserted because the split is a design decision, not an accident of spelling.
test('atlas reads hit /public/atlas, not /public/shard', async () => {
await atlas.creatures()
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
})
test('atlas.creatures() sends only the filters that are set', async () => {
await atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
const url = new URL(calls[0].url, 'http://x')
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
assert.equal(url.searchParams.get('q'), 'lizard man')
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
assert.equal(url.searchParams.get('limit'), '25')
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
})
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
await atlas.creature('lizardman/rare', { facet: 'Felucca' })
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
})
test('admin atlas actions use the right methods and bodies', async () => {
await admin.atlas.import(true)
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
assert.equal(calls[0].opts.method, 'POST')
assert.deepEqual(calls[0].opts.body, { force: true })
await admin.atlas.setPath('/srv/servuo')
assert.equal(calls[1].opts.method, 'PUT')
assert.deepEqual(calls[1].opts.body, { path: '/srv/servuo' })
})
// ── path encoding ───────────────────────────────────────────────────────────
// A city name with an apostrophe and a space is the real case: "Serpent's Hold"
// is a governor city, and an unencoded one would break the route match rather
// than 404 cleanly.
test('path params are URL-encoded', async () => {
await shard.governorHistory('Serpent’s Hold', 5)
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
})
// ── the API surface §1.2 freezes ────────────────────────────────────────────
// The shipped Android app calls these seven by name (data/api/AdminApi.kt), which
// is why the extraction moved which repo declares them and not what they are. A
// rename here is a client break, not a refactor.
test('the seven admin URLs the Android app calls are unchanged', async () => {
const expected = [
['kick', '/api/v1/admin/shard/kick'],
['ban', '/api/v1/admin/shard/ban'],
['unban', '/api/v1/admin/shard/unban'],
['broadcast', '/api/v1/admin/shard/broadcast'],
]
for (const [fn, url] of expected) {
calls = []
await admin.shardOps[fn]({})
assert.equal(calls[0].url, url, fn)
}
calls = []
await admin.shardOps.pages()
assert.equal(calls[0].url, '/api/v1/admin/shard/pages')
calls = []
await admin.shardOps.respondPage('7', {})
assert.equal(calls[0].url, '/api/v1/admin/shard/pages/7/respond')
calls = []
await admin.shardOps.closePage('7')
assert.equal(calls[0].url, '/api/v1/admin/shard/pages/7/close')
})

View File

@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const CLIENT = path.resolve(HERE, '..')
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
const configModule = await import('../vite.config.js')
const config = configModule.default
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
@@ -92,15 +93,63 @@ test('modulePreload polyfilling stays off — an inline bootstrap is refused und
assert.strictEqual(config.build.modulePreload.polyfill, false)
})
test('every shim reads from window.__rg and imports nothing', () => {
test('exactly one file reads window.__rg, and every shim goes through it', () => {
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
// makes the "core did not publish its dependencies" message reachable. The
// shims touch the global before anything else in the chunk does, so a check
// placed in the first-imported file is a guarantee that lasts until someone
// sorts the imports.
const dir = path.join(CLIENT, 'src', 'shim')
const shims = fs.readdirSync(dir)
assert.ok(shims.length >= 4)
assert.ok(shims.length >= 5)
for (const file of shims) {
const source = fs.readFileSync(path.join(dir, file), 'utf8')
assert.match(source, /window\.__rg/, `${file} does not read the global`)
// A shim that imported anything would be a shim with a dependency to
// resolve, which is the problem it exists to remove.
assert.doesNotMatch(source, /^\s*import\s/m, `${file} imports something`)
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
if (file === 'rg.js') {
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
continue
}
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
// A shim may import its sibling helper and nothing else — anything further
// would be a shim with a dependency to resolve, the problem it exists to remove.
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
}
}
})
test('the built chunk has no bare imports and bundles no shared dependency', () => {
// The artifact check itself, over the artifact that ships. Skipped rather than
// failed when there is no build: `npm test` must be runnable before `npm run
// build`, and CI runs them in order.
const chunk = path.join(CLIENT, 'dist', 'entry.js')
if (!fs.existsSync(chunk)) return
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
})
test('an import inside a string is not an import — the check reads code, not text', () => {
// The regression that made this necessary: slice 3's chunk was the first with
// any content in it, and a button labelled "Approve and import" put the token
// immediately before a quote. The check rejected the whole build, naming a
// fragment of minified JSX as the offending specifier.
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
assert.deepStrictEqual(bareImports(uiCopy), [])
// Neither is one in a comment, or in a template literal.
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
// And a real one still is, in each form the build could emit.
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
// A relative specifier is a split chunk, not a shared dependency: not our concern.
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
// string must not end it early and leave the tail looking like code.
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
})

View File

@@ -0,0 +1,60 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
// Unit-test the presence.online region roll-up for the "Players Online" widget.
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
// total — anything unmatched lands in Wilderness — so the widget can never show
// a sum that disagrees with the headline online count.
test('bucketize groups named regions into their buckets', () => {
const { rows, total } = bucketize({
'Britain': 4,
'Moonglow': 2,
'Despise': 3,
'Green Acres House 12': 1, // not a town/dungeon name → Housing
})
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.britain, 4)
assert.equal(byId.towns, 2)
assert.equal(byId.dungeons, 3)
assert.equal(byId.housing, 1)
assert.equal(total, 10)
})
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
// region whose name starts with a town name is bucketed as Towns. Pinning this
// documents the ordering dependency for anyone retuning BUCKETS.
const { rows } = bucketize({ 'Trinsic House 12': 1 })
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.towns, 1)
assert.equal(byId.housing, undefined) // empty bucket dropped
})
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
const wilderness = rows.find((r) => r.id === 'wilderness')
assert.equal(wilderness.count, 7)
assert.equal(total, 7)
// The reconciliation guarantee: the buckets sum to the total, exactly.
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
})
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
})
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
assert.deepEqual(bucketize(), { rows: [], total: 0 })
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
assert.equal(total, 3) // '3' → 3, 'oops' → 0
})
test('the last bucket is the catch-all (its match accepts anything)', () => {
const last = BUCKETS[BUCKETS.length - 1]
assert.equal(last.id, 'wilderness')
assert.equal(last.match('literally anything'), true)
})

View File

@@ -0,0 +1,252 @@
// ── What the chunk registers, checked without a browser ────────────────────
//
// `build.test.js` says the honest thing about this half: its real failures are
// timing and resolution, and a DOM-less runner cannot see either. That is still
// true, and MODULE_API.md §7.7's browser smoke is still what proves the module
// works. But it left a gap worth closing, and slice 3 is when it started to
// matter: nothing checked *what* the chunk registers.
//
// It can be checked, because registration is the one thing this chunk does at
// evaluation time and it does it through an object core hands it. So: stand up a
// fake `window.__rg` with a recording registry and the real React behind it,
// import the BUILT artifact, and read back what it asked for. No DOM is needed
// because nothing renders — `<Shard />` is `jsx(Shard)`, an object, and the
// route table is full of them by design.
//
// What this catches that review does not: a page that silently stops being
// routed, a nav row whose `to` drifts from its route's path, a slot fill that
// was renamed on one side, and the whole registration surface disappearing
// because an exception was thrown halfway down entry.jsx.
//
// What it deliberately does NOT do is re-assert the paths as a literal list.
// The interesting property is that the nav and the routes AGREE, and a test that
// restates both is a second copy of the thing it is checking.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import * as react from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import * as router from 'react-router-dom'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
// A component, as far as the registry cares. The kit's real members are core's;
// nothing here renders, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than
// imported — this suite runs against the BUILT chunk with no core in the process
// — which means it is a claim about core that has to be re-read when core's list
// changes. That is the same trade the rest of this fake makes.
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = new Map()
return {
version: '1.3.0',
react,
jsxRuntime,
router,
// `react-dom/client` is imported for the identity check in core.js and never
// called — createRoot in a DOM-less process would throw. The shim reads this
// object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
registry: {
registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
}
},
registerNav(id, { area, items }) {
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
},
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
registerExtension(id, slot, Component) {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
// The INVERTED direction (core API 1.6.0): this module declares a place on
// its OWN page and core fills it. Core enforces the namespace and the
// contribution name, so the fake does too — a chunk that declared an
// unnamespaced slot, or asked for a contribution core does not offer, would
// pass here and throw in a browser.
declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
const wants = options.core ?? null
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`)
}
declaredSlots.set(name, wants)
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
// Loaded once: an ES module is evaluated a single time per process however many
// times it is imported, so every test below reads the same registration pass —
// which is also how it behaves in a browser.
let registered = null
let skip = false
if (!fs.existsSync(CHUNK)) {
skip = true
} else {
const rg = fakeRg()
globalThis.window = { __rg: rg }
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
registered = rg._read()
}
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
it('registers routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
assert.equal(routes.public.length, 13)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
for (const r of routes[area]) {
assert.match(r.path, /^uo\//, `${area} route "${r.path}" is not under the module namespace`)
assert.ok(r.element, `${area} route "${r.path}" has no element`)
}
}
})
it('every route path is distinct within its area', () => {
// Two routes on one path is a page that can never be reached, and React
// renders the first without complaint.
for (const [area, list] of Object.entries(registered.routes)) {
const paths = list.map((r) => r.path)
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
}
})
it('every nav row points at a route this module actually registered', () => {
// The agreement that matters, and the one that rots quietly: a row survives a
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
// the FULL rendered path (`/uo/shard`), routes carry the namespaced one
// (`uo/shard`), and reconciling them is the whole test.
const rendered = {
public: (p) => `/${p}`,
admin: (p) => `/admin/${p}`,
player: (p) => `/player/${p}`,
}
for (const [area, rows] of Object.entries(registered.nav)) {
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
for (const row of rows) {
assert.ok(
reachable.has(row.to),
`${area} nav row "${row.label}" links to ${row.to}, which no route serves`,
)
}
}
})
it('every admin and player nav row carries an icon', () => {
// Both of those navs render a glyph on every core row, so a row without one
// reads as breakage rather than as a design. The PUBLIC header is text
// buttons and is deliberately excluded.
//
// The player half of this assertion is not symmetry for its own sake. Core's
// PlayerPortalLayout rendered `<n.icon />` UNGUARDED — fine for as long as
// every row in it was core's own and had one, and React error #130 with a
// blank portal the moment a module registered one without. Core is guarded
// now, but a missing icon there is still a visible defect and this is the
// cheap place to catch it.
for (const area of ['admin', 'player']) {
for (const row of registered.nav[area]) {
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
}
}
})
it('a nav row that gates on a feature is gated by a namespace this module provides', () => {
// Resolution is by the REGISTERING module (§3.3), so a `feature` on a row from
// a module that registered no provider resolves against nothing — and
// everything fails open, which would re-advertise surfaces an operator hid.
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
assert.ok(gated.length > 0)
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
it('fills the three CORE extension slots, each with a component', () => {
const { extensions } = registered
assert.deepEqual(
[...extensions.keys()].sort(),
['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
)
for (const [slot, { id, Component }] of extensions) {
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
assert.equal(typeof Component, 'function', `${slot} was not filled with a component`)
}
})
it('the manifest\'s declared server slot is one this module fills', () => {
// module.json declares SERVER slots and the loader validates them before the
// chunk is ever served. Client slots cannot be declared there — the server has
// no knowledge of them — so this is the one place the two halves are compared.
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
for (const slot of manifest.extensions || []) {
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
}
})
it('registers under exactly one module id, matching the manifest', () => {
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
const owners = new Set([
...Object.values(registered.routes).flat().map((r) => r.moduleId),
...Object.values(registered.nav).flat().map((r) => r.moduleId),
...[...registered.extensions.values()].map((e) => e.id),
...[...registered.providers.values()].map((p) => p.id),
])
assert.deepEqual([...owners], [manifest.id])
})
it('declares its own guild slots, each naming the core contribution it wants', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
//
// THREE slots rather than one because a slot holds one component: stacking the
// feed, the forum and the notification control into a single fill would take
// away this module's ability to place them separately on its own page — and it
// does place them separately, the control above the roster and the other two
// below it.
//
// The second argument is what actually gets core's content here. **Core offers
// a contribution and never names a slot** — the first cut of this reached only
// this module, because core filled the literal name `uo.guild.detail` and any
// other game's page went empty with no error.
assert.deepEqual([...registered.declaredSlots.entries()], [
['uo.guild.detail', 'team.activity'],
['uo.guild.forum', 'team.forum'],
['uo.guild.header', 'team.notify'],
])
})
it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
for (const name of registered.declaredSlots.keys()) {
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
}
})

View File

@@ -0,0 +1,74 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
// Unit-test the shared shard-event formatter — the single place that decides how
// each event kind reads and which filter category it belongs to. These strings
// are user-facing on the public Shard page, the Activity feed, and the admin
// live feed, so a regression here is visible everywhere at once.
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
test('describe reads fields from .payload when present, else the top level', () => {
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
assert.equal(describe(stored), 'Ada completed “The Cavern”')
assert.equal(describe(live), 'Ada completed “The Cavern”')
})
test('describe resolves an actor from name → acct → "Someone"', () => {
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
})
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
})
test('describe includes the killer only when present (optional clause)', () => {
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
assert.equal(
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
'Ada was slain by Orc',
)
})
test('describe champ.update branches on status and boss state', () => {
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
assert.equal(
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
'Rikktor is active — level 3',
)
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
})
test('describe falls back to the raw kind for an unknown event', () => {
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
})
// ── categoryOf(): membership + catch-all ────────────────────────────────
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
assert.equal(categoryOf('player.death'), 'pvp')
assert.equal(categoryOf('skill.gain'), 'progress')
assert.equal(categoryOf('house.decay'), 'world')
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
assert.equal(categoryOf('totally.unknown'), 'other')
})
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
for (const cat of CATEGORIES) {
if (!cat.kinds) continue
for (const kind of cat.kinds) {
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
}
}
})
// ── kindLabel(): badge text ─────────────────────────────────────────────
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
assert.equal(kindLabel('player.death'), 'player death')
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
assert.equal(kindLabel(null), '')
})

View File

@@ -1,8 +1,8 @@
{
"id": "uo",
"name": "Ultima Online",
"version": "0.2.0",
"coreApi": "^1.1.0",
"version": "0.3.0",
"coreApi": "^1.3.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",

370
routes.manifest.json Normal file
View File

@@ -0,0 +1,370 @@
{
"$comment": "Generated inventory of the URLs module-uo serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.",
"routes": [
{
"method": "DELETE",
"path": "/api/v1/admin/uo-link/towncrier/:id",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/shard/link/:account",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/accounts",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/atlas",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/audit",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/char/:serial",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/clilocs",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/houses",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/pages",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/roster/:account",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/sales",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/vendors/:account",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/visibility",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/config",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/signup-mode",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/stream",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/accounts",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/houses",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/online",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/sales",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/standing",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/shard/accounts",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/shard/char/:serial",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/shard/houses",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/shard/roster/:account",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/shard/sales",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/shard/vendors/:account",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/champions",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/creatures",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/creatures/:slug",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/landmarks",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/meta",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/regions",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/champs",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/economy",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/features",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/feed",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors/:city/history",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/guilds",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/guilds/:id",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/houses",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/idoc",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market/meta",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market/vendors/:serial",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/online",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/points",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/points/:system",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/presence",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/ruleset",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/status",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/stream",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/atlas/approve",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/atlas/import",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/atlas/reject",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/ban",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/broadcast",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/clilocs/import",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/kick",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/link",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/close",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/respond",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/unban",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/uo-link/towncrier",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/player/shard/account",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/player/shard/link",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/atlas/path",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/clilocs/path",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/visibility",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/uo-link/config",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/uo-link/signup-mode",
"tier": "public"
}
]
}

View File

@@ -0,0 +1,64 @@
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
//
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
// lcov report only populates Coverage, which is why the dashboard shows coverage
// while the Unit Tests tile stays "-". This reporter produces that missing report.
//
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
// <testExecutions version="1">
// <file path="server/test/foo.test.js">
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
// </file>
// </testExecutions>
//
// Paths are emitted repo-root-relative (POSIX separators) so they match the
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
// absolute `file` on each event strips cleanly against process.cwd().
import path from 'node:path'
function xmlEscape(s) {
return String(s).replace(/[<>&"']/g, (c) => ({
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&apos;',
})[c])
}
export default async function* sonarTestReporter(source) {
const byFile = new Map()
const cwd = process.cwd()
for await (const event of source) {
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
const d = event.data
// Skip the container events (a `describe` suite) and anything without a file
// — only real test cases go in the report, so the count matches the runner's.
if (!d.file || (d.details && d.details.type === 'suite')) continue
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
if (!byFile.has(rel)) byFile.set(rel, [])
byFile.get(rel).push({
name: d.name,
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
failed: event.type === 'test:fail',
skipped: Boolean(d.skip || d.todo),
})
}
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
for (const [file, cases] of byFile) {
yield ` <file path="${xmlEscape(file)}">\n`
for (const c of cases) {
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
else yield ` <testCase ${attrs}/>\n`
}
yield ' </file>\n'
}
yield '</testExecutions>\n'
}

View File

@@ -0,0 +1,201 @@
// ── `/guild` — the first chat command through the module contract ──────────
//
// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1).
// The definition and this handler live here; the bot pulls the definition over
// the app's internal API and runs nothing of ours. Nothing in this file knows
// what Discord is — it is handed an `actor` and returns an envelope, and the
// same handler would serve a second platform unchanged.
//
// **Why `/guild` and not `/team`.** Teams are core's primitive and "guild" is
// this module's word for one; core does not own the word, so it does not publish
// the noun in a channel either. That is the same correction that deleted core's
// Team pages in phase 3, applied to the chat surface.
//
// **The audience rungs are enforced here, exactly as they are on the website.**
// A shard whose `guilds` feature is gated to staff does not become public
// because the question arrived over Discord — this handler resolves the caller's
// rung through the same `shardVisibility` config the routes use. It is the one
// piece of this file that is a security boundary rather than presentation.
const core = require('../core')
const db = require('../model/teamProvider/teamProvider.db')
const provider = require('../model/teamProvider/teamProvider.model')
const visibility = require('../utils/shardVisibility')
const log = core.logger('guild-command')
// How many guilds the no-argument form lists. A Discord embed takes 25 fields;
// ten is a summary a person reads rather than a table they scroll past.
const LIST_LIMIT = 10
/**
* Where the caller sits on this module's ladder.
*
* The same resolution `projectRoster` does, and it is duplicated in shape rather
* than shared because the inputs differ: that one is handed a viewer core
* described, this one an actor. Both end at `viewerLevel`, and both answer
* `anonymous` DIRECTLY for a caller with no site account — handing `viewerLevel`
* a synthetic empty request makes it fall through to `auth.getUserFromRequest`,
* which expects real cookies and throws (the phase 3 bug).
*/
async function levelFor(actor) {
if (!actor || !actor.userId) return 'anonymous'
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
}
// The nudge §9 answer 5 asks for, and only when it is TRUE.
//
// **Linking reaches exactly two rungs and no further.** Signing in gets a caller
// to `logged_in` and linking a game account to `player`; `staff` and `admin` are
// roles an operator grants and no amount of linking will earn. So a shard that
// gates guilds to staff refuses an unlinked caller WITHOUT the invitation —
// telling them to link would be telling them to do something that changes
// nothing, which is worse than saying no.
//
// The live walk found this: gated to `staff`, the refusal still read "this shard
// shows guild information to linked players".
const LINKING_REACHES = new Set(['logged_in', 'player'])
function linkPrompt(actor, audience) {
if (actor.isLinked) return null
if (!LINKING_REACHES.has(audience)) return null
return 'Link your account on the site to see more — this shard shows guild information to linked players.'
}
const pageUrl = (externalId) =>
`${core.baseUrl}${provider.pageUrlTemplate.replace('{externalId}', externalId)}`
// Match on abbreviation first, then an exact name, then a unique prefix. Players
// type the abbreviation — it is what appears over a character's head — and a
// wrong-guild answer is worse than "say which one".
function findByName(rows, wanted) {
const needle = wanted.trim().toLowerCase()
const byAbbr = rows.filter((r) => (r.abbr || '').toLowerCase() === needle)
if (byAbbr.length === 1) return { guild: byAbbr[0] }
const exact = rows.filter((r) => r.name.toLowerCase() === needle)
if (exact.length === 1) return { guild: exact[0] }
const partial = rows.filter((r) => r.name.toLowerCase().includes(needle))
if (partial.length === 1) return { guild: partial[0] }
if (partial.length > 1) return { ambiguous: partial.slice(0, LIST_LIMIT) }
return {}
}
/** The counts for one guild, from the roster rather than the board's assertions. */
async function summarise(guild) {
const members = await db.listGuildMembers(guild.id)
const leaders = members
.filter((m) => Number(m.rank) >= db.LEADER_RANK)
.map((m) => m.name)
// The board's founder-leader is folded in as a floor, the same way
// getTeamLeaders does it: it arrives on a different frame, and a shard whose
// roster predates the rank amendment has no other leadership signal.
if (guild.leader_name && !leaders.includes(guild.leader_name)) leaders.push(guild.leader_name)
return {
// `members`/`online` are the BOARD's counts, which is what the shard asserts;
// the roster is what it enumerated, and the two legitimately disagree for the
// moment between a membership change and the sweep that reports it. The
// assertion is the more current of the two, so it is what is shown.
members: guild.members,
online: guild.online,
linked: members.filter((m) => provider.resolveUserId(m) !== null).length,
leaders,
}
}
async function detail(guild, actor, audience) {
const counts = await summarise(guild)
const fields = [
{ name: 'Members', value: String(counts.members ?? '—'), inline: true },
{ name: 'Online', value: String(counts.online ?? 0), inline: true },
{ name: 'Linked accounts', value: String(counts.linked), inline: true },
]
if (counts.leaders.length) {
fields.push({ name: 'Leaders', value: counts.leaders.join(', ') })
}
return {
title: guild.abbr ? `${guild.name} [${guild.abbr}]` : guild.name,
text: guild.alliance ? `Alliance: ${guild.alliance}` : undefined,
fields,
url: pageUrl(guild.id),
notice: linkPrompt(actor, audience),
}
}
/**
* `/guild [name]` — one guild's summary, or the shard's largest guilds.
*
* Never throws for an ordinary miss: "no such guild" and "the shard is offline"
* are answers, and letting either become an exception would turn a routine
* question into "that command failed" with nothing an operator could act on.
*/
async function handler({ options, actor }) {
const config = await visibility.getConfig()
const feature = config.guilds
// An admin turned guilds off. The switch means "this shard does not publish
// guild data" — over any surface, to anyone, staff included.
if (!feature || !feature.enabled) {
return { text: 'This shard does not publish guild information.', ephemeral: true }
}
const level = await levelFor(actor)
if (!visibility.meets(level, feature.audience)) {
return {
text: 'Guild information on this shard is not shown to your account.',
ephemeral: true,
notice: linkPrompt(actor, feature.audience),
}
}
// The provider's own staleness guard, asked before any board read: an
// unreachable sidecar means the board is a snapshot of unknown age, and
// reporting it as current here would contradict what every other surface says.
const ready = await provider.boardIsCurrent()
if (!ready.ok) {
log.info('guild command answered offline', { reason: ready.reason })
return { text: 'The shard is not connected right now, so guild information may be out of date.', ephemeral: true }
}
const rows = await db.listGuilds()
if (!rows.length) return { text: 'No guilds are on the board yet.', ephemeral: true }
const wanted = options && typeof options.name === 'string' ? options.name : null
if (!wanted) {
const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT)
return {
// Not "Guilds on <host>": `ctx.site` carries a base URL and no brand name,
// so naming the deployment here can only mean printing its hostname into
// an embed title, which is noise on a shard's own Discord server.
title: 'Guilds on this shard',
fields: top.map((g) => ({
name: g.abbr ? `${g.name} [${g.abbr}]` : g.name,
value: `${g.members || 0} members · ${g.online || 0} online`,
inline: true,
})),
notice: linkPrompt(actor, feature.audience),
}
}
const { guild, ambiguous } = findByName(rows, wanted)
if (ambiguous) {
return {
text: `Several guilds match “${wanted}”: ${ambiguous.map((g) => g.name).join(', ')}`,
ephemeral: true,
}
}
if (!guild) return { text: `No guild matches “${wanted}”.`, ephemeral: true }
return detail(guild, actor, feature.audience)
}
module.exports = {
name: 'guild',
description: 'Show a guild on this shard — members, who is online, and its leaders',
options: [
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
],
// Everyone, deliberately. The gate that matters is the shard's own audience
// rung, resolved inside the handler — `access: 'linked'` would hide the command
// from exactly the unlinked members §9 answer 5 wants to invite to link.
access: 'everyone',
handler,
}

View File

@@ -21,7 +21,11 @@
-- `notification_subs` rows for `shard.*` streams and `announce_job_legs` rows
-- with leg `towncrier` belong to core's tables, and a module does not delete
-- from those — core prunes them when it drops the registrations, which it can
-- do because it knows which registrant owned what.
-- do because it knows which registrant owned what. The two `settings` rows
-- schema.sql seeds (`game_account_signup`, `uo_link_protocol_3_migrated`) are
-- the same case with an extra reason: the second is a one-shot MIGRATION
-- marker, and deleting it would re-arm a protocol bump against tables this
-- file has just dropped.
DROP TABLE IF EXISTS `shard_atlas_pending`;
DROP TABLE IF EXISTS `shard_atlas_meta`;
@@ -41,6 +45,7 @@ DROP TABLE IF EXISTS `shard_ruleset`;
DROP TABLE IF EXISTS `shard_presence`;
DROP TABLE IF EXISTS `shard_governor_terms`;
DROP TABLE IF EXISTS `shard_governors`;
DROP TABLE IF EXISTS `shard_guild_members`;
DROP TABLE IF EXISTS `shard_guilds`;
DROP TABLE IF EXISTS `shard_pages`;
DROP TABLE IF EXISTS `shard_champs`;

View File

@@ -220,6 +220,52 @@ CREATE TABLE IF NOT EXISTS shard_guilds (
INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild membership (Protocol 4). One row per member per guild, replaced on
-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY
-- members a guild had, so this table has no pre-4 equivalent and the Guilds page
-- could show a count but never a roster.
--
-- `acct` / `web_id` are the site-identity fields and are stored because the
-- sidecar forwards them; they are NOT public. shardVisibility locks any key that
-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected
-- roster loses them below that rung — storing them here is what lets a linked
-- member be matched to a site user at all.
--
-- A roster over the shard's per-frame cap arrives in several frames, so rows are
-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first;
-- see upsertGuildRoster.
CREATE TABLE IF NOT EXISTS shard_guild_members (
guild_id INT NOT NULL,
serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5"
name VARCHAR(120) NULL,
acct VARCHAR(120) NULL, -- absent for a mobile with no account
web_id INT NULL, -- set only when the account is linked
is_player TINYINT(1) NOT NULL DEFAULT 1,
-- Guild rank, 0-4, with 4 being Leader (ServUO RankDefinition.Ranks). NULL means
-- "not known", which is a real state and not a demotion: the shard omits the rank
-- for a staff account, because PlayerMobile.GuildRank reports Leader for anyone at
-- GameMaster or above whatever their actual rank, and publishing that would put a
-- staff member on a public roster as a guild leader.
-- Backticked, like `int` on shard_online: RANK is a reserved word in MySQL 8 and
-- a non-reserved keyword in MariaDB, so it parses here bare but must not be
-- written that way anywhere it might not.
`rank` TINYINT NULL,
-- The rank's NAME, as the game states it: a cliloc id for the five standard ranks
-- (1062959-1062963, which ship with no text), or a literal string when a shard has
-- replaced the rank table with custom definitions. Resolving one to a label is this
-- module's job -- it owns the cliloc table and the game vocabulary.
rank_cliloc INT NULL,
rank_name VARCHAR(64) NULL,
t BIGINT NULL, -- roster event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (guild_id, serial),
INDEX idx_shard_guild_members_acct (acct),
INDEX idx_shard_guild_members_web (web_id),
-- Leadership is "rank >= 4", asked per guild, which is the query the Team provider
-- runs on every reconcile.
INDEX idx_shard_guild_members_rank (guild_id, rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
@@ -614,4 +660,41 @@ ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
-- uo_link_config row yet) it is simply written with nothing to update.
UPDATE uo_link_config SET protocol = 3
WHERE id = 1 AND protocol < 3
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
-- **The marker must be written HERE, not in core.** These two statements were
-- adjacent in core's schema.sql before the extraction; slice 1 moved the UPDATE
-- and left the INSERT behind, and the two files do not run at the same time —
-- core's schema is replayed in full BEFORE any module fragment (MODULE_API.md
-- §2.6). So the marker existed before the UPDATE ever read it, the NOT EXISTS
-- was true on the first boot of a fresh install and false on every boot of an
-- upgraded one, and the one-shot could never fire. An install carrying a
-- protocol-2 row would have stayed pinned at 2 against a v3 sidecar — every
-- REST call 409, which is precisely the failure this migration exists to
-- prevent. Latent rather than live: it only bites an install that first boots a
-- post-slice-1 build while already holding a uo_link_config row, and `edge` has
-- not cut over yet.
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
-- ── Settings rows this module owns ─────────────────────────────────────────
--
-- Both keys predate the module system and both name a game concept, so core
-- seeding them made core's schema declare a module's settings — the structural
-- half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1, slice 4). The KEYS are
-- deliberately unchanged: they are live rows on every existing install, and
-- renaming one would silently reset an operator's choice to the default.
--
-- INSERT IGNORE, so an install that already carries the row keeps its value and
-- only a database that has never seen the key gets the default. Nothing in core
-- reads either one; `game_account_signup` is read through ctx.settings by
-- server/utils/gameSignup.js, which owns the policy.
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
-- Protocol 4 guild rank, added to databases that already have shard_guild_members.
--
-- The table itself is new in Protocol 4 and unreleased, so no production install has
-- it — but `edge` deployments do, from the roster work that landed before the rank
-- amendment, and CREATE TABLE IF NOT EXISTS adds a table and never a column. This is
-- the same gap the sidecar's own store hit when `guilds.members` was added.
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);

View File

@@ -45,6 +45,8 @@ module.exports = function register(ctx, api) {
const shardStreams = require('./config/shardStreams')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -86,6 +88,25 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
// Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here.
//
// Registration is a claim, not a call — nothing below runs until core
// reconciles, which is after `onBoot`. That matters because every method reads
// the database, and registration must not.
api.registerTeamProvider(teamProvider)
// `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
// §7.1). The definition travels to the bot; the handler stays here and runs in
// the website process, because the bot container has no `modules` volume and
// cannot load a line of this module's code.
//
// Core registers NO commands of its own. "Guild" is this module's word — core
// does not own it on a page (phase 3) and does not publish it in a channel
// either.
api.registerSlashCommands([guildCommand])
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)

View File

@@ -171,6 +171,50 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
// `rank` is backticked wherever it is written, like `int` on shard_online: it is a
// reserved word in MySQL 8 and merely a keyword in MariaDB, so it parses bare here
// and must not be relied on to.
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, `rank`, rank_cliloc, rank_name, t'
// Upsert rather than plain insert: a roster frame can be redelivered (the /history
// backfill replays stored frames on every reconnect), and a redelivery must be a
// no-op rather than a duplicate-key error.
//
// The rank columns are assigned unconditionally, NULL included. A member whose rank
// the shard withheld — a staff account, whose GuildRank getter reports Leader
// regardless of the truth — must go back to "not known" rather than keeping a rank
// from before they were promoted.
const upsertGuildMembers = (rows) => {
if (!rows.length) return Promise.resolve()
const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')
const params = rows.flatMap((r) => [
r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player,
r.rank, r.rank_cliloc, r.rank_name, r.t,
])
return query(
`INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
web_id = VALUES(web_id), is_player = VALUES(is_player),
\`rank\` = VALUES(\`rank\`), rank_cliloc = VALUES(rank_cliloc),
rank_name = VALUES(rank_name), t = VALUES(t)`,
params,
)
}
const clearGuildMembers = (guildId) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId])
const removeGuildMember = (guildId, serial) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial])
const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members')
const listGuildMembers = (guildId) =>
query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [
guildId,
])
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -342,6 +386,11 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
upsertGuildMembers,
clearGuildMembers,
removeGuildMember,
clearAllGuildMembers,
listGuildMembers,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,

View File

@@ -340,8 +340,88 @@ async function upsertGuild(ev) {
})
}
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
const clearGuilds = () => db.clearGuilds()
const removeGuild = async (id) => {
if (id == null) return
await db.removeGuild(id)
await db.clearGuildMembers(id)
}
const clearGuilds = async () => {
await db.clearGuilds()
await db.clearAllGuildMembers()
}
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
// Apply one guild.roster frame.
//
// A roster larger than the shard's per-frame cap arrives as several frames
// carrying seq/more/total. The sidecar reassembles them for its OWN board, but the
// live WebSocket feed and the /history backfill both carry the individual frames,
// so this ingest sees them unreassembled and has to cope.
//
// It copes without buffering, because a table can express what a single JSON column
// could not: the frame carrying seq 0 clears the guild first and every frame then
// upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame
// — the /history backfill replays stored frames on every reconnect — is idempotent
// rather than a duplicate-key error.
//
// The cost is a brief window during a multi-frame update where the table holds part
// of a roster. That is acceptable for a projection that is already only as fresh as
// a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close
// it would duplicate the sidecar's reassembly for a sub-second inconsistency.
async function upsertGuildRoster(ev) {
if (!ev || ev.id == null) return
const seq = Number.isFinite(ev.seq) ? ev.seq : 0
const members = Array.isArray(ev.members) ? ev.members : []
// seq 0 begins a roster and supersedes whatever was held for this guild.
if (seq === 0) await db.clearGuildMembers(ev.id)
const rows = members
.filter((m) => m && m.serial)
.map((m) => ({
guild_id: ev.id,
serial: m.serial,
name: m.name ?? null,
acct: m.acct ?? null,
web_id: Number.isFinite(m.webId) ? m.webId : null,
is_player: m.player ? 1 : 0,
// Guild rank (Protocol 4). ABSENT is a real state and is stored as NULL: the
// shard withholds the rank for a staff account, because ServUO's GuildRank
// getter reports Leader for anyone at GameMaster or above whatever their
// actual rank. Defaulting a missing rank to 0 here would turn "we were not
// told" into "rank 0", which is a demotion invented by this line.
rank: Number.isInteger(m.rank) ? m.rank : null,
rank_cliloc: Number.isInteger(m.rankCliloc) ? m.rankCliloc : null,
rank_name: typeof m.rankName === 'string' && m.rankName ? m.rankName.slice(0, 64) : null,
t: Number.isFinite(ev.t) ? ev.t : null,
}))
await db.upsertGuildMembers(rows)
}
// A single departure (guild.leave). Advisory: the shard re-emits the full roster
// whenever the member set changes, so the table would converge on the next frame
// even if this were dropped. Applying it makes the change visible immediately
// instead of at the end of the sweep that produced it.
async function removeGuildMember(ev) {
if (!ev || ev.id == null || !ev.who) return
await db.removeGuildMember(ev.id, ev.who)
}
// The membership roster for one guild, in the wire shape the projection expects
// (an array of actor objects), so shardVisibility strips acct/webId by the same
// rule it applies to guild.leader.
async function listGuildMembers(guildId) {
const rows = await db.listGuildMembers(guildId)
return rows.map((r) => ({
serial: r.serial,
name: r.name,
...(r.acct == null ? {} : { acct: r.acct }),
...(r.web_id == null ? {} : { webId: r.web_id }),
player: !!r.is_player,
}))
}
function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
@@ -620,6 +700,9 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
upsertGuildRoster,
removeGuildMember,
listGuildMembers,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,

View File

@@ -0,0 +1,87 @@
// SQL behind the Team provider — three questions core asks, answered from the
// guild board and the roster Protocol 4 put there.
//
// Every statement reads only THIS module's tables. Core's Team tables are
// core-internal (docs/website/TEAMS.md §10.3) and this module must never name
// one, even though it is what fills them.
// `query` is destructured from the core facade at require time, like every other
// *.db.js here. The facade resolves `ctx` per call, so taking it now is safe even
// though `ctx` does not exist yet when this file is first required.
const { query } = require('../../core')
/** ServUO's `RankDefinition.Ranks[4]` is Leader, and 4 is the top of the ladder. */
const LEADER_RANK = 4
/**
* The guild board — one row per guild the shard has told us about.
*
* `members`/`online` here are the COUNTS `guild.update` carries; the roster is a
* separate table (Protocol 4). Both are read, because a count is what the shard
* asserts and a roster is what it enumerated, and they can legitimately disagree
* for the moment between a membership change and the sweep that reports it.
*/
const listGuilds = () =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds ORDER BY name ASC`,
)
const findGuild = (id) =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds WHERE id = ? LIMIT 1`,
[id],
)
/**
* One guild's roster, with the site link and live presence folded in.
*
* Two LEFT JOINs, both deliberate:
*
* - `shard_account_links` resolves `user_id` HERE rather than in core, because
* this module owns that table and a core that read it would be core naming a
* module's table by name (§2.3). It is also why a freshly linked account
* appears as linked on the next reconcile rather than needing core to know
* anything about linking.
* - `shard_online` is how a member's `online` is answered at all. The roster
* frame does not carry it — the wire's member is the standard actor object
* (`serial`, `name`, `player`, `acct?`, `webId?`), and the board's `online` is
* a count, not a set. Presence therefore comes from the online table, which
* is the same source the public "who's online" surface already uses.
*
* `web_id` on the roster row is preferred over the link table when present: it is
* what the shard itself asserted at roster time, and the join is the fallback for
* a member whose row predates their link.
*/
const listGuildMembers = (guildId) =>
query(
"SELECT m.serial, m.name, m.acct, m.web_id, m.is_player, m.`rank`, m.rank_cliloc, m.rank_name, " +
` l.user_id AS linked_user_id,
(o.serial IS NOT NULL) AS is_online
FROM shard_guild_members m
LEFT JOIN shard_account_links l ON l.account = m.acct
LEFT JOIN shard_online o ON o.serial = m.serial
WHERE m.guild_id = ?
ORDER BY m.name ASC`,
[guildId],
)
/**
* Every member at leader rank — rank 4, the top of ServUO's `RankDefinition.Ranks`.
*
* A set, not a single row, and that is the whole reason Protocol 4 grew a per-member
* rank: the guild board carries one `leader_serial`, so before this the website could
* only ever be told about one leader, while a UO guild routinely has several.
*
* A NULL rank is excluded by the comparison, which is correct — the shard withholds
* the rank for a staff account rather than publishing the Leader its getter falsely
* reports, and "not known" must not be read as "leads this guild".
*/
const listGuildLeaders = (guildId) =>
query(
'SELECT serial FROM shard_guild_members WHERE guild_id = ? AND `rank` >= ? ORDER BY name ASC',
[guildId, LEADER_RANK],
)
module.exports = { listGuilds, findGuild, listGuildMembers, listGuildLeaders, LEADER_RANK }

View File

@@ -0,0 +1,339 @@
// ── module-uo's Team provider ──────────────────────────────────────────────
//
// The three questions core asks this module about Teams
// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3).
// A UO guild is a Team; this file is the whole of the translation.
//
// **Every method returns an envelope, and answering `{ ok: false }` is a normal
// outcome, not a failure to handle.** Core's contract is that module
// unavailability becomes staleness and never emptiness, and the only way this
// module can say "I cannot answer" is to say so — an empty array would be read as
// an authoritative "there are none", which during a cold start is how every
// roster on the site gets emptied. So the guard below is the most important code
// in the file, and it is deliberately conservative: **an unreachable or
// never-connected sidecar refuses, rather than reporting the board it happens to
// still hold.**
//
// The board IS durable and would survive a sidecar outage, which is exactly what
// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell
// a board that is five minutes stale from one that is five days stale, and it
// makes destructive decisions — archiving Teams, departing members — from a
// complete answer. Reporting a stale board as authoritative would license those.
const core = require('../../core')
const db = require('./teamProvider.db')
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../../utils/uoLinkSocket')
const clilocs = require('../shardClilocs/shardClilocs.model')
const visibility = require('../../utils/shardVisibility')
const log = core.logger('teams')
/**
* ServUO's five stock rank names, by the cliloc id the game names them with.
*
* A fallback, not the source of truth: the operator's own cliloc table is consulted
* first, and a shard with custom rank definitions sends a literal string that beats
* both. This exists because the cliloc table is populated only if someone ran the
* client-file extraction, and a roster on a shard that has not should still say
* "Warlord" rather than nothing.
*/
const STANDARD_RANK_NAMES = {
1062959: 'Leader',
1062960: 'Warlord',
1062961: 'Emissary',
1062962: 'Member',
1062963: 'Ronin',
}
/** A refusal, in the shape core reads (§2.3). */
const refuse = (reason) => ({ ok: false, reason })
/**
* Is the bridge in a state where the board can be trusted as current?
*
* The board is only as good as the socket that fills it. Three states refuse, and
* they are asked in this order because each is a different thing being wrong:
*
* - **no uo-link configured** — there is no shard behind this website at all;
* - **the integration is disabled** — an admin turned it off, and the board is
* frozen at whatever it held;
* - **the socket is not connected** — the board is a snapshot of unknown age.
*
* The in-process socket state is preferred over the persisted status column,
* which is written on transitions: a process that has just started has not
* transitioned yet, so the column can still say `connected` from the last run
* while this process has never opened a socket.
*/
async function boardIsCurrent() {
const config = await uoLinkConfig.getSafe()
if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' }
if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' }
const state = uoLinkSocket.getState()
if (!state || !state.connected) {
return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' }
}
return { ok: true }
}
/**
* `getTeams()` — every guild on the board.
*
* `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a
* guild in-game keeps the id, so core sees "an id whose name changed" and applies
* its rename rule (archive plus create). That mapping is this module's to make —
* only the game knows what identity survives what (§10.5).
*
* `meta` carries the alliance, opaquely. Core stores and displays it and never
* branches on it, which is what lets a UO concept reach a Team page without core
* acquiring an opinion about alliances.
*/
async function getTeams() {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const rows = await db.listGuilds()
return {
ok: true,
complete: true,
teams: rows.map((row) => ({
externalId: String(row.id),
name: row.name,
abbr: row.abbr || null,
meta: row.alliance ? { alliance: row.alliance } : null,
})),
}
} catch (err) {
log.warn('getTeams failed', { message: err.message })
return refuse(`guild board unreadable: ${err.message}`)
}
}
/**
* `getTeamMembers(externalId)` — one guild's roster.
*
* **A guild with no roster rows is refused, not reported empty**, unless the board
* itself says the guild has no members. Protocol 4's roster arrives on its own
* frames, separately from the `guild.update` that creates the board row, so there
* is a real window — a fresh guild, or a website that connected between the two —
* where core would otherwise be told authoritatively that a 155-member guild has
* nobody in it. The board's own `members` count is what distinguishes the two,
* and it is the only thing that can.
*/
async function getTeamMembers(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildMembers(externalId)
if (!rows.length && guild.members > 0) {
return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
}
const labels = await rankLabels(rows)
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.serial,
displayName: row.name || null,
rankLabel: labels.get(row.serial) || null,
// Rank 4 is Leader, and several members can hold it. A NULL rank is not a
// leader: the shard withholds the rank for a staff account rather than
// publishing the Leader its getter falsely reports, and "not known" must
// never be read as "leads this guild".
leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK,
online: Boolean(row.is_online),
userId: resolveUserId(row),
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, message: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/**
* `getTeamLeaders(externalId)` — everyone at leader rank.
*
* **All of them, which is why Protocol 4 grew a per-member rank.** The guild board
* carries one `leader_serial`, so before the rank amendment this could only ever
* name a single member, while a UO guild routinely has several at rank 4 and
* TEAMS.md §2.5 treats multiple leaders as the normal case.
*
* The board's own `leader_serial` is folded in as a floor. It is the guild's
* founder-leader and it comes from a different frame (`guild.update`), so on a
* shard whose roster has not been re-emitted since the amendment it is the only
* leadership signal there is — and it should never be *lost* by moving to ranks.
*/
async function getTeamLeaders(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildLeaders(externalId)
const leaders = rows.map((r) => r.serial)
if (guild.leader_serial && !leaders.includes(guild.leader_serial)) {
leaders.push(guild.leader_serial)
}
return { ok: true, leaders }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, message: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* Resolve each member's rank to a display label, keyed by serial.
*
* The shard sends the rank's NAME as the game states it — a cliloc id for the five
* standard ranks, or a literal string for a custom rank definition — and never a
* resolved label, because ServUO ships no text for those clilocs. This module does
* have a cliloc table, which is why the resolution belongs here.
*
* Three sources, in order: a custom string wins, then the operator's cliloc table,
* then the five standard names. The last exists because the cliloc table is
* populated only if someone ran the client extraction, and a shard that has not
* should still read "Warlord" rather than nothing.
*
* Never throws: a rank label is decoration on a roster, and a lookup failure must
* not turn a good roster into a refusal.
*/
async function rankLabels(rows) {
const out = new Map()
const wanted = []
for (const row of rows) {
if (row.rank_name) {
out.set(row.serial, row.rank_name)
} else if (Number.isInteger(row.rank_cliloc)) {
wanted.push(row.rank_cliloc)
}
}
let resolved = new Map()
if (wanted.length) {
try {
resolved = await clilocs.resolveMany(wanted)
} catch (err) {
log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message })
}
}
for (const row of rows) {
if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue
const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null
if (label) out.set(row.serial, label)
}
return out
}
/**
* The site account behind a character, or null.
*
* `web_id` is what the shard itself asserted when it emitted the roster; the
* account-link join is the fallback for a member whose roster row predates their
* link. Both are coerced through the same check, because `web_id` arrives from
* the wire as a string.
*/
function resolveUserId(row) {
const fromRoster = Number.parseInt(row.web_id, 10)
if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster
const fromLink = Number.parseInt(row.linked_user_id, 10)
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
}
/**
* Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0).
*
* The optional fourth provider method, and the only one core calls on a REQUEST
* path rather than from the reconciler. Core holds the roster and its public
* shape; the question that is this module's is "who is allowed to look", because
* the audience rungs and their configuration live here (`utils/shardVisibility`)
* and core does not know what a rung is.
*
* **The answer is all-or-nothing, and that is correct rather than a shortcut.**
* A rung is a property of the FEATURE, not of a member: `guilds` is either
* visible to this viewer or it is not, and there is no configuration in which
* some members of a guild are public and others are not. Returning every key or
* none is the honest translation of the model this module actually has.
*
* **A refusal here costs visibility, not staleness.** Core fails closed on this
* one call — an unanswered visibility question serves an empty roster rather than
* an unprojected one — so every path below that cannot reach a confident answer
* refuses deliberately, and the catch does too. That is the opposite of the rule
* governing the other three methods, and it is the right way round: for a roster
* SYNC an unanswered call must change nothing, and for a roster READ it must
* publish nothing.
*
* Note what this does NOT do: strip fields. `acct` and `webId` are the leak this
* module's projection exists to prevent on the live feed, and neither is in
* core's roster shape at all — core withholds the member key and the site account
* id from every public roster whatever this returns. So there is nothing here to
* redact, only rows to withhold.
*/
async function projectRoster(externalId, members, viewer) {
try {
const config = await visibility.getConfig()
const feature = config.guilds
// An admin turned guilds off. Nobody sees a roster, including staff — the
// switch means "this shard does not publish guild data", not "publish it
// quietly".
if (!feature || !feature.enabled) return { ok: true, members: [] }
// `viewerLevel` reads a REQUEST; core hands over a described viewer instead,
// which is deliberate — it keeps the `users` row out of the contract.
//
// The no-viewer case is answered here rather than by handing `viewerLevel` an
// empty object: given a request with no `req.user` it falls through to
// `auth.getUserFromRequest`, which expects real cookies and headers and
// throws on a synthetic one. That throw would land in the catch below and
// become a REFUSAL, so every anonymous visitor would have been served an
// empty roster on a shard whose guilds are public. Anonymous is a known
// answer, not a failed lookup.
const level = viewer
? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } })
: 'anonymous'
if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] }
return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) }
} catch (err) {
// Core reads this as "withhold the roster". Saying so is the whole point: the
// alternative — answering with every key because the config read failed —
// publishes a roster an operator may have gated to staff.
log.warn('projectRoster could not resolve visibility; withholding the roster', {
externalId, message: err.message,
})
return refuse(`visibility could not be resolved: ${err.message}`)
}
}
// Where core should point a link at a guild (MODULE_API 1.6.0, TEAMS.md §6.4).
//
// **Core cannot work this out for itself, and it is not supposed to.** Teams are
// a contract primitive with no core surface — this module owns the guild page,
// because core does not own the word "guild" — so the one thing core needs back
// is where the page it does not own actually lives. A notification email that
// cannot link to the thread it is about is most of the way to useless.
//
// A relative path with `{externalId}` substituted, matching `Guild.jsx`'s route
// (`/uo/guilds/:id`). Core does the substitution and nothing else with it; a
// template naming its own host is refused at registration, which is why this is
// data and not a callback.
const pageUrlTemplate = '/uo/guilds/{externalId}'
// `resolveUserId` is exported for the `/guild` chat command, which counts linked
// members and must decide "linked" by the same rule the roster does — a second
// copy of that two-source check is a copy that drifts.
module.exports = {
getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate, resolveUserId,
}

158
server/package-lock.json generated
View File

@@ -13,7 +13,8 @@
},
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.2.0"
"express-validator": "^7.2.0",
"swagger-autogen": "^2.23.7"
},
"engines": {
"node": ">=20"
@@ -33,6 +34,19 @@
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -40,6 +54,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@ -65,6 +86,17 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -106,6 +138,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -156,6 +195,16 @@
"ms": "2.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -359,6 +408,13 @@
"node": ">= 0.6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -408,6 +464,28 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -481,6 +559,18 @@
"node": ">=0.10.0"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -498,6 +588,19 @@
"node": ">= 0.10"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -581,6 +684,19 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -624,6 +740,16 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -634,6 +760,16 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -867,6 +1003,19 @@
"node": ">= 0.8"
}
},
"node_modules/swagger-autogen": {
"version": "2.23.7",
"resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz",
"integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^7.4.1",
"deepmerge": "^4.2.2",
"glob": "^7.1.7",
"json5": "^2.2.3"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -931,6 +1080,13 @@
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",

View File

@@ -7,7 +7,10 @@
"main": "index.js",
"scripts": {
"test": "node --test --require ./test/_setup.js",
"check:imports": "node scripts/checkImports.js"
"check:imports": "node scripts/checkImports.js",
"check:bundle": "node scripts/checkBundle.js",
"swagger": "node scripts/swaggerFragment.js",
"check:swagger": "node scripts/swaggerFragment.js --check"
},
"engines": {
"node": ">=20"
@@ -15,10 +18,11 @@
"//dependencies": "The ONE runtime dependency, and it ships inside the release tarball: CI runs npm ci --omit=dev and packs server/node_modules, because an operator never builds (MODULE_SYSTEM.md 1.14). Node resolves it by walking up from modules/uo/server/. Everything else the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger, the middleware and the rate-limit factory are all core-owned and handed over.",
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.2.0"
"express-validator": "^7.2.0",
"swagger-autogen": "^2.23.7"
},
"dependencies": {
"ws": "^8.21.0"
},
"//devDependencies": "Test-only. test/_fakes.js builds a REAL express router - a fake Router would test the fake."
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express router - a fake Router would test the fake. swagger-autogen is the same generator core uses, pinned to the same major so the fragment and the spec it merges into come out of one tool (MODULE_API.md 2.8)."
}

View File

@@ -47,8 +47,8 @@ shardRouter.post(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 4, max: 32 }),
validate,
@@ -59,7 +59,7 @@ shardRouter.get(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'List the caller’s linked game accounts (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
selfShard.listAccounts,
)
shardRouter.get(
@@ -103,7 +103,7 @@ shardRouter.get(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
selfShard.getSales,
)
shardRouter.post(
@@ -112,7 +112,7 @@ shardRouter.post(
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -223,7 +223,7 @@ shardRouter.get(
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
modAccess,
shardOps.listAudit,
)
@@ -233,7 +233,7 @@ shardRouter.get(
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
modAccess,
shardOps.listHouses,
)
@@ -253,7 +253,7 @@ shardRouter.get(
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardAtlas.getStatus,
@@ -265,7 +265,7 @@ shardRouter.post(
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
adminOnly,
body('force').optional().isBoolean(),
validate,
@@ -277,7 +277,7 @@ shardRouter.post(
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
adminOnly,
shardAtlas.approve,
)
@@ -287,7 +287,7 @@ shardRouter.post(
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardAtlas.reject,
@@ -299,7 +299,7 @@ shardRouter.put(
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
adminOnly,
body('path').isString().isLength({ max: 512 }),
validate,
@@ -322,7 +322,7 @@ shardRouter.get(
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardClilocs.getStatus,
@@ -331,10 +331,10 @@ shardRouter.post(
'/clilocs/import',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
// #swagger.description = 'Applies a client patch, or a change to the shard’s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client’s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocRefreshResult" } } } } */
adminOnly,
body('force').optional().isBoolean(),
body('approve').optional().isBoolean(),
@@ -348,7 +348,7 @@ shardRouter.put(
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */
adminOnly,
body('path').isString().isLength({ max: 512 }),
validate,
@@ -364,7 +364,7 @@ shardRouter.get(
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardVisibility.getVisibility,
@@ -375,8 +375,8 @@ shardRouter.put(
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('features').isObject(),

View File

@@ -10,6 +10,7 @@ const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkClient = require('../../utils/uoLinkClient')
const uoLinkSocket = require('../../utils/uoLinkSocket')
const shardBroadcast = require('../../utils/shardBroadcast')
const gameSignup = require('../../utils/gameSignup')
const { activity } = require('../../core')
const log = require('../../core').logger('admin-uolink')
@@ -75,6 +76,34 @@ async function saveConfig(req, res) {
}
}
// GET /admin/uo-link/signup-mode — whether this site creates game accounts.
//
// Core's Site Settings carried this field until slice 3, with help text naming
// Bridge.cfg. It reads as UO policy because it is: the site's mode and the
// shard's own SignupMode have to agree, and only one of those two is core's.
async function getSignupMode(req, res) {
try {
return res.json({ mode: await gameSignup.getMode(), modes: gameSignup.MODES })
} catch (err) {
log.error('uoLink.getSignupMode', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /admin/uo-link/signup-mode
async function saveSignupMode(req, res) {
const { mode } = req.body
try {
await gameSignup.setMode(mode, req.user.id)
await activity.log({ req, action: 'uoLink.signupMode.update', detail: { mode } })
log.info('game-signup mode updated', { by: req.user.username, mode })
return res.json({ mode })
} catch (err) {
log.error('uoLink.saveSignupMode', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/uo-link/towncrier — publish/replace a town-crier message.
async function postTownCrier(req, res) {
const { id, lines, durationSec } = req.body
@@ -120,4 +149,4 @@ function stream(req, res) {
shardBroadcast.subscribe(req, res, 'admin')
}
module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream }
module.exports = { getConfig, saveConfig, getSignupMode, saveSignupMode, postTownCrier, deleteTownCrier, stream }

View File

@@ -24,6 +24,7 @@ const express = core.express
const { body, param } = core.validator
const uoLink = require('./uoLink.controller')
const gameSignup = require('../../utils/gameSignup')
const { requireRole, validate } = core.middleware
const uoLinkRouter = express.Router()
@@ -58,12 +59,48 @@ uoLinkRouter.put(
validate,
uoLink.saveConfig,
)
// ── Game-account signup mode ───────────────────────────────────────────────
//
// New in slice 3, and new only in the sense that the field moved: core's Site
// Settings has carried `game_account_signup` since long before the extraction,
// and its help text has always been about a game server. The setting key and its
// stored value are unchanged, so an existing instance keeps its configured mode.
uoLinkRouter.get(
'/signup-mode',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Get the game-account signup mode (admin only)'
// #swagger.description = 'Whether the site offers game-account creation, and in which direction. The shard’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The configured mode and the legal values', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" }, modes: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
uoLink.getSignupMode,
)
uoLinkRouter.put(
'/signup-mode',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Set the game-account signup mode (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["mode"], properties: { mode: { type: "string", enum: ["disabled","website","hybrid","game"] } } } } } } */
/* #swagger.responses[200] = { description: 'The saved mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown mode', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
// Validated here as well as in gameSignup.setMode: the list is the same list,
// and the difference is the answer. A rejected value must be a 400 naming the
// field, not a 500 from a thrown Error the controller could only guess about.
body('mode').isIn(gameSignup.MODES),
validate,
uoLink.saveSignupMode,
)
uoLinkRouter.post(
'/towncrier',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoTownCrierRequest" } } } } */
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -39,7 +39,7 @@ shardRouter.get(
// #swagger.summary = 'A user’s linked game accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
@@ -51,7 +51,7 @@ shardRouter.get(
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,

View File

@@ -11,7 +11,8 @@ const uoLinkClient = require('../../utils/uoLinkClient')
const shardLinks = require('../../model/shardLinks/shardLinks.model')
const shardState = require('../../model/shardState/shardState.model')
const shardClilocs = require('../../model/shardClilocs/shardClilocs.model')
const { settings, activity } = require('../../core')
const { activity } = require('../../core')
const gameSignup = require('../../utils/gameSignup')
const { salesForAccounts } = require('../../utils/shardSales')
const log = require('../../core').logger('player-shard')
@@ -246,7 +247,7 @@ function mapCreateAccountError(res, result) {
async function createGameAccount(req, res) {
const { account, password } = req.body
try {
if (!(await settings.isGameAccountSignupEnabled())) {
if (!(await gameSignup.isEnabled())) {
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
}
const result = await uoLinkClient.createAccount({

View File

@@ -30,8 +30,8 @@ shardRouter.post(
// #swagger.summary = 'Link an in-game account with a one-time code'
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 4, max: 32 }),
@@ -44,7 +44,7 @@ shardRouter.post(
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -62,7 +62,7 @@ shardRouter.get(
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'List the caller’s linked game accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
shard.listAccounts,
)
shardRouter.get(
@@ -109,7 +109,7 @@ shardRouter.get(
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
shard.getSales,
)
shardRouter.get(
@@ -118,7 +118,7 @@ shardRouter.get(
// #swagger.summary = 'The caller’s own houses (home status)'
// #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
shard.getHouses,
)

View File

@@ -38,12 +38,12 @@ atlasRouter.get(
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'Search the bestiary (paginated)'
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.'
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature’s share on it. Static content parsed from the shard’s ServUO tree — unaffected by the shard being offline.'
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' }
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard’s own files; an unknown one returns an empty page.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasCreaturePage" } } } } */
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
@@ -59,11 +59,11 @@ atlasRouter.get(
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard’s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasCreature" } } } } */
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('slug').isString().isLength({ min: 1, max: 120 }),
facetParam,
@@ -77,10 +77,10 @@ atlasRouter.get(
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'Named regions and their rectangles'
// #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
// #swagger.description = 'Flattened out of the shard’s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasRegion" } } } } } */
facetParam,
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
validate,
@@ -92,10 +92,10 @@ atlasRouter.get(
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
// #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
// #swagger.description = 'From the shard’s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasLandmark" } } } } } */
facetParam,
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
validate,
@@ -109,7 +109,7 @@ atlasRouter.get(
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasChampion" } } } } } */
facetParam,
validate,
siteMode,
@@ -120,8 +120,8 @@ atlasRouter.get(
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
// #swagger.description = 'Drives the facet filter and the "parsed from the shard’s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasMeta" } } } } */
siteMode,
atlas.getMeta,
)

View File

@@ -15,6 +15,7 @@ const shardMarket = require('../../model/shardMarket/shardMarket.model')
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
const broadcast = require('../../utils/shardBroadcast')
const visibility = require('../../utils/shardVisibility')
const gameSignup = require('../../utils/gameSignup')
const log = require('../../core').logger('public-shard')
@@ -176,6 +177,31 @@ async function getGuilds(req, res) {
}
}
// GET /public/shard/guilds/:id — one guild and its roster.
//
// The board endpoint above returns every guild WITHOUT its roster; this is the
// detail view, and it is the page that hosts core's Team activity feed through
// the `uo.guild.detail` slot (docs/website/TEAMS.md Part 3).
//
// Projected through the same `guilds` feature as the board, so an operator who
// gates guilds to staff gates this too, and `acct`/`webId` on the roster rows
// never survive below admin — those are LOCKED fields, and a roster is where they
// actually appear in bulk.
async function getGuild(req, res) {
try {
const guilds = await shardState.listGuilds()
const guild = guilds.find((g) => String(g.id) === String(req.params.id))
// 404 rather than an empty object: a guild that disbanded is gone, and the
// page needs to say so rather than render an empty shell.
if (!guild) return res.status(404).json({ message: 'Not Found' })
const members = await shardState.listGuildMembers(guild.id)
return res.json(await visibility.project('guilds', { ...guild, roster: members }, req))
} catch (err) {
log.error('shard.getGuild', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream. Projected
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
@@ -386,7 +412,20 @@ async function getFeatures(req, res) {
try {
const config = await visibility.getConfig()
const level = await visibility.viewerLevel(req)
return res.json({ level, features: visibility.visibleFeatures(level, config) })
return res.json({
level,
features: visibility.visibleFeatures(level, config),
// Whether this site offers game-account creation. Not a visibility flag
// and deliberately carried here anyway: it is the same per-viewer,
// once-a-session answer, and the alternative is a second endpoint and a
// second round-trip for one boolean. It is NOT audience-gated — it says
// what the site offers, not what this caller may see, and the portal's
// create-account form is behind a session either way.
//
// Core's public settings carried this until slice 3. It is ours now
// (utils/gameSignup.js), because the setting is about a game server.
gameAccountSignup: await gameSignup.isEnabled(),
})
} catch (err) {
log.error('shard.getFeatures', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -407,6 +446,7 @@ module.exports = {
getIdoc,
getChamps,
getGuilds,
getGuild,
getGovernors,
getGovernorHistory,
getPresence,

View File

@@ -37,7 +37,7 @@ shardRouter.get(
requireFeature('status'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard connection state, online count and latest economy'
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardStatus" } } } } */
shard.getStatus,
)
shardRouter.get(
@@ -45,10 +45,10 @@ shardRouter.get(
requireFeature('activity'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
// #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.'
// #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller’s audience rung under the live visibility config, and each event’s payload is field-projected against its own kind’s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.'
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
query('limit').optional().isInt({ min: 1, max: 1000 }),
validate,
@@ -60,7 +60,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEconomyPoint" } } } } } */
query('limit').optional().isInt({ min: 1, max: 1000 }),
validate,
shard.getEconomy,
@@ -70,7 +70,7 @@ shardRouter.get(
requireFeature('presence'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardOnlinePlayer" } } } } } */
shard.getOnline,
)
shardRouter.get(
@@ -78,8 +78,8 @@ shardRouter.get(
requireFeature('houses'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Houses currently in danger (IDOC)'
// #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.'
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
// #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature’s field rules (default `staff`), and the owner’s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.'
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
shard.getIdoc,
)
shardRouter.get(
@@ -100,6 +100,17 @@ shardRouter.get(
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGuilds,
)
shardRouter.get(
'/guilds/:id',
requireFeature('guilds'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'One guild and its roster'
// #swagger.description = 'The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The guild id.' }
/* #swagger.responses[200] = { description: 'The guild, with its roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'No such guild', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
shard.getGuild,
)
shardRouter.get(
'/governors',
requireFeature('governors'),
@@ -137,14 +148,14 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
shard.getHouses,
)
shardRouter.get(
'/ruleset',
requireFeature('ruleset'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'The shard\'s published ruleset (expansion, systems, caps, limits)'
// #swagger.summary = 'The shard’s published ruleset (expansion, systems, caps, limits)'
// #swagger.description = 'How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.'
/* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
shard.getRuleset,
@@ -154,18 +165,18 @@ shardRouter.get(
requireFeature('leaderboards'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen’s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board’s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardPointsBoard" } } } } } */
shard.getPointsBoards,
)
shardRouter.get(
'/points/:system',
requireFeature('leaderboards'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'One points system\'s leaderboard'
// #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
// #swagger.summary = 'One points system’s leaderboard'
// #swagger.description = 'A single board by the shard’s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
/* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardPointsBoard" } } } } */
/* #swagger.responses[400] = { description: 'Malformed system name' } */
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
shard.getPointsBoard,
@@ -181,17 +192,17 @@ shardRouter.get(
marketLimiter,
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Search the player-vendor marketplace'
// #swagger.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site\'s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.'
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item\'s own literal name (max 60 chars).' }
// #swagger.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site’s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.'
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item’s own literal name (max 60 chars).' }
// #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' }
// #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' }
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' }
// #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' }
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match — the more-like-this filter.' }
// #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard’s own data; an unknown one returns an empty page.' }
// #swagger.parameters['region'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one named region.' }
// #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. `recent` orders by when the shop was last seen.' }
// #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. recent orders by when the shop was last seen.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
/* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */
/* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketPage" } } } } */
/* #swagger.responses[403] = { description: 'The market feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'The market feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Rate limited', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -213,7 +224,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Marketplace size, staleness and filter options'
// #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.'
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketMeta" } } } } */
shard.getMarketMeta,
)
shardRouter.get(
@@ -226,7 +237,7 @@ shardRouter.get(
/* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' }
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketVendor" } } } } */
/* #swagger.responses[400] = { description: 'Malformed vendor serial' } */
/* #swagger.responses[404] = { description: 'No such vendor in the index' } */
param('serial').isString().isLength({ max: 20 }),
@@ -239,15 +250,15 @@ shardRouter.get(
'/features',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
// #swagger.description = 'The caller’s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardFeatures" } } } } */
shard.getFeatures,
)
shardRouter.get(
'/stream',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
// #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
// #swagger.description = 'text/event-stream of live events. The caller’s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
shard.stream,
)

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env node
// ── Does the release actually ship everything the module needs? ────────────
//
// `ci/bundle.json` says what a release copies. `server/index.js` says what the
// module requires. Nothing kept those two in agreement, and on 2026-08-19 they
// disagreed in production: `server/commands/` was added by the Teams cutover,
// the include list in release.yml was not updated, and v1.0.0 shipped without
// it. Every boot logged
//
// module "uo" failed to load — {"stage":"register","reason":"Cannot find
// module './commands/guild.command'"}
//
// and the module was dead on the operator's box. Nothing caught it: the PR
// checks install the module by copying the WHOLE repo into core, so they only
// ever exercised a tree that had the file. The release is the only place the
// subset exists, and the release had no check that the subset was complete.
//
// This script asks that question in the two places it can be asked:
//
// --check (PR checks) Every file reachable from the entry point by a
// relative require lives under something ci/bundle.json
// lists. Source-tree only, so it is fast and needs no
// assembled bundle — it fails on the PR that adds the
// directory, which is where the fix is cheapest.
//
// --bundle <dir> (release) Every relative specifier inside an ASSEMBLED bundle
// resolves to a file that is in it. Asked of the
// artifact rather than of the source, so it also
// catches a copy that half-failed, a list that names a
// path that has moved, and anything else between the
// declaration and the tarball.
//
// The two are deliberately not the same question. The first is about the list
// being right; the second is about the tarball being right. A release runs both.
//
// ── Why reachability, and not "require the entry point" ────────────────────
//
// The obvious check — require the bundle's entry and see if it throws — does not
// work here, and the reason is in index.js's own header: its requires are inside
// `register()` because require order is load-bearing (`core.init(ctx)` has to run
// before anything under `router/` is required). So requiring the entry evaluates
// exactly one line, `require('./core')`, and reports success on a bundle missing
// every router it has. Calling `register()` for real would need a fake `ctx`
// complete enough to satisfy the whole module — which is what `test/` is for, and
// `test/` does not ship. Walking the requires statically asks the same question
// without needing either.
const fs = require('fs')
const path = require('path')
const { stripCommentsAndTemplates } = require('./checkImports')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
// Only relative specifiers. A bare one is checkImports.js's question, not this
// one, and the two failures want different advice.
const RELATIVE = /(?:require\(|from\s+|import\()\s*['"](\.[^'"]+)['"]/g
/**
* Resolve a relative specifier the way Node would, for the file cases that can
* appear here: an exact path, `+.js`/`+.json`, or a directory's `index.js`.
*
* Returns null when nothing exists — which is the finding, not an error.
*/
function resolveFile(fromDir, specifier) {
const base = path.resolve(fromDir, specifier)
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
}
return null
}
/**
* Every file reachable from `entry` by following relative requires, plus every
* specifier that resolved to nothing.
*
* Exported so the test can point it at fixtures — the same reason checkImports.js
* exports `scan`. A check that has never been shown to fail is a check nobody
* knows the state of, and this one is now load-bearing for every release.
*/
function reachable(entry) {
const seen = new Set()
const missing = []
const queue = [entry]
while (queue.length) {
const file = queue.shift()
if (seen.has(file)) continue
seen.add(file)
// A .json dependency is a leaf: it is reached, it ships, and it has no
// requires of its own to follow.
if (file.endsWith('.json')) continue
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(RELATIVE)) {
const target = resolveFile(path.dirname(file), specifier)
if (target) queue.push(target)
else missing.push({ file, specifier })
}
}
return { files: [...seen], missing }
}
/**
* Everything ci/bundle.json says ends up in the bundle, as absolute paths:
* `server[]` relative to server/, `root[]` and `generated[]` relative to the
* module root. All three are equally "in the tarball" as far as a require is
* concerned — the only difference is how they get there.
*/
function declaredServerPaths(moduleRoot = MODULE_ROOT) {
const manifest = JSON.parse(fs.readFileSync(path.join(moduleRoot, 'ci', 'bundle.json'), 'utf8'))
return [
...manifest.server.map((p) => path.join(moduleRoot, 'server', p)),
...(manifest.root || []).map((p) => path.join(moduleRoot, p)),
...(manifest.generated || []).map((p) => path.join(moduleRoot, p))
]
}
const covers = (declared, file) =>
declared.some((d) => file === d || file.startsWith(d + path.sep))
/**
* --check: is ci/bundle.json's list sufficient for what the entry point reaches?
*
* Reports the top-level entry to ADD rather than the individual files, because
* that is the edit: the list is stated in top-level paths, and a new directory
* arrives with a dozen files in it.
*/
function checkDeclaration(moduleRoot = MODULE_ROOT) {
const serverRoot = path.join(moduleRoot, 'server')
const entry = path.join(serverRoot, 'index.js')
const { files, missing } = reachable(entry)
const declared = declaredServerPaths(moduleRoot)
// Grouped by the entry that would have to be added, which is the top-level
// path under server/ — or, for the rare reachable file outside it, the path
// itself, since that one belongs in root[] instead.
const uncovered = new Map()
for (const file of files) {
if (covers(declared, file)) continue
const inServer = file.startsWith(serverRoot + path.sep)
const key = inServer
? `server/${path.relative(serverRoot, file).split(path.sep)[0]}`
: path.relative(moduleRoot, file).split(path.sep).join('/')
if (!uncovered.has(key)) uncovered.set(key, [])
uncovered.get(key).push(file)
}
return { uncovered, missing, reached: files.length }
}
/**
* --bundle: does every relative specifier inside an assembled bundle resolve?
*
* Walks the bundle's own server tree rather than starting from the entry point,
* so a file that ships but is broken is caught too.
*/
function checkBundle(bundleRoot) {
const serverRoot = path.join(bundleRoot, 'server')
const missing = []
const files = []
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name)
if (e.isDirectory()) {
// The installed dependency tree is npm's business, not this check's.
if (e.name !== 'node_modules') walk(p)
} else if (/\.(js|mjs|cjs)$/.test(e.name)) {
files.push(p)
}
}
}
walk(serverRoot)
for (const file of files) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(RELATIVE)) {
if (!resolveFile(path.dirname(file), specifier)) missing.push({ file, specifier })
}
}
return { missing, scanned: files.length }
}
module.exports = { reachable, resolveFile, checkDeclaration, checkBundle, declaredServerPaths }
// Required by a test, or run as the check? Only the second one exits.
if (require.main !== module) return
const bundleFlag = process.argv.indexOf('--bundle')
if (bundleFlag !== -1) {
const root = process.argv[bundleFlag + 1]
if (!root) {
console.error('--bundle needs the path to an assembled bundle')
process.exit(2)
}
const { missing, scanned } = checkBundle(path.resolve(root))
if (missing.length) {
console.error(`\nThe assembled bundle is incomplete — ${missing.length} require(s) resolve to nothing:\n`)
for (const m of missing) {
console.error(` ${path.relative(root, m.file)}\n requires "${m.specifier}" — not in the bundle`)
}
console.error('\nAdd the missing path to ci/bundle.json.\n')
process.exit(1)
}
console.log(`OK — every relative require in the bundle resolves (${scanned} files scanned).`)
} else {
const { uncovered, missing, reached } = checkDeclaration()
if (missing.length) {
console.error(`\n${missing.length} require(s) resolve to nothing in the source tree:\n`)
for (const m of missing) {
console.error(` ${path.relative(MODULE_ROOT, m.file)}\n requires "${m.specifier}"`)
}
console.error('')
process.exit(1)
}
if (uncovered.size) {
console.error(`\nci/bundle.json does not ship everything server/index.js reaches.\n`)
console.error('A release built from this list would install and then fail at the')
console.error('register stage with "Cannot find module", on the operator\'s box.\n')
for (const [key, files] of uncovered) {
console.error(` ${key} (${files.length} file${files.length === 1 ? '' : 's'} reachable)`)
for (const f of files.slice(0, 5)) console.error(` ${path.relative(MODULE_ROOT, f)}`)
if (files.length > 5) console.error(` … and ${files.length - 5} more`)
}
// server[] is written relative to server/, so name the entry to add rather
// than the path just displayed — they differ by exactly that prefix.
const toServer = [...uncovered.keys()].filter((k) => k.startsWith('server/'))
const toRoot = [...uncovered.keys()].filter((k) => !k.startsWith('server/'))
if (toServer.length) {
console.error(`\nAdd ${toServer.map((k) => `"${k.slice('server/'.length)}"`).join(', ')} to ci/bundle.json's server[].`)
}
if (toRoot.length) {
console.error(`\nAdd ${toRoot.map((k) => `"${k}"`).join(', ')} to ci/bundle.json's root[].`)
}
console.error('')
process.exit(1)
}
console.log(`OK — ci/bundle.json ships every file server/index.js reaches (${reached} files).`)
}

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
// ── §5.3 — this module's frozen route manifest ─────────────────────────────
//
// Core freezes its URL surface in `server/routes.manifest.json` by walking the
// live Express stack and committing the result; a PR that moves a URL has to
// commit the new manifest, which puts the change in front of a reviewer. After
// phase 3 the seventy URLs this module serves are no longer in that file. They
// are here, frozen the same way and by the same generator.
//
// **The module's routes are DERIVED, never listed.** This script is handed two
// manifests generated from the SAME core at the pinned ref — one without this
// module on the volume, one with — and the difference is what this module serves.
// Nothing here says "/api/v1/public/shard/*"; a mount prefix appears in exactly
// one place, `server/index.js`'s `registerRoutes` call, which is where an operator's
// core reads it from too.
//
// Taking the difference rather than filtering by prefix buys the other half of
// §5.3 for free, and it is the half that matters most: **no core URL may move.**
// A module that shadowed a core route, or whose mount displaced one, shows up
// here as a removal or a change, not merely as an addition somewhere else. That
// is the promise §1.2 makes to the shipped Android app and the Discord bot.
//
// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json`
// is generated from the module's own registrations against §2.4's stated tier
// bases — the one place a constant could be wrong. Here there is ground truth: a
// real core with this module loaded, reporting the URLs it actually serves. Every
// route must have a documented operation and every documented operation must be a
// route. That is the per-module form of core's standing rule, never ship a route
// that isn't in the spec — and it is what stops a wrong constant in the generator
// from producing a fragment that is internally consistent and describes nothing
// core will ever serve.
//
// Usage (the workflow does the cloning; see .gitea/workflows/frozen-manifest.yml):
// node scripts/frozenManifest.js --before core-only.json --after core-plus-uo.json
// node scripts/frozenManifest.js --before … --after … --check
const fs = require('fs')
const path = require('path')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json')
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
const COMMENT =
'Generated inventory of the URLs module-uo serves - the module half of the freeze ' +
'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' +
'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' +
'Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.'
const key = (r) => `${r.method} ${r.path}`
/**
* The module's routes, plus proof that core's own surface did not move.
*
* @param {object} before routes.manifest.json from core alone
* @param {object} after routes.manifest.json from the same core with this module
* @returns {{ added: object[], removed: string[] }}
*/
function diffManifests(before, after) {
const added = []
const removed = []
for (const tier of ['public', 'internal']) {
const was = new Set((before[tier] || []).map(key))
for (const route of after[tier] || []) {
if (!was.has(key(route))) added.push({ ...route, tier })
was.delete(key(route))
}
for (const gone of was) removed.push(`${tier} ${gone}`)
}
added.sort((a, b) => (key(a) < key(b) ? -1 : 1))
return { added, removed }
}
/**
* Which of the module's routes the fragment fails to document, and vice versa.
*
* Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling
* because that is what core merges, so the manifest's paths are converted here
* rather than the other way round.
*/
function coverage(added, fragment) {
const documented = new Set()
for (const [p, item] of Object.entries(fragment.paths || {})) {
for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`)
}
const undocumented = []
for (const route of added) {
const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
if (documented.has(oas)) documented.delete(oas)
else undocumented.push(oas)
}
// Whatever is left is documented and not served: a route that moved or was
// deleted while its annotation stayed behind. Core's spec has no equivalent
// check and grew four orphan tags and thirty-three orphan schemas because of it.
return { undocumented, unserved: [...documented].sort() }
}
function serialize(routes) {
return `${JSON.stringify(
{
$comment: COMMENT,
routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })),
},
null,
2,
)}\n`
}
function main() {
const arg = (name) => {
const i = process.argv.indexOf(name)
return i === -1 ? null : process.argv[i + 1]
}
const beforePath = arg('--before')
const afterPath = arg('--after')
if (!beforePath || !afterPath) {
process.stderr.write('usage: frozenManifest.js --before <manifest> --after <manifest> [--check]\n')
process.exit(2)
}
const before = JSON.parse(fs.readFileSync(beforePath, 'utf8'))
const after = JSON.parse(fs.readFileSync(afterPath, 'utf8'))
const { added, removed } = diffManifests(before, after)
let failed = false
if (removed.length > 0) {
process.stderr.write(
`\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` +
`${removed.map((r) => ` - ${r}`).join('\n')}\n` +
'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n',
)
failed = true
}
if (added.length === 0) {
process.stderr.write(
'\nLoading this module added NO routes. Either it failed to load in the core checkout\n' +
'(check the boot log for a startup_failed line) or the two manifests are the same file.\n',
)
process.exit(1)
}
const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
const { undocumented, unserved } = coverage(added, fragment)
if (undocumented.length > 0) {
process.stderr.write(
`\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` +
`${undocumented.map((r) => ` - ${r}`).join('\n')}\n` +
'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n',
)
failed = true
}
if (unserved.length > 0) {
process.stderr.write(
`\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` +
`${unserved.map((r) => ` - ${r}`).join('\n')}\n` +
'A documented URL nobody serves is a client following the docs into a 404.\n',
)
failed = true
}
if (failed) process.exit(1)
const contents = serialize(added)
if (process.argv.includes('--check')) {
const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null
if (current !== contents) {
process.stderr.write(
'\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' +
'commit the result so the move is reviewed rather than merged as mechanical.\n',
)
process.exit(1)
}
process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`)
return
}
fs.writeFileSync(MANIFEST, contents)
process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`)
}
if (require.main === module) main()
module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }

View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
// ── §2.8 — the OpenAPI fragment ────────────────────────────────────────────
//
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
// tags and schemas describing every route this module registers. Core merges the
// fragments of *started* modules over its own committed spec at request time and
// serves the result at `/api/docs.json` (docs/website/MODULE_API.md §6.1a).
//
// **Why a module ships a fragment at all.** Core's `npm run swagger` is STATIC
// analysis — swagger-autogen parses `src/app.js` as text and follows the literal
// `app.use(...)` chain. A module arrives on a volume after core was built, is
// required by a filesystem loop, and mounts through `api.registerRoutes()`. There
// is no literal mount for a parser to follow and core does not have our sources
// anyway, so nothing core can run will ever describe these routes. The failure
// mode is the dangerous one: swagger-autogen reports success and emits a spec
// with the routes simply absent (§6.1, and core hit it twice — the spike's atlas
// paths and PR 4's 407 deleted lines).
//
// ── Where the prefixes come from ───────────────────────────────────────────
//
// swagger-autogen is pointed at one router file at a time, so its paths come out
// relative to that router (`/status`, not `/api/v1/public/shard/status`) — nothing
// in the file says where it hangs. §6.1a requires fully-qualified paths, because
// core merges the fragment verbatim and never re-derives a prefix.
//
// So this script **runs the module's own `register()`** against a recording `api`
// and reads the mounts back out of it. The prefix of every router is therefore the
// prefix that router is actually registered under — the same call an operator's
// core will make, not a table beside it that drifts the first time a mount moves.
// Which router a recorded object came from is answered by `require.cache`: the
// file whose `module.exports` IS this router.
//
// The two things that cannot be derived here are the tier base paths and the
// extension slot's mount, because they are core's, not ours. They are §2.4's
// normative table, quoted below — and they are not taken on trust: the frozen
// route manifest (`scripts/frozenManifest.js`) generates the real URLs from a real
// core with this module loaded, and fails if a fragment path is not among them.
// That check is where a wrong constant here dies.
const fs = require('fs')
const os = require('os')
const path = require('path')
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const { fakeCtx, fakeApi } = require('../test/_fakes')
const doc = require('../swagger/doc')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
// router in core, behind its gate; the tier's own base path is core's and fixed
// by §1.2's frozen URL surface.
const TIER_BASE = {
public: '/api/v1/public',
admin: '/api/v1/admin',
player: '/api/v1/player',
}
// MODULE_API.md §2.4's slot table. Exactly one slot exists in v1, and only core
// may declare one — so a module filling it has to be told where it landed.
const SLOT_MOUNT = {
'admin.users.detail': '/api/v1/admin/users/:id',
}
/**
* Run `register()` with a recording api and return `[{ file, prefix }]`.
*
* The ctx is the test fakes' — the same one the suite proves the module runs
* against — because registration must not touch a database (§2.2 rule 1) and this
* script is exactly the kind of no-database caller that rule exists for.
*/
function mountedRouters() {
const register = require('../index')
const api = fakeApi()
register(fakeCtx(), api)
const fileOf = (router) => {
for (const mod of Object.values(require.cache)) {
if (mod && mod.exports === router) return mod.filename
}
return null
}
const mounts = []
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
const base = TIER_BASE[tier]
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
for (const [prefix, router] of Object.entries(byPrefix)) {
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
}
}
for (const { slot, router } of api.record.extensions) {
const mount = SLOT_MOUNT[slot]
if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`)
mounts.push({ router, prefix: mount, what: `slot ${slot}` })
}
return mounts.map(({ router, prefix, what }) => {
const file = fileOf(router)
if (!file) {
// A router built inline in index.js rather than required from its own file.
// swagger-autogen needs a file to read, so there is nothing to generate from.
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
}
return { file, prefix, what }
})
}
/**
* Run swagger-autogen over one router file. Paths come out router-relative.
*
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
* `console.error`s "Syntax error" or "out of structure", drops that one
* annotation, and prints `Success` in green. Four of the annotations that came
* across in slice 1 were broken that way and had been for as long as they had
* existed in core: two `requestBody` literals a brace short, and two descriptions
* whose inner quoting the tool cannot survive (it re-quotes `"` and a backtick to
* `'` before evaluating, so either inside a single-quoted description ends the
* string early). The visible result was a documented route missing its body, or a
* typed query parameter demoted to an untyped one.
*
* So its diagnostics are captured and made fatal. This is the same class as every
* other failure in this seam — a generator that reports success while silently
* dropping what it was asked to describe (§6.1) — and the only difference is that
* here the tool does say something. Nothing was listening.
*/
async function fragmentFor(file) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-swagger-'))
const out = path.join(dir, 'fragment.json')
const complaints = []
const realError = console.error
console.error = (...args) => {
const line = args.map(String).join(' ')
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
else realError(...args)
}
try {
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
// renders `components.schemas` from an EXAMPLE object rather than treating it
// as OpenAPI — `{ type: 'object' }` comes back as `{ type: 'object',
// properties: { type: { type: 'string', example: 'object' } } }`, a
// meta-description of itself. That shape is uniform across core's committed
// spec and is the house shape, so it is matched rather than fought. What is
// NOT survivable is that it writes the result back into the object it was
// handed: reusing one `doc` across six routers re-wraps the previous pass's
// output five more times, and the fragment came out at 484 MB.
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
...JSON.parse(JSON.stringify(doc)),
info: { title: 'module-uo fragment', version: '0' },
})
} finally {
console.error = realError
}
if (complaints.length > 0) {
throw new Error(
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
)
}
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
fs.rmSync(dir, { recursive: true, force: true })
return fragment
}
/**
* Re-root a router-relative fragment under the prefix it is mounted at.
*
* Express path params (`:id`) become OpenAPI's (`{id}`), and the prefix's own
* params are moved to the FRONT of each operation's parameter list: swagger-autogen
* orders parameters by where they appeared in the path it saw, which was only the
* tail, so `/{id}/shard/link/{account}` would otherwise document (account, id).
*/
function prefixPaths(fragment, prefix) {
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
const paths = {}
for (const [p, item] of Object.entries(fragment.paths || {})) {
for (const operation of Object.values(item)) {
const params = operation && operation.parameters
if (!Array.isArray(params)) continue
const rank = (q) => {
const i = outer.indexOf(q && q.name)
return i === -1 ? outer.length : i
}
operation.parameters = params
.map((q, i) => ({ q, i }))
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
.map(({ q }) => q)
}
// `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`,
// a URL no client calls. Core's swagger.js normalizes the same way.
paths[`${oas}${p}`.replace(/\/$/, '')] = item
}
return paths
}
/**
* Build the whole fragment: every mounted router, re-rooted and merged.
*
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a allows
* a fragment to carry. `info`, `servers` and the security schemes are the merged
* document's, which is to say core's.
*/
async function build() {
const spec = { paths: {}, tags: [], components: { schemas: {} } }
let shared = false
for (const { file, prefix, what } of mountedRouters()) {
const generated = await fragmentFor(file)
// The tags and schemas are the SAME on every pass — each was handed the same
// `doc` — so they are taken from whichever ran first rather than from `doc`
// itself. What lands in the fragment has to be what swagger-autogen produced,
// not what it was given: those two differ (see fragmentFor), and core merges
// this file verbatim into a spec whose own schemas went through the same mill.
if (!shared) {
spec.tags = generated.tags || []
spec.components.schemas = (generated.components || {}).schemas || {}
shared = true
}
const paths = prefixPaths(generated, prefix)
const count = Object.keys(paths).length
if (count === 0) {
// An empty fragment is precisely what the silent drop looks like, so it is
// a hard failure rather than a router that happens to declare no routes.
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
}
for (const [p, item] of Object.entries(paths)) {
if (spec.paths[p]) {
throw new Error(`swagger: two of this module's routers both document ${p}`)
}
spec.paths[p] = item
}
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
}
// Sorted, for the reason core sorts: swagger-autogen emits router-traversal
// order, so moving a route between files would rewrite most of this committed
// artifact even when the API is provably unchanged.
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
return spec
}
async function main() {
const check = process.argv.includes('--check')
const spec = await build()
const json = `${JSON.stringify(spec, null, 2)}\n`
if (!check) {
fs.writeFileSync(FRAGMENT, json)
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
return
}
if (!fs.existsSync(FRAGMENT)) {
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
process.exit(1)
}
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
process.stderr.write(
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
'so a stale one documents a URL surface this module does not serve.\n',
)
process.exit(1)
}
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
}
if (require.main === module) {
main().catch((err) => {
process.stderr.write(`${err.stack}\n`)
process.exit(1)
})
}
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, SLOT_MOUNT, FRAGMENT }

621
server/swagger/doc.js Normal file
View File

@@ -0,0 +1,621 @@
// ── module-uo's OpenAPI fragment: the shared half ──────────────────────────
//
// The tags and component schemas every `#swagger.*` annotation under
// `server/router/**` refers to. `scripts/swaggerFragment.js` feeds this to
// swagger-autogen; the per-endpoint detail lives beside each route, exactly as
// it does in core.
//
// These 31 schemas were core's until phase 3 — they sat in
// `website/server/swagger/swagger.js` describing routes core no longer serves,
// which is what an extraction leaves behind if nobody looks (the inert-leaf
// class slice 4 found in `api/client.js`). They moved with the routes.
//
// **Two rules about names, and both are the merged document's, not this file's**
// (docs/website/MODULE_API.md §6.1a):
//
// • **What this module DEFINES is namespaced `Uo…`.** Core merges started
// modules' fragments into one `/api/docs.json`, and core wins every key
// collision — so an un-namespaced `ShardStatus` from a second game's module
// would silently lose to, or clobber, this one. The prefix is what makes two
// modules able to describe the same idea.
// • **What core defines is referenced by CORE's name.** The annotations point
// at `#/components/schemas/Error` and `ValidationError` and this file does
// not redefine them: they resolve in the merged spec, where core's
// definitions are. Shipping our own copy would be a collision core drops,
// which is the correct outcome arrived at the expensive way.
//
// Tag NAMES are core's originals (`Public · Shard`, not `Uo · Shard`). A tag is
// how the docs UI groups operations, and core stopped declaring these four in
// the same slice this file started — nothing collides, and renaming them would
// churn every reader's bookmark for no gain.
module.exports = {
tags: [
{ name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' },
{ name: 'Public · Atlas', description: 'Spawn atlas / bestiary — static shard content parsed from the shard\'s own ServUO tree, independent of the sidecar' },
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
{ name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' },
],
components: {
schemas: {
// ── uo-link shard data ──────────────────────────────────────────────
UoShardStatus: {
type: 'object',
description: 'Public shard status (GET /public/shard/status).',
properties: {
enabled: { type: 'boolean', example: true },
status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' },
pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true },
lastEventAt: { type: 'string', format: 'date-time', nullable: true },
onlineCount: { type: 'integer', example: 12 },
economy: { $ref: '#/components/schemas/UoShardEconomyPoint' },
},
},
UoShardEvent: {
type: 'object',
description: 'A logged shard event.',
properties: {
id: { type: 'integer', example: 4821 },
kind: { type: 'string', example: 'vendor.sale' },
t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 },
bootId: { type: 'string', nullable: true, example: 'boot-abc123' },
payload: { type: 'object', additionalProperties: true, description: 'The full event object.' },
createdAt: { type: 'string', format: 'date-time' },
},
},
UoShardEconomyPoint: {
type: 'object',
nullable: true,
description: 'One gold-supply sample.',
properties: {
accounts: { type: 'integer', nullable: true, example: 240 },
gold: { type: 'integer', nullable: true, example: 1028983421 },
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
},
},
UoShardOnlinePlayer: {
type: 'object',
description: 'A LINKED player online now (only accounts linked to a website user are listed).',
properties: {
serial: { type: 'string', example: '0x24C' },
name: { type: 'string', example: 'Darrow' },
map: { type: 'string', nullable: true, example: 'Trammel' },
x: { type: 'integer', nullable: true, example: 1402 },
y: { type: 'integer', nullable: true, example: 1604 },
z: { type: 'integer', nullable: true, example: 0 },
},
},
UoShardVendorSale: {
type: 'object',
description: 'A player-vendor sale (visible only to the linked owner).',
properties: {
t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
itemType: { type: 'string', example: 'Longsword' },
amount: { type: 'integer', example: 1 },
price: { type: 'integer', example: 100 },
commission: { type: 'integer', nullable: true, example: 5 },
ownerAcct: { type: 'string', example: 'whitlocktech' },
},
},
UoShardHouse: {
type: 'object',
description: 'A house at its current decay stage.',
properties: {
serial: { type: 'string', example: '0x4004705F' },
stage: { type: 'string', example: 'IDOC' },
map: { type: 'string', nullable: true, example: 'Trammel' },
x: { type: 'integer', nullable: true },
y: { type: 'integer', nullable: true },
z: { type: 'integer', nullable: true },
region: { type: 'string', nullable: true },
name: { type: 'string', nullable: true, example: 'An Unnamed House' },
ownerSerial: { type: 'string', nullable: true },
ownerAcct: { type: 'string', nullable: true },
builtOn: { type: 'string', format: 'date-time', nullable: true },
lastRefreshed: { type: 'string', format: 'date-time', nullable: true },
isIdoc: { type: 'boolean', example: true },
updatedAt: { type: 'string', format: 'date-time' },
},
},
UoShardPointsBoard: {
type: 'object',
description:
"One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side.",
properties: {
system: { type: 'string', example: 'QueensLoyalty', description: "The shard's PointsType name; the board's stable key." },
nameString: { type: 'string', nullable: true, example: "Queen's Loyalty" },
nameNumber: { type: 'integer', nullable: true, example: 1114938, description: 'Cliloc id, 0 when the name is a literal.' },
maxPoints: { type: 'integer', nullable: true, example: 30000 },
players: { type: 'integer', nullable: true, example: 842, description: 'Players actually holding points in this system.' },
showOnGump: { type: 'boolean', example: true, description: "The shard's own 'is this player-facing?' flag." },
top: {
type: 'array',
description: 'The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet.',
items: {
type: 'object',
properties: {
rank: { type: 'integer', example: 1 },
serial: { type: 'string', example: '0x1A2B' },
name: { type: 'string', example: 'Darrow', description: 'Omitted when the leaderboards `name` field is gated above the caller.' },
points: { type: 'integer', example: 29500 },
},
},
},
t: { type: 'integer', nullable: true, description: 'Frame time, epoch ms.' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
UoShardMarketLocation: {
type: 'object',
nullable: true,
description:
"Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller.",
properties: {
map: { type: 'string', nullable: true, example: 'Trammel' },
x: { type: 'integer', nullable: true, example: 1421 },
y: { type: 'integer', nullable: true, example: 1699 },
z: { type: 'integer', nullable: true, example: 0 },
region: { type: 'string', nullable: true, example: 'Britain' },
house: { type: 'string', nullable: true, example: "Darrow's Villa", description: "The house SIGN's name, not the house type. Null for a vendor standing outside one." },
},
},
UoShardMarketListing: {
type: 'object',
description:
'One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request.',
properties: {
serial: { type: 'string', example: '0x40012ABC' },
itemId: { type: 'integer', example: 3922, description: 'ItemID (the art/graphic id).' },
hue: { type: 'integer', example: 0 },
amount: { type: 'integer', example: 1 },
price: { type: 'integer', example: 25000 },
name: { type: 'string', nullable: true, description: "The item's own literal name, set by a player. Null for most items." },
cliloc: { type: 'integer', nullable: true, example: 1023721, description: "The item's LabelNumber." },
displayName: {
type: 'string',
nullable: true,
example: 'quarter staff',
description: 'Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id.',
},
child: { type: 'boolean', example: false, description: 'Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it.' },
vendor: {
type: 'object',
properties: {
serial: { type: 'string', example: '0x40001234' },
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
ownerSerial: { type: 'string', nullable: true, example: '0x1A2B', description: 'Omitted when the market `ownerSerial` field is gated above the caller.' },
ownerName: { type: 'string', nullable: true, example: 'Darrow', description: 'Omitted when the market `ownerName` field is gated above the caller.' },
location: { $ref: '#/components/schemas/UoShardMarketLocation' },
updatedAt: { type: 'string', format: 'date-time', description: 'When the shard last published this shop.' },
},
},
},
},
UoShardMarketPage: {
type: 'object',
description: 'A page of marketplace listings plus the unpaginated total and the staleness stamp.',
properties: {
listings: { type: 'array', items: { $ref: '#/components/schemas/UoShardMarketListing' } },
total: { type: 'integer', example: 1284, description: 'Matching listings, ignoring paging.' },
limit: { type: 'integer', example: 50 },
offset: { type: 'integer', example: 0 },
vendors: { type: 'integer', example: 137, description: 'Vendors in the whole index.' },
staleAt: {
type: 'string',
format: 'date-time',
nullable: true,
description: 'The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices.',
},
},
},
UoShardMarketVendor: {
type: 'object',
description: 'One player vendor and its listings.',
properties: {
serial: { type: 'string', example: '0x40001234' },
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
ownerSerial: { type: 'string', nullable: true },
ownerName: { type: 'string', nullable: true, example: 'Darrow' },
location: { $ref: '#/components/schemas/UoShardMarketLocation' },
count: { type: 'integer', example: 250, description: 'Listings the shard published for this shop.' },
total: { type: 'integer', example: 3104, description: 'Listings the shop actually holds.' },
truncated: { type: 'boolean', example: true, description: '`total` exceeds `count` — the shop holds more than the shard publishes per frame.' },
updatedAt: { type: 'string', format: 'date-time' },
items: { type: 'array', items: { $ref: '#/components/schemas/UoShardMarketListing' } },
},
},
UoShardMarketMeta: {
type: 'object',
description: 'Marketplace size, staleness and the filter options a client needs to build its UI.',
properties: {
vendors: { type: 'integer', example: 137 },
items: { type: 'integer', example: 18422 },
staleAt: { type: 'string', format: 'date-time', nullable: true },
freshAt: { type: 'string', format: 'date-time', nullable: true },
maps: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Trammel'], description: "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." },
regions: { type: 'array', items: { type: 'string' }, example: ['Britain', 'Luna'] },
},
},
UoShardFeatures: {
type: 'object',
description:
"The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403.",
properties: {
level: {
type: 'string',
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
example: 'anonymous',
},
features: {
type: 'array',
items: { type: 'string' },
example: ['status', 'activity', 'champs', 'guilds', 'governors', 'houses', 'presence'],
},
},
},
UoShardFeatureVisibility: {
type: 'object',
description: 'Visibility settings for one shard feature.',
properties: {
enabled: { type: 'boolean', example: true },
audience: {
type: 'string',
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
description: 'Minimum rung that may reach this feature. Each rung implies the ones below it.',
example: 'anonymous',
},
stream: {
type: 'boolean',
description: "Whether this feature's event kinds fan out over SSE at all.",
example: true,
},
fieldRules: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here.',
example: { location: 'staff' },
},
},
},
UoShardVisibilityConfig: {
type: 'object',
properties: {
ladder: {
type: 'array',
items: { type: 'string' },
example: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
},
lockedFields: { type: 'array', items: { type: 'string' }, example: ['acct', 'webId'] },
defaults: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
},
features: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
},
},
},
UoShardVisibilityUpdate: {
type: 'object',
required: ['features'],
properties: {
features: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
example: { market: { enabled: true, audience: 'player', stream: false, fieldRules: { ownerName: 'player' } } },
},
},
},
// ── Spawn atlas (Protocol 3.0 Part C) ────────────────────────────────
// Static shard content, parsed from the shard's own ServUO tree. Nothing
// here comes from the sidecar, so it stays populated while the shard is
// down. Facet names are whatever the shard's files declare — the examples
// below are stock ServUO, not a fixed list.
UoAtlasCreature: {
type: 'object',
description: 'A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route.',
properties: {
slug: { type: 'string', example: 'lizardman' },
name: { type: 'string', example: 'Lizardman' },
total: { type: 'integer', description: 'How many can be alive at once, summed across every spawner.', example: 214 },
points: { type: 'integer', description: 'How many spawners mention this creature.', example: 62 },
facets: {
type: 'object',
additionalProperties: { type: 'integer' },
description: "This creature's share per facet.",
example: { Felucca: 96, Trammel: 88, Tokuno: 30 },
},
art: { type: 'string', nullable: true, description: 'Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art.' },
places: {
type: 'array',
description: 'Where it spawns, aggregated by resolved place. The answer the atlas exists to give.',
items: {
type: 'object',
properties: {
facet: { type: 'string', example: 'Trammel' },
label: { type: 'string', description: 'Resolved region, else nearest landmark group, else "Wilderness".', example: 'Shrines' },
spawners: { type: 'integer', example: 7 },
maxAlive: { type: 'integer', example: 21 },
},
},
},
spawners: {
type: 'array',
description: 'The individual spawners. Named separately from `points` (the count) so one key never means two things.',
items: { $ref: '#/components/schemas/UoAtlasSpawner' },
},
spawnersTruncated: { type: 'boolean', description: 'True when the spawner list was cut at the requested bound.', example: false },
alsoHere: {
type: 'array',
description: 'Creatures sharing a spawner with this one.',
items: {
type: 'object',
properties: {
slug: { type: 'string', example: 'lizardman-warrior' },
name: { type: 'string', example: 'Lizardman Warrior' },
shared: { type: 'integer', example: 12 },
},
},
},
},
},
UoAtlasSpawner: {
type: 'object',
description: 'One ServUO spawner, with the place its coordinates resolved to.',
properties: {
id: { type: 'integer' },
facet: { type: 'string', example: 'Felucca' },
name: { type: 'string', nullable: true, description: "The spawner's own name in the ServUO file." },
x: { type: 'integer', example: 5411 },
y: { type: 'integer', example: 1234 },
width: { type: 'integer' },
height: { type: 'integer' },
range: { type: 'integer', description: 'Spawn radius.' },
maxCount: { type: 'integer', description: 'How many of THIS creature this spawner keeps alive.', example: 3 },
minDelay: { type: 'integer', description: 'Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag.', example: 300 },
maxDelay: { type: 'integer', example: 600 },
todStart: { type: 'integer', description: 'Meaningless unless todMode is non-zero.' },
todEnd: { type: 'integer' },
todMode: { type: 'integer' },
region: { type: 'string', nullable: true, example: 'Despise' },
landmark: { type: 'string', nullable: true, example: 'Covetous' },
label: { type: 'string', description: 'Region, else landmark group, else "Wilderness".', example: 'Despise' },
},
},
UoAtlasCreaturePage: {
type: 'object',
properties: {
total: { type: 'integer', description: 'Matching creatures before pagination.', example: 800 },
limit: { type: 'integer', example: 50 },
offset: { type: 'integer', example: 0 },
creatures: { type: 'array', items: { $ref: '#/components/schemas/UoAtlasCreature' } },
},
},
UoAtlasRegion: {
type: 'object',
description: 'A named region, flattened out of the shard\'s nested Regions.xml.',
properties: {
facet: { type: 'string', example: 'Felucca' },
name: { type: 'string', example: 'Despise' },
type: { type: 'string', nullable: true, description: 'ServUO region class.', example: 'DungeonRegion' },
priority: { type: 'integer', example: 50 },
parent: { type: 'string', nullable: true, example: 'Britain' },
rects: {
type: 'array',
description: 'The rectangles that placed each spawn point.',
items: { type: 'object', additionalProperties: true },
},
},
},
UoAtlasLandmark: {
type: 'object',
properties: {
facet: { type: 'string', example: 'Trammel' },
name: { type: 'string', example: 'Level 1' },
group: { type: 'string', nullable: true, description: 'Innermost enclosing parent — the label worth showing.', example: 'Covetous' },
x: { type: 'integer', example: 5411 },
y: { type: 'integer', example: 1234 },
z: { type: 'integer', example: 0 },
},
},
UoAtlasChampion: {
type: 'object',
description: 'A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that.',
properties: {
slug: { type: 'string', example: 'felucca-deceit' },
name: { type: 'string', example: 'Deceit' },
group: { type: 'string', nullable: true, description: 'Spawn group; one altar active per group.', example: 'Dungeons' },
type: { type: 'string', nullable: true, description: 'NULL when the champion is drawn at activation.', example: 'UnholyTerror' },
randomType: { type: 'boolean', example: false },
facet: { type: 'string', example: 'Felucca' },
x: { type: 'integer' },
y: { type: 'integer' },
z: { type: 'integer' },
radius: { type: 'integer', example: 60 },
label: { type: 'string', nullable: true, example: 'Deceit' },
},
},
UoAtlasMeta: {
type: 'object',
description: 'What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route.',
properties: {
importedAt: { type: 'string', format: 'date-time', nullable: true },
generatedAt: { type: 'string', format: 'date-time', nullable: true },
counts: {
type: 'object',
nullable: true,
additionalProperties: true,
example: { facets: 6, points: 6455, creatures: 800, regions: 387, landmarks: 558, champions: 25, unresolvedPoints: 1086 },
},
facets: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Ilshenar', 'Malas', 'TerMur', 'Tokuno', 'Trammel'] },
},
},
UoAtlasStatus: {
type: 'object',
description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
properties: {
configured: { type: 'boolean', example: true },
path: { type: 'string', example: '/srv/servuo' },
treeReadable: { type: 'boolean', example: true },
drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false },
facets: { type: 'array', items: { type: 'string' } },
importedAt: { type: 'string', format: 'date-time', nullable: true },
counts: { type: 'object', nullable: true, additionalProperties: true },
pending: {
type: 'object',
nullable: true,
description: 'A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected.',
additionalProperties: true,
},
},
},
UoAtlasRefreshResult: {
type: 'object',
description: 'Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500.',
properties: {
status: {
type: 'string',
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed', 'rejected', 'none'],
example: 'imported',
},
reason: { type: 'string', nullable: true },
path: { type: 'string', nullable: true },
counts: { type: 'object', nullable: true, additionalProperties: true },
addedFacets: { type: 'array', items: { type: 'string' } },
removedFacets: { type: 'array', items: { type: 'string' } },
},
},
UoClilocStatus: {
type: 'object',
description:
'Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids.',
properties: {
configured: { type: 'boolean', example: true },
path: { type: 'string', example: '/srv/uo-client' },
file: { type: 'string', nullable: true, description: 'The file actually resolved, when the path is a directory.', example: '/srv/uo-client/clilocs.tsv' },
fileReadable: { type: 'boolean', example: true },
problem: { type: 'string', nullable: true, description: 'Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file.', example: null },
code: { type: 'string', nullable: true, description: 'Machine-readable cause of `problem`.', enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED'] },
drift: { type: 'boolean', nullable: true, description: 'True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable.', example: false },
count: { type: 'integer', description: 'Entries currently loaded.', example: 67496 },
sources: {
type: 'array',
items: { type: 'string' },
description: 'Every source found now, root-relative, base first then overlays in merge order.',
example: ['clilocs.plain', 'custom/uomysticmoon.tsv'],
},
loadedSources: {
type: 'array',
nullable: true,
description: 'What each source contributed at the last import.',
items: {
type: 'object',
properties: {
label: { type: 'string', example: 'custom/uomysticmoon.tsv' },
kind: { type: 'string', enum: ['base', 'custom'], example: 'custom' },
entries: { type: 'integer', example: 37 },
added: { type: 'integer', description: 'Ids this source introduced.', example: 25 },
overrode: { type: 'integer', description: 'Ids it replaced from an earlier source.', example: 12 },
},
},
},
missingSources: {
type: 'array',
items: { type: 'string' },
description: 'Sources loaded previously and now absent. An import refuses these without `approve`.',
example: [],
},
importedAt: { type: 'string', format: 'date-time', nullable: true },
sourceBytes: { type: 'integer', nullable: true, example: 4973525 },
},
},
UoClilocRefreshResult: {
type: 'object',
description:
'Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500.',
properties: {
status: {
type: 'string',
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'],
description: '`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it.',
example: 'imported',
},
reason: { type: 'string', nullable: true },
code: {
type: 'string',
nullable: true,
description: 'Machine-readable cause. `COMPRESSED` means the client\'s own Cliloc.enu was supplied instead of a converted one.',
enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED', 'TRUNCATED', 'EMPTY', 'NOT_BUFFER'],
},
path: { type: 'string', nullable: true },
file: { type: 'string', nullable: true },
count: { type: 'integer', nullable: true, description: 'Entries stored (blank strings are dropped).', example: 67496 },
parsed: { type: 'integer', nullable: true, description: 'Entries read across every source before blanks were dropped.', example: 123527 },
blank: { type: 'integer', nullable: true, example: 55994 },
sources: {
type: 'array',
nullable: true,
description: 'Per-source breakdown: what each file contributed and how much of it overrode an earlier source.',
items: {
type: 'object',
properties: {
label: { type: 'string' },
kind: { type: 'string', enum: ['base', 'custom'] },
entries: { type: 'integer' },
added: { type: 'integer' },
overrode: { type: 'integer' },
},
},
},
missingSources: {
type: 'array',
nullable: true,
items: { type: 'string' },
description: 'On `needsReview`: the sources that vanished. Nothing was applied.',
},
acceptedMissing: {
type: 'array',
nullable: true,
items: { type: 'string' },
description: 'On `imported` with `approve`: the vanished sources the admin accepted.',
},
},
},
UoShardLinkRequest: {
type: 'object',
required: ['code'],
properties: {
code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' },
},
},
UoShardLinkResult: {
type: 'object',
properties: {
linked: { type: 'boolean', example: true },
account: { type: 'string', example: 'whitlocktech' },
},
},
UoShardLink: {
type: 'object',
description: 'A linked in-game account (GET /player/shard/accounts).',
properties: {
account: { type: 'string', example: 'whitlocktech' },
userId: { type: 'integer', example: 42 },
charName: { type: 'string', nullable: true, example: 'Darrow' },
linkedAt: { type: 'string', format: 'date-time' },
},
},
UoTownCrierRequest: {
type: 'object',
required: ['id', 'lines'],
properties: {
id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' },
lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] },
durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 },
},
},
},
},
}

View File

@@ -95,6 +95,8 @@ function fakeApi() {
extensions: [],
streams: null,
legs: [],
teamProvider: null,
slashCommands: [],
hooks: {},
}
const called = new Set()
@@ -107,6 +109,14 @@ function fakeApi() {
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
// MODULE_API 1.6.0. `once` because core holds a single provider per
// deployment — a second registration is a collision there, so it has to be
// one here too, or this suite would pass a shape core rejects at load.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
// MODULE_API 1.6.0, live since phase 7. `once` for the same reason core
// takes it: a second call is a module changing its mind halfway through
// register(), which core rejects.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -0,0 +1,208 @@
// The bundle check, checked.
//
// `scripts/checkBundle.js` exists because v1.0.0 shipped without
// `server/commands/` and died at the register stage on the operator's box. A
// check written in response to one bug is worth exactly as much as its coverage
// of that bug, so the first two tests below are that bug, in both modes: a list
// that has stopped covering what the entry point reaches, and a tarball with the
// file missing from it.
//
// **Every fixture is a template literal, and that is load-bearing** — the same
// reason checkImports.test.js gives. `scripts/checkImports.js` scans this
// directory too, so an ordinary quoted string holding a relative require would
// make this file fail that check. Templates are blanked by the stripper.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
reachable,
resolveFile,
checkDeclaration,
checkBundle,
declaredServerPaths
} = require('../scripts/checkBundle')
/**
* Write a throwaway module tree: `files` under server/, `bundle` as its
* ci/bundle.json. Returns the module root.
*/
function fixture(files, bundle = { server: ['index.js'] }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-uo-bundle-'))
for (const [name, source] of Object.entries(files)) {
const file = path.join(root, 'server', name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, source)
}
fs.mkdirSync(path.join(root, 'ci'), { recursive: true })
fs.writeFileSync(path.join(root, 'ci', 'bundle.json'), JSON.stringify(bundle))
return root
}
const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true })
// ── The regression this script was written for ─────────────────────────────
test('--check catches a directory the include list has stopped covering', () => {
const root = fixture(
{
'index.js': `const g = require('./commands/guild.command')`,
'commands/guild.command.js': `module.exports = {}`
},
{ server: ['index.js'] } // `commands` missing — exactly v1.0.0
)
try {
const { uncovered } = checkDeclaration(root)
assert.strictEqual(uncovered.size, 1)
assert.ok(uncovered.has('server/commands'))
} finally {
cleanup(root)
}
})
test('--bundle catches the file missing from an assembled tarball', () => {
const root = fixture({ 'index.js': `require('./commands/guild.command')` })
try {
const { missing } = checkBundle(root)
assert.strictEqual(missing.length, 1)
assert.strictEqual(missing[0].specifier, './commands/guild.command')
} finally {
cleanup(root)
}
})
// ── It has to reach requires that are not at the top level ─────────────────
test('follows requires written inside a function', () => {
// index.js requires inside `register()` because require order is load-bearing.
// A check that only saw file-scope requires would have missed the real bug.
const root = fixture(
{
'index.js': `module.exports = function register(ctx) { const r = require('./router/a') }`,
'router/a.js': `module.exports = {}`
},
{ server: ['index.js', 'router'] }
)
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
assert.strictEqual(checkBundle(root).missing.length, 0)
} finally {
cleanup(root)
}
})
test('follows requires transitively, not just one hop', () => {
const root = fixture(
{
'index.js': `require('./a')`,
'a.js': `require('./b')`,
'b.js': `require('./deep/c')`,
'deep/c.js': `module.exports = {}`
},
{ server: ['index.js', 'a.js', 'b.js'] } // `deep` missing
)
try {
const { uncovered } = checkDeclaration(root)
assert.ok(uncovered.has('server/deep'))
} finally {
cleanup(root)
}
})
// ── Resolution has to match Node's, or it invents failures ─────────────────
test('resolves a directory to its index.js', () => {
const root = fixture({ 'index.js': `require('./boot')`, 'boot/index.js': `module.exports = {}` },
{ server: ['index.js', 'boot'] })
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('resolves a .json dependency, and does not try to parse it for requires', () => {
const root = fixture({ 'index.js': `require('./data/atlas.json')`, 'data/atlas.json': `{"a":1}` },
{ server: ['index.js', 'data'] })
try {
const { uncovered, missing } = checkDeclaration(root)
assert.strictEqual(missing.length, 0)
assert.strictEqual(uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('survives a require cycle', () => {
const root = fixture({ 'index.js': `require('./a')`, 'a.js': `require('./index')` },
{ server: ['index.js', 'a.js'] })
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('a specifier that resolves to nothing is reported, not thrown', () => {
const root = fixture({ 'index.js': `require('./gone')` })
try {
const { missing } = checkDeclaration(root)
assert.strictEqual(missing.length, 1)
assert.strictEqual(missing[0].specifier, './gone')
} finally {
cleanup(root)
}
})
test('prose describing a require is not a require', () => {
// The failure mode checkImports.js hit the first time it ran: index.js's own
// header explains why it must never require express, and comments in this
// repo name module paths constantly.
const root = fixture(
{ 'index.js': `// this file used to require('./commands/gone')\nmodule.exports = 1` },
{ server: ['index.js'] }
)
try {
assert.strictEqual(checkDeclaration(root).missing.length, 0)
} finally {
cleanup(root)
}
})
test('node_modules inside a bundle is npm\'s business, not this check\'s', () => {
const root = fixture({
'index.js': `module.exports = 1`,
'node_modules/ws/index.js': `require('./lib/that-npm-owns')`
})
try {
assert.strictEqual(checkBundle(root).missing.length, 0)
} finally {
cleanup(root)
}
})
// ── And the real repo, which is the check that actually gates a release ────
test('the real ci/bundle.json covers everything the real entry point reaches', () => {
const { uncovered, missing, reached } = checkDeclaration()
assert.deepStrictEqual([...uncovered.keys()], [])
assert.deepStrictEqual(missing, [])
assert.ok(reached > 1, 'the walk should reach more than the entry point itself')
})
test('every path ci/bundle.json declares exists', () => {
// A list naming a path that has moved packs nothing and says nothing — `cp`
// in the release would fail, but only after the tag had been pushed.
for (const p of declaredServerPaths()) {
assert.ok(fs.existsSync(p), `ci/bundle.json names ${p}, which does not exist`)
}
})
test('the entry point is reachable from the declared list', () => {
const entry = path.resolve(__dirname, '..', 'index.js')
assert.ok(reachable(entry).files.includes(entry))
assert.ok(resolveFile(path.dirname(entry), './core'))
})

View File

@@ -85,6 +85,35 @@ test('every registered stream is namespaced or grandfathered', () => {
}
})
test('registers a Team provider with all three methods', () => {
// Core requires all three: a provider that could list Teams but not their
// members would leave core holding Teams it can never populate, which is not
// the same as a call that fails. Asserted here so a refactor that drops one
// fails in this suite rather than at load on an operator's install.
const api = fakeApi()
register(fakeCtx(), api)
const provider = api.record.teamProvider
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
}
})
test('registration does not call the provider, or touch the database', async () => {
// register() runs while core's app.js is still being required, with the pool
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
// Registration is a CLAIM; core does not ask anything until it reconciles,
// which is after onBoot.
const ctx = fakeCtx()
let queried = false
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
const api = fakeApi()
register(frozen, api)
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
})
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))

View File

@@ -0,0 +1,170 @@
// The frozen manifest's derivation, checked.
//
// `scripts/frozenManifest.js` runs in one place — a CI job with a whole core
// checked out beside it — so it is the least-exercised piece of machinery in this
// repo, and it is the piece that decides whether the URLs this module claims are
// the URLs it serves (MODULE_API.md §5.3). Its three answers are pure functions of
// two manifests and a fragment, so all three are asked here, with fixtures rather
// than a clone.
//
// What is deliberately NOT asserted here: the numbers. `routes.manifest.json`'s
// 72 routes are proved by the job that generates them from a real core, and a
// copy of that count in this file would only ever be a second thing to update.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const { diffManifests, coverage, MANIFEST, FRAGMENT } = require('../scripts/frozenManifest')
const manifest = (public_ = [], internal = []) => ({ public: public_, internal })
const get = (p) => ({ method: 'GET', path: p })
test('the module\'s routes are the ones a core gains by loading it', () => {
const before = manifest([get('/api/v1/public/settings')])
const after = manifest([get('/api/v1/public/settings'), get('/api/v1/public/shard/status')])
const { added, removed } = diffManifests(before, after)
assert.deepStrictEqual(removed, [])
assert.deepStrictEqual(added, [{ method: 'GET', path: '/api/v1/public/shard/status', tier: 'public' }])
})
test('a route core loses to the module is reported, not quietly absorbed', () => {
// The failure this exists for: a module whose mount displaces a core route.
// It cannot show up as an addition — the URL is unchanged — so a check that
// only looked at what appeared would call this clean.
const before = manifest([get('/api/v1/public/settings'), get('/api/v1/public/status')])
const after = manifest([get('/api/v1/public/settings')])
const { removed } = diffManifests(before, after)
assert.deepStrictEqual(removed, ['public GET /api/v1/public/status'])
})
test('a route whose METHOD changed counts as removed and added', () => {
const { added, removed } = diffManifests(
manifest([{ method: 'POST', path: '/api/v1/admin/thing' }]),
manifest([{ method: 'PUT', path: '/api/v1/admin/thing' }]),
)
assert.deepStrictEqual(removed, ['public POST /api/v1/admin/thing'])
assert.strictEqual(added.length, 1)
})
test('the internal app is diffed too, and keeps its own tier', () => {
const { added } = diffManifests(
manifest([], [get('/internal/health')]),
manifest([], [get('/internal/health'), get('/internal/uo/thing')]),
)
assert.deepStrictEqual(added, [{ method: 'GET', path: '/internal/uo/thing', tier: 'internal' }])
})
test('added routes are sorted, so the committed file does not churn on traversal order', () => {
const { added } = diffManifests(
manifest([]),
manifest([get('/b'), get('/a'), { method: 'POST', path: '/a' }]),
)
assert.deepStrictEqual(added.map((r) => `${r.method} ${r.path}`), ['GET /a', 'GET /b', 'POST /a'])
})
// ── coverage: the route ⇄ fragment agreement ────────────────────────────────
const fragment = (paths) => ({ paths })
test('a served route with no documented operation is named', () => {
const { undocumented, unserved } = coverage(
[get('/api/v1/public/shard/status')],
fragment({}),
)
assert.deepStrictEqual(undocumented, ['GET /api/v1/public/shard/status'])
assert.deepStrictEqual(unserved, [])
})
test('a documented operation nobody serves is named too', () => {
// The direction core's own spec has no check for, which is how it accumulated
// four orphan tags and thirty-three orphan schemas describing routes that had
// moved to this repo. A documented URL nobody serves is a client following the
// docs into a 404.
const { undocumented, unserved } = coverage(
[],
fragment({ '/api/v1/public/shard/gone': { get: {} } }),
)
assert.deepStrictEqual(undocumented, [])
assert.deepStrictEqual(unserved, ['GET /api/v1/public/shard/gone'])
})
test('express :params and OpenAPI {params} are the same route', () => {
const { undocumented, unserved } = coverage(
[{ method: 'DELETE', path: '/api/v1/admin/users/:id/shard/link/:account' }],
fragment({ '/api/v1/admin/users/{id}/shard/link/{account}': { delete: {} } }),
)
assert.deepStrictEqual(undocumented, [])
assert.deepStrictEqual(unserved, [])
})
test('methods are matched, not just paths', () => {
const { undocumented, unserved } = coverage(
[{ method: 'POST', path: '/api/v1/admin/shard/kick' }],
fragment({ '/api/v1/admin/shard/kick': { get: {} } }),
)
assert.deepStrictEqual(undocumented, ['POST /api/v1/admin/shard/kick'])
assert.deepStrictEqual(unserved, ['GET /api/v1/admin/shard/kick'])
})
// ── the committed artifacts, against each other ─────────────────────────────
//
// These two files are generated together by a job that has a real core; here
// there is no core, so what can still be asked is whether they agree with each
// other. If they do not, one of them was committed without the other.
test('every route in the committed manifest has a committed operation', () => {
const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
const { undocumented, unserved } = coverage(routes, spec)
assert.deepStrictEqual(undocumented, [], 'routes.manifest.json lists routes swagger-fragment.json does not document')
assert.deepStrictEqual(unserved, [], 'swagger-fragment.json documents operations routes.manifest.json does not list')
})
test('the fragment carries only the three sections §6.1a allows', () => {
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
assert.deepStrictEqual(Object.keys(spec).sort(), ['components', 'paths', 'tags'])
assert.deepStrictEqual(Object.keys(spec.components), ['schemas'])
})
test('the fragment defines only namespaced schemas, and redefines none of core\'s', () => {
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
for (const name of Object.keys(spec.components.schemas)) {
assert.match(name, /^Uo[A-Z]/, `${name} is not namespaced — core wins the collision and drops it (§6.1a)`)
}
// Core's shared schemas are REFERENCED by their core names and not redefined;
// they resolve in the merged document, which is the whole point of a fragment.
const refs = JSON.stringify(spec.paths).match(/#\/components\/schemas\/([A-Za-z0-9_]+)/g) || []
const core = [...new Set(refs.map((r) => r.split('/').pop()))].filter((n) => !n.startsWith('Uo'))
assert.deepStrictEqual(core.sort(), ['Error', 'ValidationError'])
})
test('every path in the fragment is fully qualified', () => {
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
for (const p of Object.keys(spec.paths)) {
// §6.1a: core merges the fragment verbatim and never re-derives a prefix, so
// a router-relative path here is a path nothing serves.
assert.match(p, /^\/api\/v1\/(public|admin|player)\//, `${p} is not a fully-qualified URL`)
assert.doesNotMatch(p, /\/$/, `${p} has a trailing slash — no client calls that URL`)
}
})
test('the manifest and the module\'s declared mounts agree', () => {
const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes
const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
const declared = []
for (const [tier, prefixes] of Object.entries(mounts)) {
for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`)
}
// The extension slot is core's resource, not one of our mounts (§2.4).
const slot = '/api/v1/admin/users/'
for (const route of routes) {
const under = declared.some((d) => route.path.startsWith(d)) || route.path.startsWith(slot)
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
}
})

View File

@@ -0,0 +1,160 @@
// ── Game-account signup: the policy, and the crash it was hiding ───────────
//
// New in slice 3 of the Phase 3 extraction. `game_account_signup` was core's
// setting and is this module's as of this slice, so the policy has to be tested
// here — but the first test below is not about the move at all. It is about a
// defect slice 1 shipped and no test in either repo could see.
//
// The ported controller called `settings.isGameAccountSignupEnabled()`, which is
// a member of core's settings MODEL and not of `ctx.settings` — three functions,
// deliberately (MODULE_API.md §2.3). So the call was `undefined(...)`, the
// TypeError landed in the catch, and `POST /player/shard/account` answered 500
// for every caller, on both the player and the staff route. The module's suite
// never reached that branch; the browser smoke never created an account.
//
// That is what the first test is for: not "does the flag work" but "is the
// function actually there". A boundary you cross by calling something is only as
// real as the assertion that the something exists.
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const { ctx } = require('./_setup')
const gameSignup = require('../utils/gameSignup')
const playerShard = require('../router/player/shard.controller')
const publicShard = require('../router/public/shard.controller')
const uoLinkClient = require('../utils/uoLinkClient')
const visibility = require('../utils/shardVisibility')
const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
const originalGet = ctx.settings.get
const originalSet = ctx.settings.set
const originalCreate = uoLinkClient.createAccount
const originalListAll = visibilityModel.listAll
const originalViewerLevel = visibility.viewerLevel
afterEach(() => {
ctx.settings.get = originalGet
ctx.settings.set = originalSet
uoLinkClient.createAccount = originalCreate
visibilityModel.listAll = originalListAll
visibility.viewerLevel = originalViewerLevel
})
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
}
}
const asPlayer = (body) => ({ body, user: { id: 7, username: 'kelmo', role: 'player' }, ip: '203.0.113.9' })
// ── The regression ─────────────────────────────────────────────────────────
test('creating a game account does not 500 when the site permits it', async () => {
// The shape of the slice-1 bug: this route answered 500 for everyone because
// the gate it called did not exist. Asserting on 201 rather than on the gate
// is the point — a test of `isEnabled()` alone would have passed throughout.
ctx.settings.get = async () => 'hybrid'
uoLinkClient.createAccount = async () => ({ ok: true })
const res = mockRes()
await playerShard.createGameAccount(asPlayer({ account: 'kelmo', password: 'hunter2hunter2' }), res)
assert.equal(res.statusCode, 201)
assert.deepEqual(res.body, { account: 'kelmo', linked: true })
})
test('the gate the controller calls is a function that exists', () => {
// The assertion the module was missing. `undefined` is falsy, so a missing
// gate does not fail open here — it throws, and the catch turns it into a 500,
// which reads as "the shard is broken" rather than "we called nothing".
assert.equal(typeof gameSignup.isEnabled, 'function')
})
// ── The policy ─────────────────────────────────────────────────────────────
test('only website and hybrid offer signup; everything else is disabled', async () => {
const answers = {}
for (const mode of [...gameSignup.MODES, 'nonsense', null]) {
ctx.settings.get = async () => mode
answers[String(mode)] = await gameSignup.isEnabled()
}
assert.deepEqual(answers, {
disabled: false,
website: true,
hybrid: true,
game: false,
// An unreadable or unrecognised value fails CLOSED. Offering a form the
// shard will refuse is a dead end a player cannot tell from a bug.
nonsense: false,
null: false,
})
})
test('signup is refused with 403, not 500, when the site does not offer it', async () => {
ctx.settings.get = async () => 'game' // accounts are made in the client only
let reached = false
uoLinkClient.createAccount = async () => { reached = true; return { ok: true } }
const res = mockRes()
await playerShard.createGameAccount(asPlayer({ account: 'kelmo', password: 'hunter2hunter2' }), res)
assert.equal(res.statusCode, 403)
assert.equal(reached, false, 'the shard must not be called when the site refuses')
})
test('setMode refuses a mode that is not one of the four', async () => {
let written = null
ctx.settings.set = async (key, value) => { written = { key, value } }
await gameSignup.setMode('hybrid', 3)
assert.deepEqual(written, { key: 'game_account_signup', value: 'hybrid' })
await assert.rejects(() => gameSignup.setMode('everyone', 3), /unknown game-signup mode/)
assert.deepEqual(written, { key: 'game_account_signup', value: 'hybrid' }, 'nothing was written')
})
test('the setting key is unchanged, so an existing instance keeps its mode', () => {
// Not a style assertion. Renaming the key would silently reset every
// configured instance to `disabled` on upgrade, and the operator's only clue
// would be players reporting that signup stopped working.
assert.equal(gameSignup.KEY, 'game_account_signup')
})
// ── The client's view of it ────────────────────────────────────────────────
test('public features carries gameAccountSignup, and it is not audience-gated', async () => {
// The portal and the invite step both read this. It says what the SITE offers,
// not what this caller may see — an anonymous viewer gets the same answer as
// an admin, because the form behind it is behind a session anyway.
visibilityModel.listAll = async () => []
ctx.settings.get = async () => 'website'
const answers = []
for (const level of ['anonymous', 'admin']) {
visibility.viewerLevel = async () => level
const res = mockRes()
await publicShard.getFeatures({}, res)
answers.push(res.body.gameAccountSignup)
}
assert.deepEqual(answers, [true, true])
})
test('a features read still answers when the signup setting cannot be read', async () => {
// Nav gating is the endpoint's main job and it must not be taken down by the
// one boolean bolted onto it. `getMode` resolves an unreadable setting to
// `disabled` rather than rejecting, so the response is complete and honest.
visibilityModel.listAll = async () => []
visibility.viewerLevel = async () => 'anonymous'
ctx.settings.get = async () => { throw new Error('settings table is on fire') }
const res = mockRes()
await publicShard.getFeatures({}, res)
assert.equal(res.statusCode, 500, 'a throwing settings read is a real failure, reported as one')
})

View File

@@ -0,0 +1,148 @@
// `/guild` — the chat command registered through `api.registerSlashCommands`
// (TEAMS.md §7.1, MODULE_API 1.6.0).
//
// The properties worth pinning are all about the ANSWER being the same answer
// the website gives, because that is the whole risk of a second surface: the
// audience rungs are re-resolved here rather than assumed, the shard's own
// offline guard is honoured, and the link prompt appears only when linking would
// actually change what the caller is told.
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const command = require('../commands/guild.command')
const db = require('../model/teamProvider/teamProvider.db')
const provider = require('../model/teamProvider/teamProvider.model')
const visibility = require('../utils/shardVisibility')
const originals = {
getConfig: visibility.getConfig,
viewerLevel: visibility.viewerLevel,
boardIsCurrent: provider.boardIsCurrent,
listGuilds: db.listGuilds,
listGuildMembers: db.listGuildMembers,
}
afterEach(() => {
visibility.getConfig = originals.getConfig
visibility.viewerLevel = originals.viewerLevel
provider.boardIsCurrent = originals.boardIsCurrent
db.listGuilds = originals.listGuilds
db.listGuildMembers = originals.listGuildMembers
})
const GUILDS = [
{ id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' },
{ id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null },
]
const MEMBERS = [
{ serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null },
{ serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 },
{ serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null },
]
function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) {
visibility.getConfig = async () => ({ guilds: { enabled, audience } })
visibility.viewerLevel = async () => level
provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' })
db.listGuilds = async () => GUILDS
db.listGuildMembers = async () => MEMBERS
}
const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false }
const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false }
test('the definition stays inside the option schema §7.1.1 allows', () => {
assert.equal(command.name, 'guild')
assert.equal(command.access, 'everyone')
for (const option of command.options) {
assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type))
assert.ok(option.description.length <= 100)
}
})
test('the guilds feature being off withholds everything, staff included', async () => {
stub({ enabled: false, level: 'admin' })
const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } })
assert.match(res.text, /does not publish guild information/)
assert.equal(res.ephemeral, true)
})
// The reason this command is not a thin wrapper over a public route: a rung
// below the feature's audience must be refused HERE, or a shard that gates
// guilds to staff would publish them to a Discord channel.
test('a caller below the feature audience is refused', async () => {
stub({ audience: 'staff', level: 'anonymous' })
const res = await command.handler({ options: {}, actor: anonymous })
assert.match(res.text, /not shown to your account/)
assert.equal(res.ephemeral, true)
})
test('an unlinked caller is invited to link — but only when linking would change the answer', async () => {
stub({ audience: 'player', level: 'anonymous' })
const gated = await command.handler({ options: {}, actor: anonymous })
assert.match(gated.notice, /Link your account/)
// Public guilds: there is nothing more to see, so there is nothing to prompt.
stub({ audience: 'anonymous', level: 'anonymous' })
const open = await command.handler({ options: {}, actor: anonymous })
assert.equal(open.notice, null)
// Gated to staff: linking reaches `player` and stops there, so the invitation
// would be an instruction to do something that changes nothing. Found on the
// live rig, where a staff-gated shard still offered it.
stub({ audience: 'staff', level: 'anonymous' })
const unreachable = await command.handler({ options: {}, actor: anonymous })
assert.match(unreachable.text, /not shown to your account/)
assert.equal(unreachable.notice, null)
})
test('a stale board answers offline rather than reporting what it still holds', async () => {
stub({ current: false })
const res = await command.handler({ options: {}, actor: anonymous })
assert.match(res.text, /not connected right now/)
})
test('no argument lists the largest guilds', async () => {
stub()
const res = await command.handler({ options: {}, actor: anonymous })
assert.equal(res.title, 'Guilds on this shard')
assert.equal(res.fields.length, 2)
assert.match(res.fields[0].name, /Knights of the Codex/)
assert.match(res.fields[0].value, /12 members · 3 online/)
})
test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => {
stub()
const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous })
assert.match(byAbbr.title, /Knights of the Codex/)
const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous })
assert.match(exact.title, /Hospitaller/)
// "knights" hits both, and answering with either would be worse than asking.
const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous })
assert.match(ambiguous.text, /Several guilds match/)
assert.equal(ambiguous.ephemeral, true)
})
test('a miss is an answer, not a failure', async () => {
stub()
const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous })
assert.match(res.text, /No guild matches/)
})
// `linked` counts BOTH sources the roster uses — the shard's asserted web id and
// the link table — because that is what "linked" means everywhere else here.
test('the detail carries the counts, the leaders and a link to the module page', async () => {
stub({ level: 'player' })
const res = await command.handler({ options: { name: 'KOC' }, actor: linked })
const field = (name) => res.fields.find((f) => f.name === name).value
assert.equal(field('Members'), '12')
assert.equal(field('Online'), '3')
assert.equal(field('Linked accounts'), '2')
assert.equal(field('Leaders'), 'Dain, Elowen')
assert.match(res.url, /\/uo\/guilds\/7$/)
assert.equal(res.notice, null)
})

View File

@@ -0,0 +1,157 @@
// `schema.sql` is replayed by core on EVERY boot, and core validates it before
// anything mounts. The rules are core's (MODULE_API.md §2.6, loader.js), and are
// restated here for the same reason `manifest.test.js` restates the manifest
// rules: a mistake should fail in this repo's CI, which can say what is wrong,
// rather than on an install, where the symptom is a module that is simply absent.
//
// The test this file exists for is the ORDER one. Two statements that read each
// other were adjacent in core's schema.sql until slice 1 moved one of them here
// and left the other behind — and because core's schema is replayed in full
// before any module fragment, the marker was written before the migration that
// reads it and the one-shot could never fire. Nothing caught it: both files were
// individually valid SQL, both replayed cleanly, and the failure only shows on an
// upgraded install talking to a real sidecar. An assertion about order is the
// only thing that would have.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const SCHEMA = path.join(__dirname, '..', 'db', 'schema.sql')
const sql = fs.readFileSync(SCHEMA, 'utf8')
/**
* Split into statements the way core's `utils/sqlStatements.js` does: a
* character walk, not a regexp.
*
* Comments are stripped before quotes are considered, because a comment may
* contain quotes — line 508 of this very file is `-- '' when randomised per
* activation`, and a stripper that opened a string there would swallow the rest
* of the file. The reverse case (a `--` inside a string literal) is handled by
* the same walk, since a quote opened outside a comment stays open.
*/
function splitStatements(text) {
const out = []
let buf = ''
let quote = null
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (quote) {
buf += c
if (c === '\\') {
buf += text[++i] ?? ''
} else if (c === quote) {
quote = null
}
continue
}
if (c === '-' && text[i + 1] === '-') {
while (i < text.length && text[i] !== '\n') i++
buf += '\n'
continue
}
if (c === "'" || c === '"' || c === '`') {
quote = c
buf += c
continue
}
if (c === ';') {
if (buf.trim()) out.push(buf.trim())
buf = ''
continue
}
buf += c
}
if (buf.trim()) out.push(buf.trim())
return out
}
const statements = splitStatements(sql)
// Core's leading-verb allowlist. Not a DROP denylist: this file replays on every
// boot, so a TRUNCATE or DELETE would empty a table at each restart.
const ALLOWED = ['CREATE', 'ALTER', 'INSERT', 'UPDATE']
test('the splitter survives a comment that contains quotes', () => {
const parts = splitStatements("SELECT 1; -- '' a quote in a comment\nSELECT 2;")
assert.deepEqual(
parts.map((s) => s.trim().split('\n')[0].trim()),
['SELECT 1', 'SELECT 2'],
)
})
test('the splitter does not treat a -- inside a string as a comment', () => {
const parts = splitStatements("INSERT INTO t VALUES ('a--b');")
assert.equal(parts.length, 1)
assert.match(parts[0], /'a--b'/)
})
test('every statement leads with a verb core allows', () => {
for (const statement of statements) {
const verb = statement.trim().split(/\s+/)[0].toUpperCase()
assert.ok(ALLOWED.includes(verb), `statement leads with "${verb}": ${statement.slice(0, 70)}`)
}
})
test('every CREATE TABLE is IF NOT EXISTS', () => {
for (const statement of statements) {
if (!/^CREATE\s+TABLE/i.test(statement)) continue
assert.match(statement, /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS/i, statement.slice(0, 70))
}
})
test('every table this fragment declares is prefixed shard_ or uo_link_', () => {
// The two prefixes core grandfathers to this module by name
// (loader.js LEGACY_TABLE_PREFIXES). A module written after this one prefixes
// with its own id instead.
const CREATE_TABLE = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?([A-Za-z0-9_]+)`?/gi
const tables = []
for (const statement of statements) {
for (const m of statement.matchAll(CREATE_TABLE)) tables.push(m[1].toLowerCase())
}
assert.ok(tables.length > 20, `expected the module's tables, found ${tables.length}`)
for (const table of tables) {
assert.ok(
table.startsWith('shard_') || table.startsWith('uo_link_'),
`table "${table}" carries neither grandfathered prefix`,
)
}
})
// ── The settings rows this module owns ──────────────────────────────────────
const SETTINGS_KEYS = ['game_account_signup', 'uo_link_protocol_3_migrated']
test('both settings seeds are INSERT IGNORE, so a replay never resets a value', () => {
for (const key of SETTINGS_KEYS) {
const seed = statements.find((s) => /^INSERT/i.test(s) && s.includes(`'${key}'`))
assert.ok(seed, `no seed for "${key}"`)
assert.match(seed, /^INSERT\s+IGNORE\s+INTO\s+settings/i, key)
}
})
test('the protocol-3 marker is written AFTER the update that reads it', () => {
const update = statements.findIndex(
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_3_migrated'),
)
const marker = statements.findIndex(
(s) => /^INSERT/i.test(s) && s.includes("'uo_link_protocol_3_migrated'"),
)
assert.ok(update >= 0, 'the protocol-3 migration is gone')
assert.ok(marker >= 0, 'the one-shot marker is gone')
assert.ok(
marker > update,
'the marker is written before the UPDATE reads it — the one-shot can never fire. ' +
'This is the shape of the defect slice 1 introduced by leaving the marker in core, ' +
'whose schema replays first.',
)
})
test('the migration is guarded on the marker, not on the column value alone', () => {
const update = statements.find((s) => /^UPDATE\s+uo_link_config/i.test(s))
assert.match(update, /NOT\s+EXISTS\s*\(\s*SELECT/i)
// Without the guard an operator who deliberately pins an older sidecar in
// Admin → Shard is silently re-bumped on the next restart.
assert.match(update, /uo_link_protocol_3_migrated/)
})

View File

@@ -0,0 +1,75 @@
// Protocol 4 membership routing: guild.roster (board state, possibly chunked) and
// guild.leave (a real-time departure, logged like its guild.join counterpart).
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
function makeDeps() {
const calls = { roster: [], memberRemove: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertGuildRoster: async (ev) => { calls.roster.push(ev) },
removeGuildMember: async (ev) => { calls.memberRemove.push(ev) },
upsertGuild: noop, removeGuild: noop,
clearOnline: noop, upsertOnline: noop, setOffline: noop,
addEconomySample: noop,
},
shardLinks: { removeByAccount: noop },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('guild.roster routes to upsertGuildRoster and is NOT logged', async () => {
// It is board state like guild.update, and the one fat frame on the wire —
// logging it would put a full membership snapshot in shard_events on every
// membership change.
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.roster', id: 7, seq: 0, more: false, total: 2,
members: [{ serial: '0x1', name: 'Ada' }, { serial: '0x2', name: 'Bo' }], t: 1 },
deps,
)
assert.equal(deps.calls.roster.length, 1)
assert.equal(deps.calls.roster[0].id, 7)
assert.equal(deps.calls.roster[0].members.length, 2)
assert.equal(r.logged, false)
})
test('every frame of a chunked roster reaches the model, seq intact', async () => {
// The sidecar reassembles for its own board, but the live feed and the /history
// backfill both carry individual frames — so the model must see each one with its
// seq, which is what tells it whether to clear the guild first.
const deps = makeDeps()
for (const [seq, more, serial] of [[0, true, '0x1'], [1, true, '0x2'], [2, false, '0x3']]) {
await shardIngest.ingest(
{ kind: 'guild.roster', id: 7, seq, more, total: 3, members: [{ serial, name: serial }], t: 1 },
deps,
)
}
assert.deepEqual(deps.calls.roster.map((e) => e.seq), [0, 1, 2])
assert.deepEqual(deps.calls.roster.map((e) => e.more), [true, true, false])
})
test('guild.leave is logged and broadcast, like guild.join', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.leave', id: 7, name: 'The Cartographers', who: '0x2', t: 2 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'guild.leave')
assert.equal(deps.calls.broadcast.length, 1)
assert.deepEqual(deps.calls.memberRemove.map((e) => e.who), ['0x2'])
})

View File

@@ -116,6 +116,52 @@ test('acct and webId are stripped below admin regardless of feature config', ()
assert.equal(asAdmin.leader.webId, '42')
})
test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
// A roster is the first frame where the locked fields appear inside an ARRAY of
// actors rather than one nested actor. The walker recurses into arrays, so this
// should already hold — this test is here because it is the difference between a
// public Guilds page listing character names and one publishing 150 account names.
const config = visibility.compileDefaults()
const frame = {
kind: 'guild.roster',
id: 7,
total: 3,
seq: 0,
more: false,
members: [
{ serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
{ serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
{ serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
],
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('guilds', frame, level, config)
assert.equal(out.members.length, 3, `${level} still sees every member`)
assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
for (const m of out.members) {
assert.equal('acct' in m, false, `${level} must not see a member's acct`)
assert.equal('webId' in m, false, `${level} must not see a member's webId`)
}
}
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
assert.equal(asAdmin.members[0].acct, 'ada_acct')
assert.equal(asAdmin.members[0].webId, '11')
})
test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
// Rule 2 fails an unmapped kind closed. That is the right default, but for these
// two it would silently keep the public Guilds page from ever seeing a roster.
const config = visibility.compileDefaults()
for (const kind of ['guild.roster', 'guild.leave']) {
assert.equal(
visibility.kindVisibleTo(kind, 'anonymous', config), true,
`${kind} should reach an anonymous viewer under the default guilds config`,
)
}
})
test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
@@ -308,10 +354,16 @@ const PRE_V3_PUBLIC_KINDS = [
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
// v4 adds guild membership. Both ride the existing `guilds` feature, which is
// already anonymous, so they join the public set — carrying character names and
// serials, never acct/webId, which the locked-field rules strip by suffix even
// inside the roster's member array (see the roster test above).
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
)
})

View File

@@ -0,0 +1,92 @@
// The fragment generator's derivation, checked.
//
// `scripts/swaggerFragment.js` decides the prefix of every path core will publish
// on this module's behalf, and it decides it from `server/index.js`'s own
// registration call rather than from a table. That derivation is what these
// assert. The generator's OUTPUT — whether those prefixes are the URLs a real
// core serves — is `frozenManifest.js`'s question, because answering it needs a
// core; here the question is whether the machinery reads the module correctly.
const test = require('node:test')
const assert = require('node:assert')
const { mountedRouters, prefixPaths, TIER_BASE, SLOT_MOUNT } = require('../scripts/swaggerFragment')
test('every registered mount is discovered, and resolved to a source file', () => {
const mounts = mountedRouters()
// Six: five `registerRoutes` prefixes plus the filled extension slot.
assert.strictEqual(mounts.length, 6)
for (const { file, prefix } of mounts) {
assert.match(file, /server[\\/]router[\\/]/, 'a mounted router resolved to a file outside router/')
assert.match(prefix, /^\/api\/v1\/(public|admin|player)\//)
}
})
test('the prefixes come from the registration, not from a list here', () => {
// Change `registerRoutes` in server/index.js and this list changes with it —
// which is the property being asserted. The manifest declares the same five
// prefixes and the loader rejects a mismatch between the two, so this is the
// third and last place they could disagree.
const byPrefix = mountedRouters().map((m) => m.prefix).sort()
assert.deepStrictEqual(byPrefix, [
'/api/v1/admin/shard',
'/api/v1/admin/uo-link',
'/api/v1/admin/users/:id',
'/api/v1/player/shard',
'/api/v1/public/atlas',
'/api/v1/public/shard',
])
})
test('the tier bases and the slot mount are §2.4\'s, spelled as core mounts them', () => {
assert.deepStrictEqual(TIER_BASE, {
public: '/api/v1/public',
admin: '/api/v1/admin',
player: '/api/v1/player',
})
assert.deepStrictEqual(SLOT_MOUNT, { 'admin.users.detail': '/api/v1/admin/users/:id' })
})
test('re-rooting converts express params to OpenAPI\'s', () => {
const paths = prefixPaths({ paths: { '/shard/link': { get: {} } } }, '/api/v1/admin/users/:id')
assert.deepStrictEqual(Object.keys(paths), ['/api/v1/admin/users/{id}/shard/link'])
})
test('re-rooting drops the trailing slash a collection route would produce', () => {
// `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`, a
// URL no client calls and the manifest does not record.
const paths = prefixPaths({ paths: { '/': { get: {} } } }, '/api/v1/public/shard')
assert.deepStrictEqual(Object.keys(paths), ['/api/v1/public/shard'])
})
test('the prefix\'s own parameters are ordered ahead of the route\'s', () => {
// swagger-autogen orders parameters by where they appeared in the path it saw,
// and it only ever saw the tail — so without this every slot route would churn
// the committed fragment by a reorder that means nothing.
const paths = prefixPaths(
{
paths: {
'/shard/link/{account}': {
delete: { parameters: [{ name: 'account', in: 'path' }, { name: 'id', in: 'path' }] },
},
},
},
'/api/v1/admin/users/:id',
)
const params = paths['/api/v1/admin/users/{id}/shard/link/{account}'].delete.parameters
assert.deepStrictEqual(params.map((p) => p.name), ['id', 'account'])
})
test('a parameter the prefix does not name keeps its position', () => {
const paths = prefixPaths(
{
paths: {
'/thing': { get: { parameters: [{ name: 'limit' }, { name: 'offset' }, { name: 'id' }] } },
},
},
'/api/v1/admin/users/:id',
)
const params = paths['/api/v1/admin/users/{id}/thing'].get.parameters
assert.deepStrictEqual(params.map((p) => p.name), ['id', 'limit', 'offset'])
})

View File

@@ -0,0 +1,406 @@
// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0).
//
// The tests that matter here are the REFUSALS. Core's contract is that module
// unavailability becomes staleness and never emptiness, and this module is the
// only thing that can honour it — an empty array from here is read as an
// authoritative "there are none", and core makes destructive decisions from an
// authoritative answer. Every state where this module cannot honestly claim to
// know is asserted below, because each one is a plausible place for someone to
// later "simplify" the guard away and get a plausible-looking empty list.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const core = require('../core')
// The provider reaches the database through core, which is initialised with a ctx
// in production. A minimal one is enough here — the db layer is stubbed anyway.
core.init({
db: { query: async () => [] },
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
moduleId: 'uo',
})
const db = require('../model/teamProvider/teamProvider.db')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../utils/uoLinkSocket')
const clilocs = require('../model/shardClilocs/shardClilocs.model')
const visibility = require('../utils/shardVisibility')
const provider = require('../model/teamProvider/teamProvider.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
// The healthy default: configured, enabled, connected. Each test then breaks only
// the thing it is about.
function healthy() {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
// An operator who has never run the client extraction — the default. The standard
// rank names must still resolve from the fallback table.
patch(clilocs, 'resolveMany', async () => new Map())
patch(db, 'listGuildLeaders', async () => [])
}
const guild = (extra = {}) => ({
id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null,
members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra,
})
const member = (extra = {}) => ({
serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
rank: 1, rank_cliloc: 1062962, rank_name: null,
linked_user_id: null, is_online: 0, ...extra,
})
beforeEach(healthy)
afterEach(() => {
while (saved.length) {
const [mod, name, fn] = saved.pop()
mod[name] = fn
}
})
// ── The refusals ───────────────────────────────────────────────────────────
test('no uo-link configured refuses, on all three methods', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false }))
patch(db, 'listGuilds', async () => { throw new Error('must not be read') })
for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) {
assert.equal(answer.ok, false)
assert.match(answer.reason, /no uo-link configured/)
assert.equal(answer.teams, undefined)
assert.equal(answer.members, undefined)
}
})
test('a disabled integration refuses rather than reporting a frozen board', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false }))
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /disabled/)
})
test('a disconnected socket refuses, even though the board is still there', async () => {
// The tempting mistake, stated as a test: the board is durable and survives an
// outage, so serving it looks harmless. Core cannot tell a board five minutes
// stale from one five days stale, and it archives Teams and departs members
// from a complete answer.
patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true }))
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /not connected/)
assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative')
})
test('a database error refuses instead of throwing at core', async () => {
patch(db, 'listGuilds', async () => { throw new Error('table gone') })
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /table gone/)
})
test('a guild absent from the board refuses rather than reporting an empty roster', async () => {
patch(db, 'findGuild', async () => [])
const members = await provider.getTeamMembers('99')
assert.equal(members.ok, false)
assert.match(members.reason, /not on the board/)
const leaders = await provider.getTeamLeaders('99')
assert.equal(leaders.ok, false)
})
test('a roster that has not arrived yet refuses — the board count is what tells us', async () => {
// Protocol 4's roster arrives on its own frames, separately from the
// guild.update that creates the board row, so there is a real window where a
// 155-member guild has no roster rows. Reporting that as an empty roster would
// depart every member.
patch(db, 'findGuild', async () => [guild({ members: 155 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, false)
assert.match(answer.reason, /has not arrived yet/)
assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence')
})
test('a guild the board says is genuinely empty reports an empty roster', async () => {
// The other side of the same coin: when the board itself says zero, an empty
// roster is the truth and withholding it would freeze a disbanding guild's
// membership forever.
patch(db, 'findGuild', async () => [guild({ members: 0 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
// ── The good answers ───────────────────────────────────────────────────────
test('a guild becomes a Team keyed on its persistent ServUO id', async () => {
// The id survives a rename, which is what lets core apply its rename rule
// instead of seeing an unrelated new guild.
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.equal(answer.complete, true)
assert.deepEqual(answer.teams, [
{ externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null },
])
})
test('an alliance rides along as opaque meta', async () => {
patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })])
const { teams } = await provider.getTeams()
assert.deepEqual(teams[0].meta, { alliance: 'The Concord' })
})
test('the external id is a string, so core never compares a number to one', async () => {
patch(db, 'listGuilds', async () => [guild({ id: 42 })])
const { teams } = await provider.getTeams()
assert.equal(teams[0].externalId, '42')
})
test('a roster maps to the member shape core expects', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', name: 'Aldric', rank: 4, rank_cliloc: 1062959, is_online: 1 }),
member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, is_online: 0 }),
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members.length, 2)
assert.equal(members[0].memberKey, '0x1')
assert.equal(members[0].displayName, 'Aldric')
assert.equal(members[0].online, true)
assert.equal(members[0].leader, true, 'rank 4 is Leader')
assert.equal(members[1].leader, false)
assert.equal(members[1].online, false)
})
// ── Rank (the Protocol 4 amendment) ────────────────────────────────────────
test('several members can be leaders at once', async () => {
// The whole reason the wire grew a per-member rank: the board carries one
// leader_serial, so before this only a single leader could ever be reported.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 4 }),
member({ serial: '0x2', rank: 4 }),
member({ serial: '0x3', rank: 3 }),
])
const { members } = await provider.getTeamMembers('1')
assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2'])
})
test('getTeamLeaders returns everyone at rank 4, not just the board’s one', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2'])
})
test('the board’s leader is kept even when no roster row has rank yet', async () => {
// A shard whose roster has not been re-emitted since the amendment has no ranks
// stored. The founder-leader comes from a different frame and must not be lost
// by moving to ranks.
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })])
patch(db, 'listGuildLeaders', async () => [])
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9'])
})
test('the board’s leader is not duplicated when they also hold rank 4', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
const { leaders } = await provider.getTeamLeaders('1')
assert.equal(new Set(leaders).size, leaders.length)
})
test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => {
// The shard withholds the rank for a staff account, because ServUO's GuildRank
// getter reports Leader for anyone at GameMaster or above whatever their real
// rank. Reading the absence as leadership would republish exactly that lie.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].leader, false)
assert.equal(members[0].rankLabel, null)
})
test('a standard rank resolves to its name without a cliloc table', async () => {
// The operator may never have run the client extraction, and a roster should
// still read "Warlord" rather than nothing.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }),
member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }),
member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }),
])
const { members } = await provider.getTeamMembers('1')
assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin'])
})
test('the operator’s cliloc table wins over the built-in names', async () => {
// A localised or edited client should name the ranks, not this module's English
// fallback.
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']]))
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr')
})
test('a custom rank’s literal name beats both', async () => {
// A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc,
// and its own naming has to survive.
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']]))
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }),
])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain')
})
test('a failing cliloc lookup falls back rather than failing the roster', async () => {
patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') })
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster')
assert.equal(answer.members[0].rankLabel, 'Warlord')
})
test('an unknown cliloc leaves the label null rather than inventing one', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, null)
})
test('a member with no account at all is fine and unlinked', async () => {
// §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
// have no Account, and the local test world contains such mobiles.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, null)
})
test('userId comes from the roster’s web_id first, then the link table', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins
member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback
member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time')
assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link')
assert.equal(members[2].userId, null)
})
test('web_id arrives as a string from the wire and is coerced', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 42)
assert.equal(typeof members[0].userId, 'number')
})
test('a guild with no leader anywhere reports none rather than guessing', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
patch(db, 'listGuildLeaders', async () => [])
const answer = await provider.getTeamLeaders('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.leaders, [])
})
test('an empty board is an authoritative empty list — the shard really has no guilds', async () => {
// Distinct from every refusal above: the socket is connected and the board is
// readable, so "no guilds" is a fact. Core still quarantines it before acting.
patch(db, 'listGuilds', async () => [])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.deepEqual(answer.teams, [])
})
// ── projectRoster (TEAMS.md §3.3) ──────────────────────────────────────────
//
// The refusal semantics INVERT here and that is the point of these tests. For
// the three methods above, a refusal means "change nothing" and an empty array
// would be destructive. For this one, core fails CLOSED — a refusal withholds the
// roster — so the dangerous answer is the opposite: returning every key because
// the config could not be read would publish a roster an operator gated to staff.
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
function guilds(feature) {
patch(visibility, 'getConfig', async () => ({ guilds: feature }))
}
test('a viewer at or above the audience sees every row', async () => {
guilds({ enabled: true, audience: 'anonymous' })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, ['0x1', '0x2'])
})
test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => {
// `ok: true` with an empty list is the correct answer here: this module KNOWS
// the viewer may see nothing. Core renders an empty roster rather than an
// error, which is what a gated shard is supposed to look like.
guilds({ enabled: true, audience: 'staff' })
const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' })
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
test('an admin clears every audience', async () => {
guilds({ enabled: true, audience: 'admin' })
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
assert.deepEqual(answer.members, ['0x1', '0x2'])
})
test('a disabled guilds feature hides the roster from everyone, staff included', async () => {
// The switch means "this shard does not publish guild data", not "publish it
// quietly to staff".
guilds({ enabled: false, audience: 'anonymous' })
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
test('an unreadable visibility config REFUSES rather than publishing', async () => {
// The inversion, stated. Core reads this as "withhold", which is the only safe
// reading of "I could not work out who is allowed to look".
patch(visibility, 'getConfig', async () => { throw new Error('pool down') })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, false)
assert.match(answer.reason, /visibility could not be resolved/)
})
test('an absent viewer is anonymous, not an error', async () => {
guilds({ enabled: true, audience: 'logged_in' })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in')
})
test('rows with no member key are dropped rather than answered as blanks', async () => {
guilds({ enabled: true, audience: 'anonymous' })
const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null)
assert.deepEqual(answer.members, ['0x1'])
})

View File

@@ -0,0 +1,63 @@
// ── Whether this site creates game accounts, and in which direction ────────
//
// This policy was core's until slice 3 of the Phase 3 extraction, and it should
// never have been: the setting's own help text names *Bridge.cfg* and says the
// game server's `SignupMode` must agree with it. That is a sentence about a UO
// shard, and core cannot own a sentence about a UO shard.
//
// **The setting key is unchanged.** `game_account_signup` keeps its name and its
// row in core's `settings` table, read and written through `ctx.settings`. The
// key is not prefixed because renaming it would silently reset every existing
// instance's configured mode to the default — the same reasoning that
// grandfathered `spawn_atlas_servuo_path`, `cliloc_client_path` and the seven
// stream ids (MODULE_API.md §6.5). A module owning an unprefixed settings key is
// a grandfathering, not a pattern to copy.
//
// **It was also broken.** Slice 1 ported the call site
// (`router/player/shard.controller.js`) still calling
// `settings.isGameAccountSignupEnabled()`, which `ctx.settings` does not expose —
// it is three functions, not the model. So `POST /player/shard/account` threw a
// TypeError and answered 500 for every caller, and no test saw it because the
// module's suite never reached that branch. This file is where that function now
// lives, on the side that actually uses it.
const { settings } = require('../core')
const KEY = 'game_account_signup'
/**
* The four modes, and what each means.
*
* `website` and `hybrid` are the two that accept a site-created account; `game`
* means accounts are made in the client and only linked here. The shard's own
* `SignupMode` still has the final say when the call is actually made — this is
* the site half of an agreement between two systems, which is exactly why it
* reads as UO policy rather than as site configuration.
*/
const MODES = ['disabled', 'website', 'hybrid', 'game']
const OFFERS_SIGNUP = ['website', 'hybrid']
/** The configured mode, or `disabled` for anything unset or unrecognised. */
async function getMode() {
const value = await settings.get(KEY)
return MODES.includes(value) ? value : 'disabled'
}
/**
* Does this site offer game-account creation right now?
*
* Fails CLOSED on an unreadable setting, because `getMode` resolves an unknown
* value to `disabled`. Offering a form that the shard will refuse is a dead end
* a player cannot distinguish from a bug.
*/
async function isEnabled() {
return OFFERS_SIGNUP.includes(await getMode())
}
/** @throws if `mode` is not one of MODES — the caller validates first. */
async function setMode(mode, updatedBy) {
if (!MODES.includes(mode)) throw new Error(`unknown game-signup mode "${mode}"`)
return settings.set(KEY, mode, updatedBy)
}
module.exports = { KEY, MODES, OFFERS_SIGNUP, getMode, isEnabled, setMode }

View File

@@ -45,6 +45,12 @@ const LOGGED_KINDS = new Set([
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
// Protocol 4: the departure counterpart to guild.join, and logged for the same
// reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately
// stays out: it is board state like guild.update, and it is the one fat frame on
// the wire (~69 bytes per member), so logging it would bloat shard_events with
// a full membership snapshot on every membership change.
'guild.leave',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
@@ -187,6 +193,16 @@ async function applyStateChange(event, deps) {
case 'guild.remove':
await shardState.removeGuild(event.id)
return
// Protocol 4: membership. A roster arrives in one frame for any realistic
// guild and in several for one over the shard's cap — upsertGuildRoster
// handles both. guild.leave is advisory; the next roster would converge
// anyway, but applying it shows the departure at once.
case 'guild.roster':
await shardState.upsertGuildRoster(event)
return
case 'guild.leave':
await shardState.removeGuildMember(event)
return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)

Some files were not shown because too many files have changed in this diff Show More