B2.3 + P11.1 + P11.2 + monster schema fix

B2.3 LatestWinsGate fireAndWait:
  Added fireAndWait(value, ctx?) returning per-fire settlement promise.
  Supersede resolves with frozen sentinel {superseded: true} (no
  rejection — callers branch on value without try/catch). Dispatch
  errors also resolve (with undefined); error surfaces via gate.lastError.
  LatestWinsGate.js 75 → 116 lines. 12/12 tests pass.

P11.1 convert.possibilities(measure):
  New helper returning sorted+deduped unit names for a measure.
  Cached per measure. Reuses existing convert measures map. Also
  exposed convert.measures() listing all known measures.
  convert/index.js +21 lines. New test file: 90 lines, 12/12 tests.

P11.2 commandRegistry.units field:
  Pre-dispatch normalisation pipeline. descriptor.units = {measure,
  default}; commandRegistry extracts msg.payload + msg.unit (3 shapes),
  validates against measure, converts to default, falls back + warns
  with accepted-list on unknown/wrong-measure. Falls back gracefully
  if convert.possibilities is missing. commandRegistry.js 164 → 237.
  +7 new tests covering all 4 paths.

monster schema fix (P11.2 sibling):
  generalFunctions/src/configs/monster.json was stripping four
  legitimate constraint keys (nominalFlowMin, flowMax, maxRainRef,
  minSampleIntervalSec). Added them with defaults matching the
  legacy nodeClass coercion. Side effect: this also UNBLOCKED the
  monster cooldown-guard test (separate ROOT-CAUSE entry below).

CONTRACTS.md §4 + §8 updated. 144/144 basic tests + 206/206 full
generalFunctions tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-11 17:29:14 +02:00
parent f11754635b
commit 5ea968eabc
8 changed files with 522 additions and 15 deletions

View File

@@ -10,8 +10,32 @@
// JSON-Schema. Anything richer belongs in the handler itself, which has
// access to logger via ctx.
const convert = require('../convert');
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any', 'none']);
function _acceptedList(measure) {
if (convert && typeof convert.possibilities === 'function') {
const list = convert.possibilities(measure);
if (Array.isArray(list) && list.length) return list.join(', ');
}
return '(see convert docs)';
}
function _describeUnit(unit) {
try { return convert().describe(unit); } catch (_) { return null; }
}
function _extractValueAndUnit(msg) {
if (!msg || typeof msg !== 'object') return null;
const p = msg.payload;
if (typeof p === 'number') return { value: p, unit: msg.unit };
if (p && typeof p === 'object' && typeof p.value === 'number') {
return { value: p.value, unit: p.unit ?? msg.unit };
}
return null;
}
class CommandRegistry {
constructor(commands, options = {}) {
if (!Array.isArray(commands)) {
@@ -45,11 +69,13 @@ class CommandRegistry {
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
}
}
const units = this._validateUnits(cmd);
const descriptor = {
topic: cmd.topic,
aliases,
payloadSchema: cmd.payloadSchema || null,
description: typeof cmd.description === 'string' ? cmd.description : null,
units,
handler: cmd.handler,
};
this._byKey.set(cmd.topic, descriptor);
@@ -60,6 +86,17 @@ class CommandRegistry {
this._descriptors.push(descriptor);
}
_validateUnits(cmd) {
if (cmd.units === undefined || cmd.units === null) return null;
const { measure, default: def } = cmd.units;
if (typeof measure !== 'string' || measure.length === 0 ||
typeof def !== 'string' || def.length === 0) {
throw new TypeError(
`command '${cmd.topic}' units requires { measure: string, default: string }`);
}
return { measure, default: def };
}
has(topic) {
return typeof topic === 'string' && this._byKey.has(topic);
}
@@ -77,6 +114,7 @@ class CommandRegistry {
aliases: d.aliases.slice(),
payloadSchema: d.payloadSchema,
description: d.description,
units: d.units ? { measure: d.units.measure, default: d.units.default } : null,
}));
}
@@ -99,6 +137,7 @@ class CommandRegistry {
return;
}
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
if (descriptor.units) this._normaliseUnits(descriptor, msg, log);
if (!this._validatePayload(descriptor, msg, log)) return;
return descriptor.handler(source, msg, ctx);
}
@@ -111,6 +150,40 @@ class CommandRegistry {
log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`);
}
_normaliseUnits(descriptor, msg, log) {
const { measure, default: defaultUnit } = descriptor.units;
const extracted = _extractValueAndUnit(msg);
if (!extracted) return; // unknown shape — let payload validator handle it
let { value, unit } = extracted;
if (unit === undefined || unit === null || unit === '') {
// No unit supplied — assume default, silent.
msg.payload = value;
msg.unit = defaultUnit;
return;
}
const desc = _describeUnit(unit);
if (!desc) {
log.warn?.(`${descriptor.topic}: unknown unit '${unit}'. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`);
msg.payload = value;
msg.unit = defaultUnit;
return;
}
if (desc.measure !== measure) {
log.warn?.(`${descriptor.topic}: unit '${unit}' is ${desc.measure}, expected ${measure}. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`);
msg.payload = value;
msg.unit = defaultUnit;
return;
}
try {
msg.payload = convert(value).from(unit).to(defaultUnit);
msg.unit = defaultUnit;
} catch (err) {
log.warn?.(`${descriptor.topic}: failed to convert ${value} ${unit} -> ${defaultUnit} (${err.message}). Treating as ${defaultUnit}.`);
msg.payload = value;
msg.unit = defaultUnit;
}
}
_validatePayload(descriptor, msg, log) {
const schema = descriptor.payloadSchema;
if (!schema) return true;