Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fe-1262-scenario-range-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hashintel/petrinaut": patch
"@hashintel/petrinaut-core": patch
---

Scenario code fixes and helpers: array methods that read `.constructor` internally (`.map`, `.filter`, `.slice`, `.concat`, `.flatMap`) no longer throw inside scenario expressions and "Define as code" initial state; a Python-style `range(end)` / `range(start, end, step?)` helper is now in scope (and typed in the code editors); scenario compilation errors are surfaced in Simulation Settings instead of being silently ignored.
6 changes: 6 additions & 0 deletions .changeset/satellites-pre-deployed-constellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hashintel/petrinaut": patch
"@hashintel/petrinaut-core": patch
---

Add a "Pre-deployed Constellation" scenario to the Probabilistic Satellite Launcher example: its initial state is authored in code mode, building a ring of satellites with `range(...).map(...)` from two scenario parameters (`number_of_satellites`, `initial_altitude`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";

import { compileScenario } from "../simulation/authoring/scenario/compile-scenario";
import { probabilisticSatellitesSDCPN } from "./satellites-launcher";

const { petriNetDefinition } = probabilisticSatellitesSDCPN;

const constellationScenario = petriNetDefinition.scenarios?.find(
(scenario) => scenario.id === "scenario__pre_deployed_constellation",
);

const spacePlace = petriNetDefinition.places.find(
(place) => place.name === "Space",
);

describe("Pre-deployed Constellation scenario", () => {
it("compiles a ring of satellites from the scenario defaults", () => {
const outcome = compileScenario(
constellationScenario!,
petriNetDefinition.parameters,
petriNetDefinition.places,
petriNetDefinition.types,
);

expect(outcome.ok).toBe(true);
if (!outcome.ok) {
return;
}

const tokens = outcome.result.initialState[spacePlace!.id];
expect(Array.isArray(tokens)).toBe(true);
if (!Array.isArray(tokens)) {
return;
}

// Defaults: 8 satellites at planet_radius (50) + initial_altitude (40).
expect(tokens).toHaveLength(8);
expect(tokens[0]).toEqual({ x: 90, y: 0, direction: 0, velocity: 0 });
for (const [index, token] of tokens.entries()) {
const angle = Math.PI * 2 * (index / 8);
expect(token.x).toBeCloseTo(Math.cos(angle) * 90);
expect(token.y).toBeCloseTo(Math.sin(angle) * 90);
expect(token.direction).toBeCloseTo(angle);
expect(token.velocity).toBe(0);
}
});

it("scales with user-supplied scenario parameter values", () => {
const outcome = compileScenario(
constellationScenario!,
petriNetDefinition.parameters,
petriNetDefinition.places,
petriNetDefinition.types,
{
scenarioParameterValues: {
number_of_satellites: 3,
initial_altitude: 10,
},
},
);

expect(outcome.ok).toBe(true);
if (!outcome.ok) {
return;
}

const tokens = outcome.result.initialState[spacePlace!.id];
expect(Array.isArray(tokens)).toBe(true);
if (!Array.isArray(tokens)) {
return;
}

expect(tokens).toHaveLength(3);
// planet_radius (50) + initial_altitude (10)
expect(tokens[0]).toEqual({ x: 60, y: 0, direction: 0, velocity: 0 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import type { SDCPN } from "../types/sdcpn";
* distance; "Crash" checks distance from the planet surface. A custom SVG
* visualizer renders the planet, satellites, and velocity vectors. The Moon
* Orbit and Earth Orbit scenarios preset gravity, planet radius, and launch
* parameters.
* parameters. The Pre-deployed Constellation scenario builds its initial
* marking as code (`range(...).map(...)`) from scenario parameters.
*
* See `docs/examples.md` (Probabilistic Satellite Launcher).
*/
Expand Down Expand Up @@ -536,6 +537,34 @@ return sats.reduce((sum, s) => sum + s.velocity, 0) / sats.length;`,
},
initialState: { type: "per_place", content: {} },
},
{
id: "scenario__pre_deployed_constellation",
name: "Pre-deployed Constellation",
description:
"Starts with a configurable number of satellites already in orbit, evenly spaced in a ring around the planet. The initial state is defined as code from the scenario parameters.",
scenarioParameters: [
{ type: "integer", identifier: "number_of_satellites", default: 8 },
{ type: "real", identifier: "initial_altitude", default: 40 },
],
parameterOverrides: {},
initialState: {
type: "code",
content: `const distanceToCenter =
parameters.planet_radius + scenario.initial_altitude;

return {
Space: range(scenario.number_of_satellites).map((i) => {
const angle = Math.PI * 2 * (i / scenario.number_of_satellites);
return {
x: Math.cos(angle) * distanceToCenter,
y: Math.sin(angle) * distanceToCenter,
direction: angle,
velocity: 0,
};
}),
};`,
},
},
],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getTransitionLogicAvailability,
type PetrinautExtensionSettings,
} from "../../extensions";
import { SCENARIO_HELPER_TYPE_DECLARATIONS } from "../../simulation/authoring/scenario/helpers";
import { TYPE_POLICIES } from "../../simulation/engine/type-policies";
import { getItemFilePath } from "./file-paths";

Expand Down Expand Up @@ -467,6 +468,7 @@ export function generateScenarioSessionFiles(
`import type { Parameters } from "${parametersDefsPath}";`,
`declare const parameters: Parameters;`,
scenarioTypeDecl,
SCENARIO_HELPER_TYPE_DECLARATIONS,
].join("\n");

// Generate defs file (shared declarations for this session)
Expand Down Expand Up @@ -503,7 +505,14 @@ export function generateScenarioSessionFiles(

// Generate full code file for "Define as code" initial state β€” only when
// that mode is active so we don't lint stale code from the inactive mode.
if (session.initialStateAsCode && session.initialStateCode !== undefined) {
// Skip empty code β€” the runtime compiler ignores it (no initial state), so
// there's nothing to lint; wrapping an empty body in `__check` would raise
// a cryptic TS2355 ("must return a value") the moment the mode is toggled.
if (
session.initialStateAsCode &&
session.initialStateCode !== undefined &&
session.initialStateCode.trim() !== ""
) {
// Build return type: { "PlaceName"?: TokenType[], ... }
const colorById = new Map(sdcpn.types.map((c) => [c.id, c]));
const initialStateTypeImports: string[] = [];
Expand Down
106 changes: 106 additions & 0 deletions libs/@hashintel/petrinaut-core/src/lsp/lib/scenario-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";

import { SDCPNLanguageServer } from "./create-sdcpn-language-service";
import { getItemFilePath } from "./file-paths";
import { createSDCPN } from "./helper/create-sdcpn";

import type { ScenarioSessionData } from "./generate-virtual-files";

/**
* A net mirroring the satellites example: a coloured place plus a net-level
* parameter, both referenced from initial-state code.
*/
const SDCPN = createSDCPN({
types: [
{
id: "c1",
elements: [
{ name: "x", type: "real" },
{ name: "y", type: "real" },
{ name: "direction", type: "real" },
{ name: "velocity", type: "real" },
],
},
],
places: [{ id: "pl1", name: "Space", colorId: "c1" }],
parameters: [{ id: "p1", variableName: "planet_radius", type: "real" }],
});

const SESSION_DEFAULTS: Omit<ScenarioSessionData, "initialStateCode"> = {
sessionId: "sess1",
scenarioParameters: [
{ identifier: "number_of_satellites", type: "integer", default: 3 },
],
parameterOverrides: {},
initialState: {},
initialStateAsCode: true,
};

function getInitialStateDiagnostics(initialStateCode: string): string[] {
const server = new SDCPNLanguageServer();
server.syncFiles(SDCPN);
server.syncScenarioFiles(SDCPN, { ...SESSION_DEFAULTS, initialStateCode });
const filePath = getItemFilePath("scenario-initial-state-full-code", {
sessionId: SESSION_DEFAULTS.sessionId,
});
return [
...server.getSyntacticDiagnostics(filePath),
...server.getSemanticDiagnostics(filePath),
].map((diagnostic) =>
typeof diagnostic.messageText === "string"
? diagnostic.messageText
: diagnostic.messageText.messageText,
);
}

describe("scenario session initial-state-as-code diagnostics", () => {
it("reports no diagnostics for valid code using range, scenario, and parameters", () => {
const diagnostics = getInitialStateDiagnostics(`return {
Space: range(scenario.number_of_satellites).map(i => {
const angle = Math.PI * 2 * (i / scenario.number_of_satellites);
const altitude = parameters.planet_radius;
return {
x: Math.cos(angle) * altitude,
y: Math.sin(angle) * altitude,
direction: angle,
velocity: 0,
};
}),
};`);

expect(diagnostics).toEqual([]);
});

it("generates no virtual file (and thus no diagnostics) for empty code", () => {
// Regression: an empty "Define as code" editor used to produce
// TS2355 ("A function whose declared type is neither 'undefined',
// 'void', nor 'any' must return a value.") because the empty body was
// wrapped in `function __check(): InitialState {}`. The runtime
// compiler ignores empty code, so the LSP must too. Like empty
// parameter-override expressions, the virtual file is not generated at
// all β€” the diagnostics publisher only visits files that exist.
for (const emptyCode of ["", " \n "]) {
const server = new SDCPNLanguageServer();
server.syncFiles(SDCPN);
server.syncScenarioFiles(SDCPN, {
...SESSION_DEFAULTS,
initialStateCode: emptyCode,
});
const filePath = getItemFilePath("scenario-initial-state-full-code", {
sessionId: SESSION_DEFAULTS.sessionId,
});
expect(server.getFileContent(filePath)).toBeUndefined();
expect(
server.getScenarioFileNames(SESSION_DEFAULTS.sessionId),
).not.toContain(filePath);
}
});

it("still reports diagnostics for non-empty code with real errors", () => {
const diagnostics = getInitialStateDiagnostics("const unused = 1;");

expect(
diagnostics.some((message) => message.includes("must return a value")),
).toBe(true);
});
});
16 changes: 11 additions & 5 deletions libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ export const SHADOWED_GLOBALS = [
* expression body.
*
* To make the common form of that escape fail we temporarily replace the
* `.constructor` getter on the built-in prototypes below. The descriptors are
* `.constructor` getter on the built-in prototypes below with one returning
* `undefined`. The walk then dead-ends (`undefined.constructor` is a
* TypeError) while well-behaved user code keeps working: spec-internal
* `constructor` reads β€” `ArraySpeciesCreate` inside `Array.prototype.map`,
* `filter`, `slice`, `concat`, `flatMap`, `splice`, and `SpeciesConstructor`
* elsewhere β€” treat `undefined` as "use the built-in default". A throwing
* getter would break those methods for every user. The descriptors are
* restored in `finally` before any queued microtasks or other code runs.
*
* This is defense-in-depth, not an isolation boundary: hostile JavaScript has
Expand Down Expand Up @@ -117,13 +123,13 @@ export function runSandboxed<T>(action: () => T): T {
const saved = prototypes.map((p) =>
Object.getOwnPropertyDescriptor(p, "constructor"),
);
const blocked = () => {
throw new Error("Access to .constructor is blocked inside user code.");
};
// `undefined` rather than a throwing getter: spec-internal reads (array
// species lookups) must keep working β€” see the doc comment above.
const masked = () => undefined;

for (const p of prototypes) {
Object.defineProperty(p, "constructor", {
get: blocked,
get: masked,
configurable: true,
});
}
Expand Down
Loading
Loading