fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
Stage 2 of PLAN_LAST_FIXES (docs#366). - W7/W11: an "is it loaded" refusal is confirmed live before it is given. New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick would), at most once per server per 5 s and waiting at most 3 s, then reads the stored status again. Used by Economics (step and staff page), Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes, TruePVE and RunicNPC. - W3: a world call answered `already-out` (the bridge half comes in stage 3) succeeds as called 0, saying a Travelling Vendor is already out. - W6: "A locked crate dropped by a Chinook is being hacked." - W8: a RaidableBases profile's audit row names RaidableBases, not "@data". - W10: the backpack sizes picker lists each size once, naming its servers. - W13: a RaidableBases name both servers describe differently shows each server's own description. - W14: each RaidableBases note after the first starts its own sentence. - W15: a staff deposit refused at Economics' limit answers 409. - W16: a payment's failedNote explains only the reasons that happened. - W17: the sync state counts player grants that did not land (waitingToLand), and the Permissions page says "in sync · 1 waits for their player to join" instead of plain "in sync". - W18: Guarded Crate's kept note drops the "30 minutes" it could not know. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
@@ -194,16 +198,21 @@ test('the revert deletes the step’s rows, and the next sync takes the size bac
|
||||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||||
})
|
||||
|
||||
test('the size list offers each server’s sizes above its smallest, grouped by server', async (t) => {
|
||||
test('the size list offers each size above a server’s smallest once, naming every server that offers it (W10)', async (t) => {
|
||||
const saved = { listForPolling: servers.listForPolling, reports: backpacksDb.reports }
|
||||
t.after(() => Object.assign(servers, { listForPolling: saved.listForPolling }) && Object.assign(backpacksDb, { reports: saved.reports }))
|
||||
servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }]
|
||||
servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }, { id: 'off', name: 'Off' }]
|
||||
backpacksDb.reports = async () => [
|
||||
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) },
|
||||
{ serverId: 'pve', backpacks: null },
|
||||
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48, 96] }) },
|
||||
{ serverId: 'pve', backpacks: JSON.stringify({ loaded: true, sizes: [12, 24, 48] }) },
|
||||
{ serverId: 'off', backpacks: null },
|
||||
]
|
||||
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.backpacksizes')
|
||||
assert.deepStrictEqual(await source.resolve(), [{ value: 48, label: '48 slots', group: 'Main' }])
|
||||
assert.deepStrictEqual(await source.resolve(), [
|
||||
{ value: 24, label: '24 slots — PvE' },
|
||||
{ value: 48, label: '48 slots — Main, PvE' },
|
||||
{ value: 96, label: '96 slots — Main' },
|
||||
])
|
||||
})
|
||||
|
||||
// ── Reading one ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -558,6 +558,15 @@ test('the page’s poll answers 404 for a write that is not on that server', asy
|
||||
|
||||
// ── RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205) ────────────────
|
||||
|
||||
test('a path names its plugin, and a RaidableBases profile names RaidableBases, not "@data" (W8)', () => {
|
||||
const model = require('../model/config/config.model')
|
||||
assert.strictEqual(model.pluginOf('@data/RaidableBases/Profiles/Hard Bases.json'), 'RaidableBases')
|
||||
assert.strictEqual(model.pluginOf('Kits.json'), 'Kits')
|
||||
assert.strictEqual(model.pluginOf('RaidableBases/Profiles.json'), 'RaidableBases')
|
||||
assert.strictEqual(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true)
|
||||
assert.strictEqual(model.isBridgeConfig('@data/RaidableBases/Profiles/x.json', 'RunicGateway'), false)
|
||||
})
|
||||
|
||||
test('a RaidableBases profile’s virtual path is plausible, one file deep, and nothing else under @data is', () => {
|
||||
const model = require('../model/config/config.model')
|
||||
assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true)
|
||||
|
||||
@@ -37,6 +37,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const dt = require('../dangerousTreasures')
|
||||
const { gridOf } = require('../worldEvents')
|
||||
const { TRIGGERS } = require('../engagement/triggers')
|
||||
|
||||
@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
@@ -220,10 +224,21 @@ test('coins go once to each website user, with the key, the amount and the messa
|
||||
assert.strictEqual(result.detail.total, 500)
|
||||
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
|
||||
assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }])
|
||||
assert.strictEqual(result.detail.failedNote, "at-limit: the balance is at Economics' limit", 'only the reason that happened (W16)')
|
||||
assert.deepStrictEqual(result.detail.missed, ['p3'])
|
||||
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
|
||||
})
|
||||
|
||||
test('a payment’s failedNote explains each reason that happened, and only those (W16)', () => {
|
||||
const { failedNote } = require('../eventRewards')
|
||||
assert.strictEqual(failedNote([{ reason: 'unmeasured' }, { reason: 'unmeasured' }]), 'unmeasured: Economics did not report the deposit')
|
||||
assert.strictEqual(
|
||||
failedNote([{ reason: 'unmeasured' }, { reason: 'at-limit' }, { reason: 'refused' }]),
|
||||
"at-limit: the balance is at Economics' limit; refused: Economics did not take the deposit; unmeasured: Economics did not report the deposit",
|
||||
)
|
||||
assert.strictEqual(failedNote([{ reason: 'odd' }]), "odd: Economics' own reason")
|
||||
})
|
||||
|
||||
test('a repeated key is answered from the first payment, and a lost answer is retried', async (t) => {
|
||||
const person = { steamId: A, name: 'pa', seconds: 60, kills: 0, score: 1, joinedAt: 1 }
|
||||
stub(t, { people: [person], links: { [A]: 1 }, payAnswer: () => ok({ kind: 'economics.paid', repeat: true, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [] }) })
|
||||
@@ -291,6 +306,12 @@ test('a withdrawal above the balance is refused with the balance', async (t) =>
|
||||
assert.match(result.message, /only 340 coins on Main/)
|
||||
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
|
||||
|
||||
// A deposit refused at the balance limit is the game saying no too (W15).
|
||||
client.economicsAdjust = async () => ok({ kind: 'economics.error', reason: 'at-limit', message: "this player's balance is at Economics' limit" })
|
||||
const capped = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 50, key: 'adj-3' })
|
||||
assert.strictEqual(capped.status, 409)
|
||||
assert.strictEqual(capped.reason, 'at-limit')
|
||||
|
||||
economicsDb.reportFor = async () => null
|
||||
const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' })
|
||||
assert.strictEqual(missing.status, 409)
|
||||
|
||||
@@ -40,6 +40,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const gc = require('../guardedCrate')
|
||||
const { gridOf } = require('../worldEvents')
|
||||
const { TRIGGERS } = require('../engagement/triggers')
|
||||
|
||||
113
server/test/liveStatus.test.js
Normal file
113
server/test/liveStatus.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// ── Asking a server how it is now, before saying no (W7, W11) ──────────────
|
||||
//
|
||||
// Each test is one way the feature could look right and be wrong:
|
||||
//
|
||||
// a "no" from the stored status is asked again, and the fresh answer is the one acted on
|
||||
// a "yes" is never asked again: the plugin refuses at the run, as it always has
|
||||
// a "no" the sidecar cannot change is still a "no"
|
||||
// ten steps against one server in one dry run ask it once
|
||||
// a step refused for Backpacks just after a restart passes once the server says it is back
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
|
||||
|
||||
const boot = require('../boot')
|
||||
const live = require('../liveStatus')
|
||||
|
||||
const MAIN = { id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' }
|
||||
|
||||
/** `boot.refreshOne` replaced by `onAsk`, counted. */
|
||||
function stubAsk(t, onAsk = () => {}) {
|
||||
const saved = boot.refreshOne
|
||||
const asked = []
|
||||
live._reset()
|
||||
boot.refreshOne = async (server) => {
|
||||
asked.push(server.id)
|
||||
await onAsk(server)
|
||||
}
|
||||
t.after(() => {
|
||||
boot.refreshOne = saved
|
||||
live._reset()
|
||||
})
|
||||
return asked
|
||||
}
|
||||
|
||||
test('a refusal from the stored status is asked again, and the fresh answer is the one acted on', async (t) => {
|
||||
let stored = { loaded: false }
|
||||
const asked = stubAsk(t, () => {
|
||||
stored = { loaded: true }
|
||||
})
|
||||
const answer = await live.confirmed(MAIN, async () => stored, (r) => !r.loaded)
|
||||
assert.deepStrictEqual(answer, { loaded: true })
|
||||
assert.deepStrictEqual(asked, ['main'])
|
||||
})
|
||||
|
||||
test('a pass is never asked again', async (t) => {
|
||||
const asked = stubAsk(t)
|
||||
const answer = await live.confirmed(MAIN, async () => ({ loaded: true }), (r) => !r.loaded)
|
||||
assert.deepStrictEqual(answer, { loaded: true })
|
||||
assert.deepStrictEqual(asked, [])
|
||||
})
|
||||
|
||||
test('a refusal the fresh answer agrees with is still a refusal, and a failed ask leaves the stored one', async (t) => {
|
||||
stubAsk(t)
|
||||
assert.strictEqual(await live.confirmed(MAIN, async () => 'Backpacks is not installed', Boolean), 'Backpacks is not installed')
|
||||
|
||||
stubAsk(t, () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
assert.strictEqual(await live.refresh(MAIN), false)
|
||||
assert.strictEqual(await live.confirmed(MAIN, async () => 'still no', Boolean), 'still no')
|
||||
})
|
||||
|
||||
test('many refusals against one server at once ask it once, and again only after a while', async (t) => {
|
||||
let release
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const asked = stubAsk(t, () => gate)
|
||||
const all = Promise.all(Array.from({ length: 10 }, () => live.confirmed(MAIN, async () => 'no', Boolean)))
|
||||
release()
|
||||
await all
|
||||
assert.deepStrictEqual(asked, ['main'], 'one ask in flight serves every step')
|
||||
|
||||
await live.confirmed(MAIN, async () => 'no', Boolean)
|
||||
assert.deepStrictEqual(asked, ['main'], `an ask within ${live.FRESH_MS} ms is as fresh as asking again`)
|
||||
|
||||
await live.confirmed({ ...MAIN, id: 'pve' }, async () => 'no', Boolean)
|
||||
assert.deepStrictEqual(asked, ['main', 'pve'], 'another server is its own')
|
||||
})
|
||||
|
||||
test('a step waits at most ASK_MS for the ask, and a late ask leaves the stored answer', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
stubAsk(t, () => new Promise(() => {}))
|
||||
const answer = live.confirmed(MAIN, async () => 'not installed', Boolean)
|
||||
// Let the first read and the ask start before the clock moves.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve()
|
||||
t.mock.timers.tick(live.ASK_MS)
|
||||
assert.strictEqual(await answer, 'not installed')
|
||||
})
|
||||
|
||||
test('a bigger backpack refused on a stale "not installed" passes once the server says Backpacks is back (W7)', async (t) => {
|
||||
const rewards = require('../eventRewards')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const backpacksDb = require('../model/backpacks/backpacks.db')
|
||||
const saved = { getServer: serversDb.getServer, reportFor: backpacksDb.reportFor }
|
||||
t.after(() => Object.assign(serversDb, { getServer: saved.getServer }) && Object.assign(backpacksDb, { reportFor: saved.reportFor }))
|
||||
|
||||
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
|
||||
let report = null
|
||||
backpacksDb.reportFor = async () => ({ serverId: 'main', backpacks: report })
|
||||
const asked = stubAsk(t, () => {
|
||||
report = JSON.stringify({ loaded: true, sizes: [6, 48] })
|
||||
})
|
||||
const step = rewards.ACTIONS.find((a) => a.id === 'rust.backpack.size')
|
||||
const result = await step.perform({ runId: 7, stepId: 1, idempotencyKey: '7:1', params: { server: 'main', size: 48, recipients: 'linked' }, verify: true })
|
||||
assert.deepStrictEqual(asked, ['main'])
|
||||
assert.deepStrictEqual(result, { ok: true })
|
||||
})
|
||||
@@ -27,6 +27,10 @@ require('../core')._reset()
|
||||
const ctx = fakeCtx({ freeze: false })
|
||||
require('../core').init(ctx)
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
|
||||
@@ -77,6 +77,25 @@ function authored() {
|
||||
}
|
||||
}
|
||||
|
||||
test('a sync whose report did not land a player’s grant is in sync and says how many wait (W17)', () => {
|
||||
withCore()
|
||||
const model = require('../model/permissions/permissions.model')
|
||||
const row = (notLanded) => ({
|
||||
serverId: 'carbon',
|
||||
state: 'ok',
|
||||
desiredHash: 'h',
|
||||
syncedHash: 'h',
|
||||
report: JSON.stringify({ notLanded }),
|
||||
})
|
||||
|
||||
const waiting = model.shapeSync(row(['76561199910000001:backpacks.size.24', 'vip:parent:default']))
|
||||
assert.strictEqual(waiting.inSync, true)
|
||||
assert.strictEqual(waiting.waitingToLand, 1, 'a group’s parent is not a player’s grant')
|
||||
|
||||
assert.strictEqual(model.shapeSync(row([])).waitingToLand, 0)
|
||||
assert.strictEqual(model.shapeSync({ serverId: 'x', state: 'pending' }).waitingToLand, 0)
|
||||
})
|
||||
|
||||
test('a grant reaches every Steam account its holder has linked (D28)', () => {
|
||||
withCore()
|
||||
const model = require('../model/permissions/permissions.model')
|
||||
|
||||
@@ -39,6 +39,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const rb = require('../raidableBases')
|
||||
const catalogue = require('../catalogue')
|
||||
const { gridOf } = require('../worldEvents')
|
||||
@@ -237,6 +241,22 @@ test('the pickers list each server’s own names, once each, and nothing for a s
|
||||
assert.deepStrictEqual(await difficulties.resolve(), [], 'nothing is made up for a server without the plugin')
|
||||
})
|
||||
|
||||
test('a name both servers have, described differently, shows each server’s own description (W13)', async (t) => {
|
||||
const [difficulties, profiles] = module_().OPTION_SOURCES
|
||||
const hard = (difficulty) => ({ ...REPORT, profiles: [{ name: 'Hard Bases', difficulty, level: 2, enabled: true, pvp: true, bases: ['h1'], seabed: false, float: false }] })
|
||||
stubStep(t, {
|
||||
reports: {
|
||||
main: { raidableBases: hard('Hard') },
|
||||
pve: { raidableBases: { ...hard('Expert'), difficulties: [{ name: 'Gentle', level: 1 }, { name: 'Wobbly', level: 7 }] } },
|
||||
},
|
||||
})
|
||||
assert.deepStrictEqual(await profiles.resolve(), [{ value: 'Hard Bases', label: 'Hard Bases · Hard — Main; Hard Bases · Expert — PvE' }])
|
||||
assert.deepStrictEqual(await difficulties.resolve(), [
|
||||
{ value: 'Gentle', label: 'Gentle (level 0) — Main; Gentle (level 1) — PvE' },
|
||||
{ value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' },
|
||||
])
|
||||
})
|
||||
|
||||
test('without a patched helper, a spot, a profile, a base or settings are refused; a plain spawn is not (D348, D387)', async (t) => {
|
||||
const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true })
|
||||
stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } } })
|
||||
@@ -337,8 +357,12 @@ test('a base the helper did not make is not the run’s: no resource, and the ru
|
||||
const result = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', difficulty: 'Wobbly' } })
|
||||
assert.ok(result.ok)
|
||||
assert.strictEqual(result.resources, undefined)
|
||||
assert.match(result.detail.note, /RaidableBases picks the spot/)
|
||||
assert.match(result.detail.note, new RegExp(module_().OWN_TIMER))
|
||||
const own = module_().OWN_TIMER
|
||||
assert.strictEqual(
|
||||
result.detail.note,
|
||||
`RaidableBases picks the spot; the map shows it once the base opens. ${own[0].toUpperCase()}${own.slice(1)}`,
|
||||
'each note after the first starts its own sentence (W14)',
|
||||
)
|
||||
})
|
||||
|
||||
test('the grid’s wait is a ten-minute retry; a refusal the plugin would repeat is not retried', async (t) => {
|
||||
|
||||
@@ -415,6 +415,17 @@ test('a refusal the plugin would repeat is not retried; a game that is down is',
|
||||
assert.match(down.error, /has no game connected/)
|
||||
})
|
||||
|
||||
test('a Travelling Vendor already out is no failure: the step says so and counts nothing called (W3)', async (t) => {
|
||||
stubCall(t, ok({ kind: 'world.error', reason: 'already-out', message: 'a Travelling Vendor is already out' }))
|
||||
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:6', params: { server: 'main', event: 'travelling_vendor' } })
|
||||
assert.strictEqual(result.ok, true)
|
||||
assert.deepStrictEqual(result.detail, {
|
||||
event: 'travelling_vendor',
|
||||
called: 0,
|
||||
note: 'a Travelling Vendor is already out on Main, and the game runs one at a time; nothing new was called',
|
||||
})
|
||||
})
|
||||
|
||||
test('a repeat of a key says so and calls nothing new', async (t) => {
|
||||
stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true }))
|
||||
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } })
|
||||
|
||||
@@ -24,6 +24,10 @@ const { fakeCtx } = require('./_fakes')
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const zonesDb = require('../model/zones/zones.db')
|
||||
|
||||
Reference in New Issue
Block a user