fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger
Needs website#<core> (the cooldown key and the seed-rule ceiling). 1. Every owner-audienced trigger reached NOBODY. `resolveTarget` read `link.user_id`; the model's `toSafe` returns `userId`. So the whole flagship family -- houses, vendors, logins, unlinks, deaths, the governor's letter -- resolved to null and looked exactly like the ordinary unlinked-account case, which the code treats as normal and deliberately does not log. The test fake returned `user_id` and therefore agreed with the bug, while `shardStreams.test.js`'s fake next door -- same model, the path this file says it copies -- returned `userId`. The fake is now built by running the real `toSafe` over a stubbed db row, so the shape is not a hand-written opinion. 2. `uo.house.refreshed`, the 26th trigger (the org lead's decision 11). The warning's rule carries `delay_seconds: 900` so a player who repairs the house inside the quarter-hour is never told it is in peril -- and nothing could cancel it: `cancel_on` named only the collapse. The wire had carried the transition all along; the mapper returned early on it. It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case: a condemned house cannot be refreshed at all (`RefreshDecay()` refuses `DecayType.Condemned`), so the rescue is the owner logging in, and their newest house then reads `Ageless`. Ships a body and a seeded (disabled) rule of its own; the cancellation is read off the WARNING's rule and works whether or not the new one is enabled. 3. Every call-to-action in every in-universe body was a dead link, from two independent mistakes. The client router prefixes a module's routes with its ID (`/uo/houses`), not with module.json's `mounts` (`/shard/...`), so every declared `example` was a 404 -- and an example is what the template editor previews and test-sends with. And no `url` variable was ever populated by the mapper, so the buttons rendered with an empty href and dropped out of the text part entirely. Both now read `config/clientPaths.js`. Two tests close it. 4. A raw wire timestamp was signing off the Merchants' Guild's letter (`2026-09-02T04:06:43.8397548Z`, mid-sentence). Core has no interpolation filters by design, so the readable form is assembled in the mapper and arrives as its own variable; the machine value stays, because an operator writes `is at most` conditions against it. Also fixes a latent flake: `hoursRemaining` floors a live clock, so a fixture at a whole number asserted 19 or 20 depending on sub-millisecond timing. 527 module tests green (3 new). Proved end to end against real ServUO + the release sidecar + a live SMTP catcher; see docs#<docs>. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../model/shardState/shardState.model')
|
||||
const { TRIGGER_IDS } = require('../config/shardTriggers')
|
||||
const { PATHS, guildPath } = require('../config/clientPaths')
|
||||
const core = require('../core')
|
||||
|
||||
const log = core.logger('shard-engagement')
|
||||
@@ -196,6 +197,35 @@ const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] ||
|
||||
// "On hand: gold. Charged each period: gold." — which is what the render sweep
|
||||
// found on a pre-v5 vendor frame. Passing the assembled line means the template
|
||||
// interpolates ONE variable and the empty case is empty rather than punctuated.
|
||||
// A wire instant as a person reads it: "2 September 2026, 04:06 UTC".
|
||||
//
|
||||
// Core deliberately has no interpolation filters (`interpolate.js` — no ternaries,
|
||||
// no formatters), so a `datetime` variable renders as whatever string the payload
|
||||
// holds — and the wire's is an ISO-8601 stamp with seven decimal places, which is
|
||||
// what a letter from the Merchants' Guild was signing off with. Same argument as
|
||||
// `place()` and `detailLine()` one line down: the presentation is assembled here,
|
||||
// at the call site, and arrives as its own value.
|
||||
//
|
||||
// **The machine value is never replaced.** `dismissalAt` and `autoPickAt` are
|
||||
// declared `datetime` and an operator can write `is at most` conditions against
|
||||
// them (`conditions.js`), so the readable form is an ADDITIONAL variable and the
|
||||
// ISO one stays exactly as it was.
|
||||
const readableTime = (iso) => {
|
||||
if (!iso) return undefined
|
||||
const at = new Date(iso)
|
||||
if (Number.isNaN(at.getTime())) return undefined
|
||||
const day = at.getUTCDate()
|
||||
const month = MONTHS[at.getUTCMonth()]
|
||||
const hh = String(at.getUTCHours()).padStart(2, '0')
|
||||
const mm = String(at.getUTCMinutes()).padStart(2, '0')
|
||||
return `${day} ${month} ${at.getUTCFullYear()}, ${hh}:${mm} UTC`
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
const detailLine = (parts) => {
|
||||
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
|
||||
@@ -239,6 +269,45 @@ const MAPPERS = {
|
||||
})
|
||||
return
|
||||
}
|
||||
// **The good outcome** (Phase 11b decision 11). A house refreshed back to
|
||||
// LikeNew is what `uo.house.idoc_warning`'s 900-second delay exists to give
|
||||
// the owner time to do, and until this branch the refresh reached the engine
|
||||
// as silence — so the delay was a late mail rather than a cancellable one.
|
||||
// Nothing on the wire changed: the decay sweep has always emitted this
|
||||
// transition, and the early return below was swallowing it.
|
||||
// **`AGELESS` as well as `LIKENEW`, and the first is the commoner case.**
|
||||
// ServUO reports `LikeNew` for a house that is still on a decay clock and
|
||||
// has just been refreshed (`DecayType.ManualRefresh`), and `Ageless` for one
|
||||
// that is no longer on a clock at all — which is what the owner's newest
|
||||
// house becomes the moment they log back in, because `DecayType` flips to
|
||||
// `AutoRefresh` and the getter stops advancing the stage. A returning player
|
||||
// is the ordinary way a decaying house is rescued, so reading only `LikeNew`
|
||||
// would miss most rescues. Both mean "out of danger", which is what this
|
||||
// trigger says.
|
||||
if (to === 'LIKENEW' || to === 'AGELESS') {
|
||||
out.push({
|
||||
triggerId: 'uo.house.refreshed',
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
houseSerial: serial,
|
||||
houseUrl: PATHS.houses,
|
||||
houseName: decayName(ev),
|
||||
previousStage: ev.from || undefined,
|
||||
region: ev.region || undefined,
|
||||
location: place(ev),
|
||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||
// A TRAILING FRAGMENT, so it leads with its own space and the sentence
|
||||
// closes cleanly without it. Built only for a stage that has a word —
|
||||
// "It stood in decay." is the fallback label leaking into prose, and an
|
||||
// empty fragment reads better than that.
|
||||
fromLine: trailing(
|
||||
STAGE_WORDS[String(ev.from || '').toUpperCase()] ? ev.from : null,
|
||||
(stage) => ` It stood ${stageLabel(stage)}.`,
|
||||
),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return
|
||||
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
|
||||
out.push({
|
||||
@@ -246,6 +315,7 @@ const MAPPERS = {
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
houseSerial: serial,
|
||||
houseUrl: PATHS.houses,
|
||||
houseName: decayName(ev),
|
||||
stage: ev.to,
|
||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||
@@ -308,6 +378,7 @@ const MAPPERS = {
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
vendorSerial: serial,
|
||||
marketUrl: PATHS.market,
|
||||
shopName: ev.shopName || undefined,
|
||||
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
||||
dismissalAt: fees.dismissalAt,
|
||||
@@ -322,7 +393,7 @@ const MAPPERS = {
|
||||
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
|
||||
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
|
||||
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
|
||||
['Dismissal', fees.dismissalAt],
|
||||
['Dismissal', readableTime(fees.dismissalAt)],
|
||||
['Standing at', place(ev)],
|
||||
]),
|
||||
}),
|
||||
@@ -470,7 +541,7 @@ const MAPPERS = {
|
||||
// already left, so there is nothing for the shard to attribute. The name is
|
||||
// looked up from the roster mirror in `dispatch`.
|
||||
memberSerial: ev.who == null ? null : String(ev.who),
|
||||
data: defined({ guildName: ev.name || `guild ${ev.id}` }),
|
||||
data: defined({ guildUrl: guildPath(ev.id), guildName: ev.name || `guild ${ev.id}` }),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -504,6 +575,7 @@ const MAPPERS = {
|
||||
// rewritten the day `city.update` gains a `previousGovernor` actor.
|
||||
const civic = defined({
|
||||
city: String(city),
|
||||
governorsUrl: PATHS.governors,
|
||||
governorName,
|
||||
previousGovernorName: undefined,
|
||||
inSuccessionTo: undefined,
|
||||
@@ -543,9 +615,11 @@ const MAPPERS = {
|
||||
triggerId: 'uo.election.opened',
|
||||
data: defined({
|
||||
city: String(city),
|
||||
governorsUrl: PATHS.governors,
|
||||
phase,
|
||||
phaseLabel: phaseLabel(phase),
|
||||
autoPickAt: ev.autoPickAt,
|
||||
autoPickWhen: readableTime(ev.autoPickAt),
|
||||
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
|
||||
candidateNote: trailing(
|
||||
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
|
||||
@@ -566,6 +640,7 @@ const MAPPERS = {
|
||||
|
||||
const base = defined({
|
||||
spawnSerial: serial,
|
||||
champsUrl: PATHS.champs,
|
||||
spawnName: ev.name || ev.type || 'a champion spawn',
|
||||
category: ev.category || undefined,
|
||||
location: place(ev),
|
||||
@@ -604,20 +679,20 @@ const MAPPERS = {
|
||||
if (wasUp === true) return
|
||||
out.push({
|
||||
triggerId: 'uo.server.up',
|
||||
data: defined({ shardName: ev.shard || undefined }),
|
||||
data: defined({ statusUrl: PATHS.shard, shardName: ev.shard || undefined }),
|
||||
})
|
||||
},
|
||||
|
||||
'server.shutdown': (ev, tracker, out) => {
|
||||
if (tracker.serverUp === false) return
|
||||
tracker.serverUp = false
|
||||
out.push({ triggerId: 'uo.server.down', data: { clean: true } })
|
||||
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: true } })
|
||||
},
|
||||
|
||||
'server.crashed': (ev, tracker, out) => {
|
||||
if (tracker.serverUp === false) return
|
||||
tracker.serverUp = false
|
||||
out.push({ triggerId: 'uo.server.down', data: { clean: false } })
|
||||
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: false } })
|
||||
},
|
||||
|
||||
// ── Leaderboard ────────────────────────────────────────────────────────
|
||||
@@ -659,6 +734,7 @@ const MAPPERS = {
|
||||
out.push({
|
||||
triggerId: 'uo.page.new',
|
||||
data: defined({
|
||||
pagesUrl: PATHS.ops,
|
||||
pageType: String(ev.type || 'Other'),
|
||||
senderName: actorName(ev.sender),
|
||||
message: ev.message || undefined,
|
||||
@@ -738,6 +814,7 @@ const MAPPERS = {
|
||||
out.push({
|
||||
triggerId: 'uo.economy.milestone',
|
||||
data: defined({
|
||||
economyUrl: PATHS.shard,
|
||||
metric,
|
||||
value: Math.round(value),
|
||||
threshold: crossed,
|
||||
@@ -799,10 +876,16 @@ async function resolveTarget(target, deps) {
|
||||
// `owner` — one account, one user. An unlinked account is nobody to notify,
|
||||
// which is a normal outcome and not an error: most game accounts on most shards
|
||||
// have never been linked.
|
||||
//
|
||||
// **`userId`, not `user_id`.** The model's `toSafe` camel-cases the row on the
|
||||
// way out, so reading the column name silently makes EVERY owner-audienced
|
||||
// trigger resolve to nobody — indistinguishable, from here and from the logs,
|
||||
// from the ordinary unlinked-account case above. `shardPush.js` is the
|
||||
// precedent this file follows and it reads `owner.userId`.
|
||||
if (target.ownerAccount) {
|
||||
const link = await links.getByAccount(target.ownerAccount)
|
||||
if (!link || link.user_id == null) return null
|
||||
return { data, ownerUserId: Number(link.user_id) }
|
||||
if (!link || link.userId == null) return null
|
||||
return { data, ownerUserId: Number(link.userId) }
|
||||
}
|
||||
|
||||
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
|
||||
@@ -814,10 +897,10 @@ async function resolveTarget(target, deps) {
|
||||
const house = houses.find((h) => String(h.serial) === target.houseSerial)
|
||||
if (!house || !house.ownerAcct) return null
|
||||
const link = await links.getByAccount(house.ownerAcct)
|
||||
if (!link || link.user_id == null) return null
|
||||
if (!link || link.userId == null) return null
|
||||
if (house.name) data.houseName = house.name
|
||||
if (house.region) data.region = house.region
|
||||
return { data, ownerUserId: Number(link.user_id) }
|
||||
return { data, ownerUserId: Number(link.userId) }
|
||||
}
|
||||
|
||||
// `members` — the guild's roster, resolved to website users through
|
||||
|
||||
Reference in New Issue
Block a user