refactor(client): delete the UO client half (phase 3, slice 3)

35 files and 5,332 lines out — twelve public pages, seven admin views, two
player views, eight components, the two `data/` leaves and the three `lib/`
ones, plus the two tests that came with them. §2.7.1's estimate of 51 files /
~3,700 lines was measured differently and is corrected in the docs PR.

The seams core keeps, each smaller than what it replaced:

Nine rows leave the public header and six leave the admin sidebar, and both
lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already
handled a module page's heading, so the six TITLES entries and the
`/admin/characters` branch of `sectionTitle` simply go.

`/player` had `PlayerCharacters` as its index — a UO page — and rather than name
a replacement or invent a landing screen it now resolves to the first row of the
portal nav this viewer can reach (`firstDestinationFor`, beside
`allowedPathsFor` and reading the BASE nav for the same reason: an override is
presentation and where everybody lands is behaviour). With the module installed
that is still Characters, so a player's first screen after signing in does not
change. Deliberately generic and deliberately not in the portal layout — the
admin index is the same question with a hardcoded answer, and if the two
logged-in areas ever become one this is what serves both.

`game_account_signup` goes with the rest of core's UO prose: the mode list, the
derived public flag, the validation and a Site Settings field whose help text
named Bridge.cfg. The row itself is untouched and module-uo reads it through
ctx.settings — the data stays, the semantics move.

KNOWN BREAK, accepted by the org lead: the shipped Android app reads
`gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a
`= false` default so nothing crashes; the app silently stops offering
game-account creation until it reads the module's `/public/shard/features`
instead. Out of scope here, recorded in the Android plan, and it lands well
before this workstream's cutover reaches `main`.

620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged;
routes.guards unchanged. The OpenAPI spec loses exactly one property, and only
because it was hand-written in swagger.js — regeneration alone would have left
the spec documenting a field core no longer returns.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:39:55 -05:00
parent 5b5006c365
commit f7d27f7a06
50 changed files with 230 additions and 5629 deletions

View File

@@ -1,7 +1,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../src/lib/adminNav.js'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath, firstDestinationFor } from '../src/lib/adminNav.js'
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
@@ -123,3 +123,50 @@ test('a nav that is not there does not throw', () => {
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
assert.equal(isAllowedPath('/admin', undefined), false)
})
// ── firstDestinationFor: where an area's index goes (Phase 3 slice 3) ────────
//
// `/player` used to be `PlayerCharacters`, a UO page. When the client half was
// extracted the portal had no index at all, and rather than pick a fixed page or
// invent a core landing screen, the index resolves to the first row this viewer
// can reach. The interesting properties are that it follows the ROLE and that it
// reads the base nav, not the merged one.
const PORTAL_NAV = [
{ to: '/account/appeals', label: 'Appeals' },
{ to: '/account', label: 'Account', end: true },
]
test('the index is the first row a viewer can actually reach', () => {
assert.equal(firstDestinationFor(PORTAL_NAV, 'player', '/account'), '/account/appeals')
})
test('a module row registered at the front becomes the index', () => {
// The behaviour that makes this a non-regression: with module-uo installed,
// Characters is the first row again and a player still lands on it.
const withModule = [{ to: '/player/uo/characters', label: 'Characters', moduleId: 'uo' }, ...PORTAL_NAV]
assert.equal(firstDestinationFor(withModule, 'player', '/account'), '/player/uo/characters')
})
test('a row this role cannot see is skipped, not landed on', () => {
const gated = [{ to: '/player/uo/staff', label: 'Staff', roles: ['admin'] }, ...PORTAL_NAV]
assert.equal(firstDestinationFor(gated, 'player', '/account'), '/account/appeals')
assert.equal(firstDestinationFor(gated, 'admin', '/account'), '/player/uo/staff')
})
test('an empty or all-gated nav falls back rather than resolving to nothing', () => {
assert.equal(firstDestinationFor([], 'player', '/account'), '/account')
assert.equal(firstDestinationFor(null, 'player', '/account'), '/account')
const allGated = [{ to: '/x', label: 'X', roles: ['admin'] }]
assert.equal(firstDestinationFor(allGated, 'player', '/account'), '/account')
})
test('it reads the grouped admin nav too, flattening in order', () => {
// Same function for both areas, which is the point: the admin index is a
// hardcoded Dashboard today, and if the two logged-in areas ever merge this is
// what answers for the result.
assert.equal(firstDestinationFor(NAV, 'moderator', '/admin/account'), '/admin')
// An editor cannot see Dashboard's neighbours in Moderation, so they land on
// the first row they can see wherever it is.
assert.equal(firstDestinationFor(NAV, 'editor', '/admin/account'), '/admin')
})

View File

@@ -1,60 +0,0 @@
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

@@ -1,74 +0,0 @@
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), '')
})