// module-uo's half of protocol 6 part b (EVENTS_PLAN.md Phase 11b). // // One lease and two participation verbs. What is worth asserting here is not that // the calls happen — a rig proves that better — but the handful of places where // the obvious implementation is subtly the wrong one, and where nothing would fail // if it were written the other way: // // • a lease's `restore()` must turn `lease.drifted` into `{ drifted: true }` // rather than an error, because core records drift as a distinct SUCCESSFUL // outcome and an error would put the row on the retry ladder instead // • `inForce()` must not be a comparison against `read()` — a changed value is // drift, which teardown reports, and orphaning the row first destroys it // • `apply()` must send a DURATION, not the deadline, or a shard whose clock is // fast restores the lease the instant it takes it // • `uo.participation.open` must NOT reconcile by boot stamp, which every other // resource in this module does — the ledger is persisted in the world save // precisely so that it survives the restart the stamp would report it lost by // • a `userId` is a foreign key and a character serial is not, so an unresolved // one is undefined rather than coerced const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const uoLinkClient = require('../utils/uoLinkClient') const shardAtlas = require('../model/shardAtlas/shardAtlas.model') require('./_setup') const actions = require('../config/uoEventActions') const byId = (id) => actions.ACTIONS.find((a) => a.id === id) const lease = () => actions.LEASES.find((l) => l.id === 'uo.playercaps.skillcap') const STUBBED = [ 'getLeases', 'applyLease', 'releaseLease', 'openParticipation', 'snapshotParticipation', 'closeParticipation', ] let calls const saved = {} beforeEach(() => { calls = { apply: [], release: [], open: [], snapshot: [], close: [] } for (const name of STUBBED) saved[name] = uoLinkClient[name] saved.listLandmarks = shardAtlas.listLandmarks uoLinkClient.getLeases = async () => ({ ok: true, status: 200, data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] }, }) uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } } uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } } uoLinkClient.openParticipation = async (b) => { calls.open.push(b); return { ok: true, status: 200, data: {} } } uoLinkClient.snapshotParticipation = async (b) => { calls.snapshot.push(b) return { ok: true, status: 200, data: { participants: [] } } } uoLinkClient.closeParticipation = async (b) => { calls.close.push(b); return { ok: true, status: 200, data: {} } } shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }] }) afterEach(() => { for (const name of STUBBED) uoLinkClient[name] = saved[name] shardAtlas.listLandmarks = saved.listLandmarks }) // ── The lease ────────────────────────────────────────────────────────────── test('the lease satisfies the shape core validates it with', () => { const l = lease() assert.ok(l.id.startsWith('uo.'), 'a lease is namespaced to its module') assert.ok(l.label && l.description) assert.equal(l.type, 'float') // Required for the numeric types, and unlike a cap a bad lease value is in // force the moment it is applied. assert.ok(Number.isFinite(l.min) && Number.isFinite(l.max) && l.min < l.max) assert.ok(Number.isInteger(l.maxDurationMs) && l.maxDurationMs > 0) for (const fn of ['read', 'apply', 'restore', 'inForce']) { assert.equal(typeof l[fn], 'function', `a lease needs ${fn}()`) } }) test('apply sends a DURATION, because a deadline is measured against two clocks', async () => { const until = new Date(Date.now() + 90 * 60_000) const answer = await lease().apply(1200, until) assert.equal(answer.ok, true) const sent = calls.apply[0] // The number the shard arms its timer off. Computed here from the deadline, so // a shard running ten minutes fast holds the lease for ninety minutes of its // own time rather than restoring it the instant it takes it. assert.ok(Math.abs(sent.holdMs - 90 * 60_000) < 2000, `holdMs was ${sent.holdMs}`) // And the absolute time still rides along, for a console that wants to say when // the hold ends in terms the operator's own clock agrees with. assert.equal(sent.untilMs, until.getTime()) // The action hands the value on unchanged; `uoLinkClient.applyLease` is what // renders it as TEXT, which is the wire's contract for every lease type: `1200` // and `1200.0` are one number to a JSON parser and two different strings to a // compare-and-set. assert.equal(sent.value, 1200) }) test('a deadline that has already passed is refused rather than sent as a negative hold', async () => { const answer = await lease().apply(1200, new Date(Date.now() - 60_000)) assert.equal(answer.ok, false) assert.match(answer.error, /already passed/) assert.equal(calls.apply.length, 0) }) test('drift comes back as drifted, not as an error', async () => { // The distinction core acts on. `cleanup.js` records `drifted` as its own // outcome — the module did exactly what it was asked and found somebody else's // value in place — while an error would put the row on the retry ladder and // eventually spend its attempts on a situation only a human can resolve. uoLinkClient.releaseLease = async () => ({ ok: true, status: 200, data: { kind: 'lease.drifted', key: 'PlayerCaps.SkillCap', current: '1300' }, }) const answer = await lease().restore('1000', { expected: '1200' }) assert.equal(answer.ok, false) assert.equal(answer.drifted, true) assert.equal(answer.current, '1300') assert.equal(answer.error, undefined) }) test('restore sends both what it applied and what to put back', async () => { await lease().restore('1000', { expected: '1200' }) // Core's `restore(baseline, { expected })` carries no key of its own -- teardown // is core's own sweep rather than a step dispatch -- so neither does this. assert.deepEqual(calls.release[0], { key: 'PlayerCaps.SkillCap', expected: '1200', baseline: '1000', }) }) test('inForce asks whether the shard still HOLDS it, not whether the value still matches', async () => { // The reason this callable exists at all. A shard reporting a value that is not // what the run applied is reporting DRIFT, which teardown delivers through // `restore()` so the ledger row lands `drifted` with the current value beside // it. Answering "not in force" here would orphan the row first and tell the // operator the lease vanished rather than that somebody moved it. uoLinkClient.getLeases = async () => ({ ok: true, status: 200, data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1300', held: true }] }, }) assert.deepEqual(await lease().inForce(), { ok: true, held: true }) // And a shard that restarted: a config lease is memory-only there by design, so // the value is back at baseline AND the record is gone. This is the case core // could not see before this phase. uoLinkClient.getLeases = async () => ({ ok: true, status: 200, data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] }, }) assert.deepEqual(await lease().inForce(), { ok: true, held: false }) }) test('a shard that cannot answer leaves the ledger alone', async () => { uoLinkClient.getLeases = async () => ({ ok: false, status: 503, error: 'shard not connected' }) const answer = await lease().inForce() assert.equal(answer.ok, false) // `ok: false` is what core reads as "I could not ask", and it keeps believing // its own ledger. Never `held: false`, which would orphan a live lease the // first time a sidecar was slow. assert.equal(answer.held, undefined) assert.equal((await lease().read()).ok, false) }) // ── Participation ────────────────────────────────────────────────────────── test('open resolves a named place to the point the shard counts around', async () => { const answer = await byId('uo.participation.open').perform({ runId: 42, idempotencyKey: 'k-1', params: { place: 'Felucca/Britain', radius: 40, durationMinutes: 120 }, }) assert.equal(answer.ok, true) assert.deepEqual(calls.open[0], { runId: 42, map: 'Felucca', x: 1496, y: 1628, radius: 40, holdMs: 7_200_000, idempotencyKey: 'k-1', }) assert.deepEqual(answer.resources, [ { kind: 'participation', ref: '42', payload: { runId: 42, place: 'Felucca/Britain', radius: 40 } }, ]) }) test('a place the atlas does not know is a refusal an author can read, not a retry', async () => { const answer = await byId('uo.participation.open').perform({ runId: 42, idempotencyKey: 'k-1', params: { place: 'Felucca/Atlantis', radius: 40 }, }) assert.equal(answer.ok, false) assert.equal(answer.retry, false) assert.match(answer.error, /no landmark called "Atlantis"/) assert.equal(calls.open.length, 0) }) test('an area outside the bound is refused before anything is sent', async () => { for (const radius of [0, -1, actions.MAX_AREA_RADIUS + 1, 1.5]) { const answer = await byId('uo.participation.open').perform({ runId: 42, idempotencyKey: 'k-1', params: { place: 'Felucca/Britain', radius }, }) assert.equal(answer.ok, false, String(radius)) assert.equal(answer.retry, false, String(radius)) } assert.equal(calls.open.length, 0) }) test('a dry run checks the place and the radius and opens nothing', async () => { const good = await byId('uo.participation.open').perform({ runId: 42, idempotencyKey: 'k-1', params: { place: 'Felucca/Britain', radius: 40 }, verify: true, }) assert.deepEqual(good, { ok: true }) assert.equal(calls.open.length, 0) // And it is a real check rather than an unconditional yes: the failure an // author most wants caught before the night of the event is a place that is not // on this shard's map. const bad = await byId('uo.participation.open').perform({ runId: 42, idempotencyKey: 'k-1', params: { place: 'Felucca/Atlantis', radius: 40 }, verify: true, }) assert.equal(bad.ok, false) }) test('the ledger is NOT reconciled by boot stamp, unlike everything else here', async () => { // The phase's one genuine divergence from wave 1. `reconcileByBootId` works // because a crier line and a news article live in shard memory, so a changed // `bootId` IS the proof they are gone. A participation ledger is written into // the world save specifically so that it survives a restart — reporting it lost // on a boot change would orphan the one resource the phase persisted. const open = byId('uo.participation.open') assert.notEqual(open.reconcile, actions.reconcileByBootId) // No stamp on the resource either, so nothing downstream can be tempted to // compare one. const answer = await open.perform({ runId: 42, idempotencyKey: 'k-1', params: { place: 'Felucca/Britain', radius: 40 }, }) assert.equal(answer.resources[0].payload.bootId, undefined) // It asks instead, and only an explicit 404 takes a row out. assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] }) uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 404, data: {} }) assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: [] }) // A shard that is down has not said the ledger is gone. uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 503, data: {} }) assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] }) }) test('a run the shard has already forgotten is a successful revert', async () => { // §L: "gone, and that is fine". A shard that restarted past its grace window, // or a second teardown attempt, must not leave a row failing forever. uoLinkClient.closeParticipation = async () => ({ ok: false, status: 404, data: {} }) assert.deepEqual(await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }), { ok: true }) uoLinkClient.closeParticipation = async () => ({ ok: false, status: 503, data: {} }) assert.deepEqual( await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }), { ok: true, failed: ['42'] }, ) }) test('collect files the tally as participants, keyed by character serial', async () => { uoLinkClient.snapshotParticipation = async (b) => { calls.snapshot.push(b) return { ok: true, status: 200, data: { participants: [ { serial: '0x400150E8', name: 'Darrow', acct: 'seed_001', webId: '17', seconds: 3600, minutes: '60.00', kills: 3, score: '75.0000', firstMs: 1788550182074, }, // No account link: the shard reports no webId, and there is nothing to // resolve. Most characters are this one. { serial: '0x1', name: 'Nobody', seconds: 60, minutes: '1.00', kills: 0, score: '1.0000', firstMs: 1788550182074, }, ], }, } } const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' }) assert.equal(answer.ok, true) assert.equal(calls.snapshot[0].idempotencyKey, 'k-2') assert.deepEqual(answer.participants.map((p) => p.memberKey), ['0x400150E8', '0x1']) // The one field core will not take on trust: it is a foreign key into `users`, // so a serial passed here would either fail the insert or attribute somebody's // attendance to a stranger. assert.equal(answer.participants[0].userId, 17) assert.equal(answer.participants[1].userId, undefined) // The score is opaque to core; the components are carried so a results table // can say why somebody scored what they did. assert.deepEqual(answer.participants[0].meta, { name: 'Darrow', seconds: 3600, minutes: '60.00', kills: 3, }) }) test('a webId that is not a positive integer resolves to nothing at all', () => { for (const bad of [null, undefined, '', 'abc', '0', '-3', '1.5', {}]) { assert.equal(actions.webUserId(bad), undefined, JSON.stringify(bad)) } assert.equal(actions.webUserId('17'), 17) assert.equal(actions.webUserId(17), 17) }) test('a busy shard is retried, because the work is happening', async () => { // 425 is `bridge.busy`: a snapshot of this run is already walking across Core // ticks. Transient by construction, and deliberately not in PERMANENT_STATUSES. uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 425, data: { kind: 'bridge.busy', reason: 'a command under this key is in flight' }, }) const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' }) assert.equal(answer.ok, false) assert.equal(answer.retry, true) // Where the event plane simply being switched off is not: 403 is an operator's // deliberate refusal and will still be true in sixty seconds. uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 403, data: { kind: 'participation.error', reason: 'the event plane is disabled on this shard' }, }) const off = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' }) assert.equal(off.retry, false) // And the shard's own words reach the run log, because for an event that ran at // four in the morning that log is the only place anyone will learn why. assert.match(off.error, /event plane is disabled/) }) test('a dry run of collect reads nothing', async () => { assert.deepEqual( await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2', verify: true }), { ok: true }, ) assert.equal(calls.snapshot.length, 0) })