fix(atlas): keep the UniqueId, and make a landmark value name one landmark
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Successful in -21s

Two defects the Phase 16b re-verify found in the released v1.2.1 bundle, both
of which make a shipped feature unusable and neither of which any test saw.

## The aggregator discarded the UniqueId

`shard_spawn_points.unique_id` was NULL on all 6,455 rows of a stock 57.4 tree.
`listSpawners` filters `unique_id IS NOT NULL`, so `uo.options.spawners` was an
empty dropdown -- and it is the ONLY option source for the Phase 12b
object-property leases, so no `Spawner.MaxCount` / `MinDelay` / `MaxDelay` lease
could be authored at all, with nothing on the form to say why.

Every part of the path was already right except one line. The spawn files carry
`<UniqueId>` (~6,374 of them), `parsePoints` returns it, the column exists and
the insert passes `p.uniqueId || null`. `buildAtlas` rebuilds each point from an
explicit field list and `uniqueId` was not on it -- the word appears nowhere in
that file. `PARSER_VERSION = 4`'s own note says "a spawn point keeps its
UniqueId, which is what a property lease targets", so the intent shipped as a
comment while the code dropped the field one function later.

`PARSER_VERSION` goes to 5 because the bump is the only thing that re-reads an
already-imported tree: `sameSources` compares the tree's hashes, which have not
changed -- only what is kept from them. Confirmed on the rig, where the boot
after the fix logged `spawn atlas refreshed` on an unchanged tree and the manual
import then correctly answered `unchanged`.

## A landmark option value named 23 places at once

A stock tree has 558 landmarks under 320 distinct `facet/name` pairs.
`Trammel/Entrance` is 23 different dungeons -- Blighted Grove, Covetous, Deceit,
Despise, Destard and so on -- and `landmarkPoint` resolved with `.find()`, so 22
of the 23 were unreachable. An author who picked "Entrance - Destard" got
Blighted Grove, and the run succeeded with no warning. The group was already the
disambiguator: it was shown in the dropdown and left out of the value.

The value is now `facet/group/name`, which is distinct across all 558.
`landmarkPoint` tries that form first and keeps the two-part read as a fallback,
because every event published before this fix stores `facet/name` and a
published version is immutable -- refusing to parse those would break runs
rather than correct them. The fallback keeps the old first-match behaviour
deliberately: it is imprecise in exactly the way it always was, and silently
relocating a live event's spawn point is worse than repeating a known
imprecision. A three-part value whose group is gone REFUSES rather than falling
back to the name, because it asked for one particular place.

## Verification

On the released-artefact rig (installer -> bundle 2026.09.10 -> stock 57.4 tree
-> protocol-7 sidecar -> core at main with this module):

  spawn points     6455 rows, 6364 with a unique_id   (was 0)
  uo.options.spawners   100 options, and `?q=orc` searches them   (was 0)
  uo.options.landmarks  558 options, 558 distinct values          (was 320)
  suite            625 pass, 0 fail

Each new test was confirmed to FAIL without its fix. The atlas one asserts the
field on the AGGREGATOR's output rather than the parser's, which is the whole
point of it -- and the test fixture had no `<UniqueId>` at all until now, which
is exactly why a green suite said nothing. The landmark one asserts an
INEQUALITY between two resolved points rather than a literal value string, so it
survives another change of format as long as two options still address two
places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-09 20:59:49 -05:00
parent bbaf08f67c
commit d6346996d3
4 changed files with 163 additions and 7 deletions

View File

@@ -305,16 +305,54 @@ function webUserId(webId) {
}
/** Resolve a `facet/name` landmark to the point the shard counts around. */
function landmarkValue(row) {
const group = row.group || ''
return group === '' ? `${row.facet}/${row.name}` : `${row.facet}/${group}/${row.name}`
}
/**
* A place string resolved to a point on a facet.
*
* **Two forms, and the older one is not deprecated — it is stored.** The current
* form is `facet/group/name`, which names exactly one landmark. The older
* `facet/name` is what every event published before this fix carries, and those
* rows are the authored record: a published version is immutable, so a parse that
* stopped understanding them would break runs rather than correct them. So the
* three-part form is tried first and the two-part read is the fallback.
*
* The fallback keeps the old first-match behaviour deliberately. It is wrong in
* the same way it always was — that is what the new form exists to fix — but it
* is what those runs did last time, and silently relocating a live event's
* spawn point is worse than repeating a known imprecision.
*
* A three-part value whose group no longer exists REFUSES rather than falling
* back to the name alone, and that is the point rather than a gap: it asked for
* one particular landmark, so the honest answer when that landmark is gone is to
* say so — the operator renamed something and an event needs re-pointing. Only a
* value that never named a group gets the imprecise read.
*/
async function landmarkPoint(value) {
const raw = String(value == null ? '' : value)
const cut = raw.indexOf('/')
if (cut < 1) {
const parts = raw.split('/')
if (parts.length < 2 || parts[0] === '' || parts[parts.length - 1] === '') {
return { ok: false, error: `"${raw}" is not a facet/name place` }
}
const facet = raw.slice(0, cut)
const name = raw.slice(cut + 1)
const facet = parts[0]
const rows = await shardAtlas.listLandmarks({ facet })
// `facet/group/name`. The name is the LAST segment and the group is everything
// between, so a group carrying a slash still resolves.
if (parts.length >= 3) {
const group = parts.slice(1, -1).join('/')
const name = parts[parts.length - 1]
const hit = rows.find((r) => r.name === name && (r.group || '') === group)
if (hit) return { ok: true, map: hit.facet, x: hit.x, y: hit.y }
// No fall-through error: a name containing a slash reads as three parts too,
// and the two-part read below is the one that resolves it.
}
const name = parts.slice(1).join('/')
const hit = rows.find((r) => r.facet === facet && r.name === name)
if (!hit) {
@@ -2125,7 +2163,14 @@ const OPTION_SOURCES = [
async resolve() {
const rows = await shardAtlas.listLandmarks()
return bounded(rows, 'uo.options.landmarks').map((r) => ({
value: `${r.facet}/${r.name}`,
// **`facet/group/name`, because `facet/name` does not name one place.**
// A stock 57.4 tree has 558 landmarks under 320 distinct `facet/name`
// pairs: `Trammel/Entrance` is 23 different dungeons, and `landmarkPoint`
// resolves with `.find()`, so 22 of them were unreachable — an author who
// picked "Entrance — Destard" got Blighted Grove, with a successful run
// and no warning. The group was already the disambiguator; it was shown
// to the eye and left out of the value. All 558 are distinct with it.
value: landmarkValue(r),
label: r.name,
// The atlas's own grouping where it has one, the facet otherwise — so a
// shard whose landmark file carries no groups still gets a usable