koala73/worldmonitor · error · ValidationError

disruptionPct must be an integer from 0 to 100 for a physica

Error message

disruptionPct must be an integer from 0 to 100 for a physical scenario

What it means

runScenario validates `disruptionPct` whenever it is supplied: it must be an integer in [0,100], and a physical (chokepoint) scenario template must actually have affectedChokepointIds. If the value is fractional, negative, above 100, or the template has no chokepoints, a ValidationError with field 'disruptionPct' is thrown.

Solutions

  1. Round or floor the percentage to an integer and clamp into 0..100 before calling: Math.max(0, Math.min(100, Math.round(pct)))
  2. Parse form/slider input with Number() and verify Number.isInteger before sending
  3. Omit disruptionPct entirely when it does not apply to the chosen scenario template
  4. Choose a scenario template that has affectedChokepointIds populated when a physical disruption percentage is required

Example fix

// before
await client.runScenario({ templateId, disruptionPct: slider.value });
// after
const pct = Math.max(0, Math.min(100, Math.round(Number(slider.value))));
if (!Number.isInteger(pct)) throw new Error('bad pct');
await client.runScenario({ templateId, disruptionPct: pct });
Defensive patterns

Strategy: validation

Validate before calling

const pct = req.disruptionPct;
if (pct !== undefined && (!Number.isInteger(pct) || pct < 0 || pct > 100)) {
  throw new Error('disruptionPct must be an integer 0..100');
}

Type guard

function isValidDisruptionPct(v: unknown): v is number {
  return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 100;
}

Try / catch

try {
  return await client.runScenario(req);
} catch (e) {
  if (e instanceof ValidationError && e.fields?.[0]?.field === 'disruptionPct') {
    return client.runScenario({ ...req, disruptionPct: clampPercent(req.disruptionPct) });
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling runScenario with disruptionPct = 12.5 (non-integer), -5, 150, NaN, or a string like "50"; or supplying disruptionPct against a scenario template whose affectedChokepointIds array is empty.

Common situations: UI slider emits fractional values; percentage computed from user input without rounding; client sends the value as a string from a form field; a custom/blank template without chokepoint mappings is combined with a physical disruption parameter.

Understand the failure class

Background: "value must be between 0 and 1" / "out of range" / "must not be negative" errors: fixing range-validation failures across open-source libraries — this error's family across 42 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/d8f58dc2b73c0d5a. Report an issue: GitHub.

Appendix: source

Thrown at server/worldmonitor/scenario/v1/run-scenario.ts:48

  req: RunScenarioRequest,
): Promise<RunScenarioResponse> {
  await requirePremiumRpcAccess(ctx.request, ApiError, 'PRO subscription required');

  const scenarioId = (req.scenarioId ?? '').trim();
  if (!scenarioId) {
    throw new ValidationError([{ field: 'scenarioId', description: 'scenarioId is required' }]);
  }
  const template = getScenarioTemplate(scenarioId);
  if (!template) {
    throw new ValidationError([{ field: 'scenarioId', description: `Unknown scenario: ${scenarioId}` }]);
  }

  const disruptionPct = req.disruptionPct;
  if (disruptionPct !== undefined && (
    !Number.isInteger(disruptionPct) || disruptionPct < 0 || disruptionPct > 100
    || template.affectedChokepointIds.length === 0
  )) {
    throw new ValidationError([{ field: 'disruptionPct', description: 'disruptionPct must be an integer from 0 to 100 for a physical scenario' }]);
  }

  const iso2 = req.iso2 ? req.iso2.trim() : '';
  if (iso2 && !/^[A-Z]{2}$/.test(iso2)) {
    throw new ValidationError([{ field: 'iso2', description: 'iso2 must be a 2-letter uppercase country code' }]);
  }

  // Queue-depth backpressure. Raw key: worker reads it unprefixed, so we must too.
  const [depthEntry] = await runRedisPipeline([['LLEN', QUEUE_KEY]], true);
  const depth = typeof depthEntry?.result === 'number' ? depthEntry.result : 0;
  if (depth > MAX_QUEUE_DEPTH) {
    throw new ApiError(429, 'Scenario queue is at capacity, please try again later', '');
  }

  const jobId = generateJobId();
  const payload = JSON.stringify({
    jobId,
    scenarioId,

View on GitHub (pinned to 7d06c8633d)