{"record":{"id":"d8f58dc2b73c0d5a","repo":"koala73/worldmonitor","slug":"disruptionpct-must-be-an-integer-from-0-to-100-for-a","errorCode":null,"errorMessage":"disruptionPct must be an integer from 0 to 100 for a physical scenario","messagePattern":"disruptionPct must be an integer from 0 to 100 for a physical scenario","errorType":"validation","errorClass":"ValidationError","httpStatus":400,"severity":"error","filePath":"server/worldmonitor/scenario/v1/run-scenario.ts","lineNumber":48,"sourceCode":"  req: RunScenarioRequest,\n): Promise<RunScenarioResponse> {\n  await requirePremiumRpcAccess(ctx.request, ApiError, 'PRO subscription required');\n\n  const scenarioId = (req.scenarioId ?? '').trim();\n  if (!scenarioId) {\n    throw new ValidationError([{ field: 'scenarioId', description: 'scenarioId is required' }]);\n  }\n  const template = getScenarioTemplate(scenarioId);\n  if (!template) {\n    throw new ValidationError([{ field: 'scenarioId', description: `Unknown scenario: ${scenarioId}` }]);\n  }\n\n  const disruptionPct = req.disruptionPct;\n  if (disruptionPct !== undefined && (\n    !Number.isInteger(disruptionPct) || disruptionPct < 0 || disruptionPct > 100\n    || template.affectedChokepointIds.length === 0\n  )) {\n    throw new ValidationError([{ field: 'disruptionPct', description: 'disruptionPct must be an integer from 0 to 100 for a physical scenario' }]);\n  }\n\n  const iso2 = req.iso2 ? req.iso2.trim() : '';\n  if (iso2 && !/^[A-Z]{2}$/.test(iso2)) {\n    throw new ValidationError([{ field: 'iso2', description: 'iso2 must be a 2-letter uppercase country code' }]);\n  }\n\n  // Queue-depth backpressure. Raw key: worker reads it unprefixed, so we must too.\n  const [depthEntry] = await runRedisPipeline([['LLEN', QUEUE_KEY]], true);\n  const depth = typeof depthEntry?.result === 'number' ? depthEntry.result : 0;\n  if (depth > MAX_QUEUE_DEPTH) {\n    throw new ApiError(429, 'Scenario queue is at capacity, please try again later', '');\n  }\n\n  const jobId = generateJobId();\n  const payload = JSON.stringify({\n    jobId,\n    scenarioId,","sourceCodeStart":30,"sourceCodeEnd":66,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/server/worldmonitor/scenario/v1/run-scenario.ts#L30-L66","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Round or floor the percentage to an integer and clamp into 0..100 before calling: Math.max(0, Math.min(100, Math.round(pct)))","Parse form/slider input with Number() and verify Number.isInteger before sending","Omit disruptionPct entirely when it does not apply to the chosen scenario template","Choose a scenario template that has affectedChokepointIds populated when a physical disruption percentage is required"],"exampleFix":"// before\nawait client.runScenario({ templateId, disruptionPct: slider.value });\n// after\nconst pct = Math.max(0, Math.min(100, Math.round(Number(slider.value))));\nif (!Number.isInteger(pct)) throw new Error('bad pct');\nawait client.runScenario({ templateId, disruptionPct: pct });","handlingStrategy":"validation","validationCode":"const pct = req.disruptionPct;\nif (pct !== undefined && (!Number.isInteger(pct) || pct < 0 || pct > 100)) {\n  throw new Error('disruptionPct must be an integer 0..100');\n}","typeGuard":"function isValidDisruptionPct(v: unknown): v is number {\n  return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 100;\n}","tryCatchPattern":"try {\n  return await client.runScenario(req);\n} catch (e) {\n  if (e instanceof ValidationError && e.fields?.[0]?.field === 'disruptionPct') {\n    return client.runScenario({ ...req, disruptionPct: clampPercent(req.disruptionPct) });\n  }\n  throw e;\n}","preventionTips":["Clamp and round slider/percentage inputs before sending","Convert form strings to Number and validate with Number.isInteger","Only send disruptionPct for templates that have chokepoints affected"],"tags":["validation","api","scenario","range-check"],"backgroundTag":"value-out-of-range","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-09-15T16:44:39.439Z","contentChangedAt":"2026-09-15T16:44:39.439Z","schemaVersion":2},"datasetVersion":"2026-09-15T18:17:12.389Z"}