@scm-js/plugin-api 1.41.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.d.ts +596 -54
  2. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -2403,6 +2403,12 @@ export interface PluginApi {
2403
2403
  * It is for a plugin that runs other code for a while and then takes it all back: the
2404
2404
  * API Playground runs each snippet in one. The child speaks as the same plugin — same
2405
2405
  * id, storage and log name.
2406
+ *
2407
+ * @example
2408
+ * // Register things for ten seconds, then take them all back.
2409
+ * const scope = api.scope();
2410
+ * scope.api.menu.add("Tools", { label: "Temporary item", run: () => scope.api.ui.status("Still here") });
2411
+ * setTimeout(() => scope.dispose(), 10_000);
2406
2412
  */
2407
2413
  scope(): PluginScope;
2408
2414
  }
@@ -2492,6 +2498,18 @@ export interface BuildStepInput {
2492
2498
  * A step that throws, or that the user stops waiting for, does not stop a save: the map is
2493
2499
  * written without the steps and a notice says why. Test Map stops instead, since a map
2494
2500
  * without its built part is not the one to test. A bare `.chk` is never built.
2501
+ *
2502
+ * @example
2503
+ * api.document.buildSteps.add({
2504
+ * id: "stamp",
2505
+ * label: "Build stamp",
2506
+ * // Only maps that carry this file are built; every other Save is untouched.
2507
+ * applies: () => api.document.extras.get("my-plugin\\stamp.txt") !== null,
2508
+ * async run({ map, purpose }) {
2509
+ * api.log(`building ${map.length} bytes for ${purpose}`);
2510
+ * return map; // an archive in, an archive out
2511
+ * },
2512
+ * });
2495
2513
  */
2496
2514
  export interface BuildStepSpec {
2497
2515
  /** Unique within the plugin; the file remembers `plugin id/id`. */
@@ -2543,6 +2561,14 @@ export interface BuildStepsApi {
2543
2561
  * The files stored in the map archive next to `staredit\scenario.chk`: custom sounds,
2544
2562
  * graphics, and anything a plugin wants to keep with the map. Names are archive paths
2545
2563
  * with backslashes (`staredit\wav\hello.wav`). They are written on Save.
2564
+ *
2565
+ * @example
2566
+ * // Keep a small JSON file inside the map, in a folder of the plugin's own.
2567
+ * const name = "my-plugin\\notes.json";
2568
+ * const bytes = api.document.extras.get(name);
2569
+ * const notes = bytes ? JSON.parse(new TextDecoder().decode(bytes)) : { opened: 0 };
2570
+ * notes.opened++;
2571
+ * api.document.extras.set(name, new TextEncoder().encode(JSON.stringify(notes)));
2546
2572
  */
2547
2573
  export interface ExtrasApi {
2548
2574
  list(): string[];
@@ -2619,6 +2645,13 @@ export interface DocumentApi {
2619
2645
  /**
2620
2646
  * The live scenario, for reading — see `ReadonlyScenario`. To change the map, use
2621
2647
  * `edit`, `update` or `sections`.
2648
+ *
2649
+ * @example
2650
+ * const scn = api.document.scenario();
2651
+ * if (scn) {
2652
+ * const marines = scn.units.filter((u) => u.unitId === 0);
2653
+ * api.ui.status(`${marines.length} marines on a ${scn.width} × ${scn.height} map`);
2654
+ * }
2622
2655
  */
2623
2656
  scenario(): ReadonlyScenario | null;
2624
2657
  /**
@@ -2635,9 +2668,10 @@ export interface DocumentApi {
2635
2668
  *
2636
2669
  * @example
2637
2670
  * // One undo entry called "Fill", however many operations it takes.
2638
- * const ground = api.terrain.types()[1].id; // the tileset's second flat terrain
2671
+ * await api.tileset.load(); // the async part first
2672
+ * const [, ground] = api.terrain.types(); // the tileset's second flat terrain; none without the graphics
2639
2673
  * const result = api.document.edit("Fill", (tx) => {
2640
- * tx.stampTerrain({ x0: 0, y0: 0, x1: 8, y1: 8 }, ground);
2674
+ * if (ground) tx.stampTerrain({ x0: 0, y0: 0, x1: 8, y1: 8 }, ground.id);
2641
2675
  * tx.placeUnit(api.consts.unit.startLocation, 0, 4 * api.consts.tile, 4 * api.consts.tile);
2642
2676
  * });
2643
2677
  * api.ui.status(`${result.tiles} tiles, ${result.units} units`);
@@ -2663,7 +2697,13 @@ export interface DocumentApi {
2663
2697
  update<R>(label: string, build: (tx: UpdateTransaction) => Sync<R>): UpdateResult;
2664
2698
  undo(): string | null;
2665
2699
  redo(): string | null;
2666
- /** The undo and redo stacks' tops — the labels the Edit menu shows — and their depths, without moving anything. */
2700
+ /**
2701
+ * The undo and redo stacks' tops — the labels the Edit menu shows — and their depths, without moving anything.
2702
+ *
2703
+ * @example
2704
+ * const { undo } = api.document.history();
2705
+ * if (undo && (await api.ui.confirm(`Undo "${undo}"?`))) api.document.undo();
2706
+ */
2667
2707
  history(): DocumentHistory;
2668
2708
  /**
2669
2709
  * The id of the map in front — the one `info()`, `scenario()` and every write are
@@ -2675,6 +2715,9 @@ export interface DocumentApi {
2675
2715
  * Every open map, in the order the editor lists them, the one in front marked `active`.
2676
2716
  * Empty with no map. The list changes only through `open`, `create`, `activate` and
2677
2717
  * `close` (by any plugin or the user), each of which fires the `"document"` event.
2718
+ *
2719
+ * @example
2720
+ * for (const map of api.document.list()) api.log(map.id, map.name, map.active ? "(in front)" : "");
2678
2721
  */
2679
2722
  list(): OpenDocumentInfo[];
2680
2723
  /**
@@ -2692,6 +2735,10 @@ export interface DocumentApi {
2692
2735
  * Scenario dialog comes first and the user may cancel. Resolves true once the file is
2693
2736
  * the map in front, false when the user kept the current map or the file could not be
2694
2737
  * read (the status bar says why).
2738
+ *
2739
+ * @example
2740
+ * const [file] = await api.ui.pickFiles({ accept: ".scx,.scm,.chk" });
2741
+ * if (file) await api.document.open(file, file.name, { into: "new" });
2695
2742
  */
2696
2743
  open(file: File | Blob | Uint8Array, fileName?: string, options?: OpenDocumentOptions): Promise<boolean>;
2697
2744
  /**
@@ -2700,11 +2747,19 @@ export interface DocumentApi {
2700
2747
  * the open map or in place of it as `open` decides, through the same unsaved-changes
2701
2748
  * gate. Resolves true once the new map is in front, false when the user kept the
2702
2749
  * current one.
2750
+ *
2751
+ * @example
2752
+ * await api.document.create({ width: 128, height: 128, tileset: "jungle", name: "Sketch", startLocations: 4 });
2703
2753
  */
2704
2754
  create(options: NewDocumentOptions): Promise<boolean>;
2705
2755
  /**
2706
2756
  * The open map as a file, as Save would write it — the remembered save options, the
2707
2757
  * archive extras included — unless `saveOptions` says otherwise. Null when no map is open.
2758
+ *
2759
+ * @example
2760
+ * // The bytes Save would write, without saving: ready to upload or hand to another tool.
2761
+ * const file = await api.document.export({ format: "scx" });
2762
+ * if (file) api.ui.status(`${file.name}: ${file.size} bytes`);
2708
2763
  */
2709
2764
  export(options?: ExportOptions): Promise<File | null>;
2710
2765
  /**
@@ -2730,6 +2785,11 @@ export interface DocumentApi {
2730
2785
  * is the one dial (32 is the game's art 1:1, 1 is a minimap); the other options
2731
2786
  * default as the dialog's do. Needs the tileset graphics — without them, or without
2732
2787
  * a map, null.
2788
+ *
2789
+ * @example
2790
+ * // A minimap-sized picture of the whole map, four pixels to a tile.
2791
+ * const png = await api.document.renderImage({ pixelsPerTile: 4 });
2792
+ * if (png) await api.ui.saveFile(png, "minimap.png");
2733
2793
  */
2734
2794
  renderImage(options?: Partial<MapImageOptions>): Promise<Blob | null>;
2735
2795
  /**
@@ -2781,6 +2841,11 @@ export interface RawEditResult {
2781
2841
  *
2782
2842
  * Indices are positions in `list()` and shift when a section is inserted or removed
2783
2843
  * before them; take a fresh `list()` after every edit.
2844
+ *
2845
+ * @example
2846
+ * // The file's sections, largest first.
2847
+ * const sections = api.document.sections.list().sort((a, b) => b.size - a.size);
2848
+ * for (const s of sections) api.log(s.name, `${s.size} bytes`, s.spec ? "" : "(unknown to the editor)");
2784
2849
  */
2785
2850
  export interface SectionsApi {
2786
2851
  /** Every occurrence in file order; empty without a map. */
@@ -2865,7 +2930,13 @@ export interface EditTransaction {
2865
2930
  groundAt(x: number, y: number): number;
2866
2931
  /** One tile into both sections. */
2867
2932
  setTile(x: number, y: number, id: number): void;
2868
- /** Many tiles into both sections; returns how many changed. */
2933
+ /**
2934
+ * Many tiles into both sections; returns how many changed.
2935
+ *
2936
+ * @example
2937
+ * // Copy the tile in the top-left corner over a 4 × 4 square beside it.
2938
+ * api.document.edit("Repeat a tile", (tx) => tx.setTiles({ x0: 1, y0: 0, x1: 5, y1: 4 }, tx.tileAt(0, 0)));
2939
+ */
2869
2940
  setTiles(cells: Cells, id: number): number;
2870
2941
  /**
2871
2942
  * The Rect brush: flat left/right pairs by column parity, one random variation per
@@ -2878,6 +2949,15 @@ export interface EditTransaction {
2878
2949
  * The isometric brush on one diamond: sets its ISOM value and regenerates the tiles
2879
2950
  * around it, cliffs and shores included. Needs ISOM and the tileset; returns whether
2880
2951
  * the terrain could be painted there.
2952
+ *
2953
+ * @example
2954
+ * await api.tileset.load();
2955
+ * const [terrain] = api.terrain.isomTypes();
2956
+ * if (terrain !== undefined) {
2957
+ * api.document.edit("Paint a patch", (tx) => {
2958
+ * for (const d of api.terrain.diamondsIn({ x0: 8, y0: 8, x1: 16, y1: 16 })) tx.paintIsom(d, terrain);
2959
+ * });
2960
+ * }
2881
2961
  */
2882
2962
  paintIsom(d: Diamond, terrainId: number, extent?: number): boolean;
2883
2963
  /**
@@ -2892,6 +2972,12 @@ export interface EditTransaction {
2892
2972
  makeUnit(unitId: number, owner: number, x: number, y: number): UnitRecord;
2893
2973
  addUnits(records: readonly UnitRecord[]): number[];
2894
2974
  removeUnits(indices: readonly number[]): number;
2975
+ /**
2976
+ * @example
2977
+ * // Give every selected unit to Player 2.
2978
+ * const selected = api.selection.units();
2979
+ * api.document.edit("Give to Player 2", (tx) => tx.updateUnits(selected, () => ({ owner: 1 })));
2980
+ */
2895
2981
  updateUnits(indices: readonly number[], patch: (u: UnitRecord) => Partial<UnitRecord>): number;
2896
2982
  /**
2897
2983
  * A unit the way the Units palette places one: a building snaps its placement box to
@@ -2900,7 +2986,18 @@ export interface EditTransaction {
2900
2986
  * if you want them. Returns the record's index.
2901
2987
  */
2902
2988
  placeUnit(unitId: number, owner: number, x: number, y: number): number;
2903
- /** Whether the Units palette's placement checks, with its current options, allow a unit of this type centred there. */
2989
+ /**
2990
+ * Whether the Units palette's placement checks, with its current options, allow a unit of this type centred there.
2991
+ *
2992
+ * @example
2993
+ * const marine = 0;
2994
+ * const x = 10 * api.consts.tile;
2995
+ * const y = 10 * api.consts.tile;
2996
+ * api.document.edit("Place a marine", (tx) => {
2997
+ * if (tx.canPlaceUnit(marine, x, y)) tx.placeUnit(marine, 0, x, y);
2998
+ * else tx.note("no room there");
2999
+ * });
3000
+ */
2904
3001
  canPlaceUnit(unitId: number, x: number, y: number): boolean;
2905
3002
  makeSprite(kind: SpriteKind, id: number, owner: number, x: number, y: number, opts?: {
2906
3003
  flipped?: boolean;
@@ -2931,13 +3028,38 @@ export interface EditTransaction {
2931
3028
  * unless `parts` says otherwise; `mode` is `"merge"` unless given. Terrain and doodads
2932
3029
  * from another tileset are refused, as a paste refuses them, and anything off the map is
2933
3030
  * skipped, both said in the result's `notes`.
3031
+ *
3032
+ * @example
3033
+ * // Stamp the marked area again ten tiles to the right, as one undo step.
3034
+ * const area = api.selection.markedArea();
3035
+ * const clip = area && api.clipboard.capture({ rect: area });
3036
+ * if (area && clip) api.document.edit("Stamp", (tx) => { tx.paste(clip, area.x0 + 10, area.y0); });
2934
3037
  */
2935
3038
  paste(clip: Clip, tx: number, ty: number, options?: PasteOptionsSpec): PasteResult;
2936
- /** A location in the lowest free slot (pixel bounds); returns the slot, or -1 when the table is full. */
3039
+ /**
3040
+ * A location in the lowest free slot (pixel bounds); returns the slot, or -1 when the table is full.
3041
+ *
3042
+ * @example
3043
+ * const t = api.consts.tile;
3044
+ * api.document.edit("Add a location", (tx) => {
3045
+ * const slot = tx.addLocation({ left: 4 * t, top: 4 * t, right: 8 * t, bottom: 8 * t }, "Beacon Alpha");
3046
+ * if (slot < 0) tx.note("the location table is full");
3047
+ * });
3048
+ */
2937
3049
  addLocation(bounds: Bounds, name?: string, elevationFlags?: number): number;
2938
3050
  editLocation(index: number, patch: LocationPatch): boolean;
2939
3051
  removeLocations(indices: readonly number[]): number;
2940
- /** Set (`"fog"`) or clear the `players` bits (bit n = player n + 1) over cells; creates MASK on first use. */
3052
+ /**
3053
+ * Set (`"fog"`) or clear the `players` bits (bit n = player n + 1) over cells; creates MASK on first use.
3054
+ *
3055
+ * @example
3056
+ * // Fog the left half of the map for Player 1 and Player 2.
3057
+ * const info = api.document.info();
3058
+ * if (info) {
3059
+ * const half = { x0: 0, y0: 0, x1: info.width >> 1, y1: info.height };
3060
+ * api.document.edit("Fog the west", (tx) => tx.setFog(half, 0b11, "fog"));
3061
+ * }
3062
+ */
2941
3063
  setFog(cells: Cells, players: number, mode: FogMode): number;
2942
3064
  /**
2943
3065
  * Tools ▸ Replace Terrain: every tile matching `from` (a flat terrain by ISOM id, or
@@ -2971,7 +3093,15 @@ export interface EditTransaction {
2971
3093
  * what the built-in brushes paint over. With the mode off, the cells as given.
2972
3094
  */
2973
3095
  mirror(cells: Cells): number[];
2974
- /** A map pixel and its images under the symmetry mode, the original first. */
3096
+ /**
3097
+ * A map pixel and its images under the symmetry mode, the original first.
3098
+ *
3099
+ * @example
3100
+ * // Follow Tools ▸ Symmetry: one marine at the point and one at each of its images.
3101
+ * api.document.edit("Marines", (tx) => {
3102
+ * for (const p of tx.mirrorPoint(320, 320)) tx.placeUnit(0, 0, p.x, p.y);
3103
+ * });
3104
+ */
2975
3105
  mirrorPoint(px: number, py: number): {
2976
3106
  x: number;
2977
3107
  y: number;
@@ -2982,6 +3112,12 @@ export interface EditTransaction {
2982
3112
  * Tools ▸ Auto-place Start Locations: one per player on a ring or in the corners, each
2983
3113
  * moved to the nearest spot the placement checks accept; `replace` removes the existing
2984
3114
  * ones first. Players count from 1.
3115
+ *
3116
+ * @example
3117
+ * api.document.edit("Four starts", (tx) => {
3118
+ * const { placed } = tx.placeStartLocations({ players: 4, replace: true });
3119
+ * tx.note(`${placed.filter(Boolean).length} of 4 placed`);
3120
+ * });
2985
3121
  */
2986
3122
  placeStartLocations(options: {
2987
3123
  players: number;
@@ -3034,6 +3170,16 @@ export interface TriggerListUpdate {
3034
3170
  * Parse the text format (`triggers.text.print`'s inverse) and append the result, or
3035
3171
  * replace the list with `replace: true`. Strings the text names are interned as it
3036
3172
  * parses. Throws with the line number when the text does not parse.
3173
+ *
3174
+ * @example
3175
+ * const source = `
3176
+ * Trigger("Player 1"){
3177
+ * Conditions:
3178
+ * Always();
3179
+ * Actions:
3180
+ * Display Text Message(Always Display, "Welcome.");
3181
+ * }`;
3182
+ * api.document.update("Add a greeting", (tx) => { tx.triggers.fromText(source); });
3037
3183
  */
3038
3184
  fromText(source: string, options?: {
3039
3185
  replace?: boolean;
@@ -3046,7 +3192,18 @@ export interface TriggerListUpdate {
3046
3192
  */
3047
3193
  export interface StringsUpdate {
3048
3194
  list(): (string | null)[];
3049
- /** The index of `text`: an identical entry when there is one, else a new one. 0 for `""`. */
3195
+ /**
3196
+ * The index of `text`: an identical entry when there is one, else a new one. 0 for `""`.
3197
+ *
3198
+ * @example
3199
+ * // Point the first trigger's first action at a new string, without touching the old one.
3200
+ * api.document.update("Reword", (tx) => {
3201
+ * const [trigger] = tx.triggers.list();
3202
+ * if (!trigger) return;
3203
+ * trigger.actions[0].text = tx.strings.intern("Hold the line!");
3204
+ * tx.triggers.replace(0, trigger);
3205
+ * });
3206
+ */
3050
3207
  intern(text: string): number;
3051
3208
  /** Overwrite one slot. */
3052
3209
  set(index: number, text: string): void;
@@ -3063,7 +3220,12 @@ export interface StringsUpdate {
3063
3220
  }
3064
3221
  export interface SwitchesUpdate {
3065
3222
  names(): string[];
3066
- /** Name a switch (0-based); `""` clears the name. Creates SWNM on the first one. */
3223
+ /**
3224
+ * Name a switch (0-based); `""` clears the name. Creates SWNM on the first one.
3225
+ *
3226
+ * @example
3227
+ * api.document.update("Name a switch", (tx) => tx.switches.setName(0, "Gate is open"));
3228
+ */
3067
3229
  setName(index: number, name: string): void;
3068
3230
  }
3069
3231
  /**
@@ -3111,6 +3273,15 @@ export interface UpdateTransaction {
3111
3273
  /** A line for the status bar, appended to the label. */
3112
3274
  note(text: string): void;
3113
3275
  }
3276
+ /**
3277
+ * @example
3278
+ * // Make Player 2 a Zerg computer.
3279
+ * const computer = api.names.playerTypes().find((t) => t.label === "Computer");
3280
+ * const zerg = api.names.races().find((r) => r.label === "Zerg");
3281
+ * if (computer && zerg) {
3282
+ * api.document.update("Player 2: Zerg computer", (tx) => { tx.players.set(1, { type: computer.value, race: zerg.value }); });
3283
+ * }
3284
+ */
3114
3285
  export interface PlayersUpdate {
3115
3286
  /** All 12 slots, 0-based, with the effective colour and force. */
3116
3287
  list(): PlayerSlotView[];
@@ -3128,6 +3299,10 @@ export interface UnitTypesUpdate {
3128
3299
  * Patch one type. Setting any number turns "use default" off for it (seeding the untouched
3129
3300
  * columns from the dat, as the dialog does); `useDefault: true` puts it back. Hit points are
3130
3301
  * whole points. `name` is the custom name (`""` restores the default); `available` edits PUNI.
3302
+ *
3303
+ * @example
3304
+ * // Marines with 80 hit points and a name of their own.
3305
+ * api.document.update("Tougher marines", (tx) => { tx.unitTypes.set(0, { hitPoints: 80, name: "Veteran Marine" }); });
3131
3306
  */
3132
3307
  set(unitId: number, patch: UnitTypePatch): boolean;
3133
3308
  }
@@ -3172,6 +3347,11 @@ export interface CuwpUpdate {
3172
3347
  * What the settings dialogs show, read without a transaction: the same views
3173
3348
  * `document.update`'s `tx.players` … `tx.techs` hand out. Every list is empty and every
3174
3349
  * single read null when no map is open.
3350
+ *
3351
+ * @example
3352
+ * for (const p of api.settings.players().slice(0, 8)) {
3353
+ * api.log(`Player ${p.slot + 1}: ${p.typeName}, ${p.raceName}, ${p.forceName ?? "no force"}`);
3354
+ * }
3175
3355
  */
3176
3356
  export interface SettingsApi {
3177
3357
  players(): PlayerSlotView[];
@@ -3197,6 +3377,12 @@ export interface SettingsApi {
3197
3377
  * TrigEdit shows it, each naming the record field it lives in and the kind of value it
3198
3378
  * is (a player group, a unit id, a location, a comparison, …). Everything that displays
3199
3379
  * or edits a trigger reads this table, the editor's own dialogs included.
3380
+ *
3381
+ * @example
3382
+ * // What the first action of the first trigger is, with the name and kind of each argument.
3383
+ * const action = api.triggers.list()[0]?.actions[0];
3384
+ * const def = action && api.triggers.defs.action(action.type);
3385
+ * if (def) api.log(def.name, def.args.map((a) => `${a.label} (${a.kind}) in .${a.field}`));
3200
3386
  */
3201
3387
  export interface TriggerDefsApi {
3202
3388
  conditions(): ConditionDef[];
@@ -3209,7 +3395,14 @@ export interface TriggerDefsApi {
3209
3395
  /** The value behind a label or one of its aliases, for parsing. */
3210
3396
  choiceValue(kind: ArgKind, text: string): number | undefined;
3211
3397
  }
3212
- /** Printing and parsing the text trigger format (File ▸ Import / Export ▸ Triggers). */
3398
+ /**
3399
+ * Printing and parsing the text trigger format (File ▸ Import / Export ▸ Triggers).
3400
+ *
3401
+ * @example
3402
+ * // Save the triggers as text, the form File ▸ Export ▸ Triggers writes.
3403
+ * const text = api.triggers.text.print(api.triggers.list());
3404
+ * await api.ui.saveFile(new Blob([text], { type: "text/plain" }), "triggers.txt");
3405
+ */
3213
3406
  export interface TriggerTextApi {
3214
3407
  print(triggers: readonly ReadonlyTrigger[], options?: {
3215
3408
  briefing?: boolean;
@@ -3252,7 +3445,15 @@ export interface TriggersApi {
3252
3445
  setPreserved(trigger: ReadonlyTrigger, on: boolean): TriggerRecord;
3253
3446
  /** The indices of the triggers any of these player groups own. */
3254
3447
  triggersFor(list: readonly ReadonlyTrigger[], groups: readonly number[]): number[];
3255
- /** The three lines the trigger list shows: players, conditions, actions. */
3448
+ /**
3449
+ * The three lines the trigger list shows: players, conditions, actions.
3450
+ *
3451
+ * @example
3452
+ * for (const trigger of api.triggers.list()) {
3453
+ * const line = api.triggers.summarize(trigger);
3454
+ * api.log(`${line.players}: ${line.conditions} → ${line.actions}`);
3455
+ * }
3456
+ */
3256
3457
  summarize(trigger: ReadonlyTrigger, briefing?: boolean): {
3257
3458
  players: string;
3258
3459
  conditions: string;
@@ -3282,6 +3483,12 @@ export interface TriggersApi {
3282
3483
  * (`Current Player` as the trigger's owners); a class unit (*Any unit*, *Men*, …)
3283
3484
  * counts for nothing, and an EUD player is an address, not a cell. What a plugin that
3284
3485
  * allocates counters or switches of its own must keep clear of.
3486
+ *
3487
+ * @example
3488
+ * // A switch no trigger touches, for a plugin that needs one of its own.
3489
+ * const taken = new Set(api.triggers.usage().switches);
3490
+ * const free = Array.from({ length: 256 }, (_, i) => i).find((i) => !taken.has(i));
3491
+ * api.log(free === undefined ? "every switch is in use" : `${api.names.switch(free)} is free`);
3285
3492
  */
3286
3493
  usage(list?: readonly ReadonlyTrigger[]): {
3287
3494
  cells: [
@@ -3329,6 +3536,25 @@ export interface TriggersApi {
3329
3536
  * place of the form; the Text Trigger Editor fences them in comments; Import Triggers
3330
3537
  * says what a replace would remove. The claim lives until `remove()` or the plugin's
3331
3538
  * deactivation; call `refresh()` after a rebuild so the editors ask `locate` again.
3539
+ *
3540
+ * @example
3541
+ * // Fingerprints of the triggers this plugin generated, kept as it generates them.
3542
+ * const mine = new Set<string>();
3543
+ * const isMine = (t: Parameters<typeof api.triggers.fingerprint>[0]) => mine.has(api.triggers.fingerprint(t));
3544
+ * const claim = api.triggers.claim({
3545
+ * label: "the wave generator",
3546
+ * badge: "waves",
3547
+ * locate(list) {
3548
+ * const start = list.findIndex(isMine);
3549
+ * if (start < 0) return null;
3550
+ * let count = 1;
3551
+ * while (start + count < list.length && isMine(list[start + count])) count++;
3552
+ * return { start, count };
3553
+ * },
3554
+ * describe: () => "Made by the wave generator. Change the waves there, not here.",
3555
+ * });
3556
+ * // After regenerating: update `mine`, then
3557
+ * claim.refresh();
3332
3558
  */
3333
3559
  claim(spec: TriggerClaimSpec): TriggerClaimHandle;
3334
3560
  /**
@@ -3399,6 +3625,14 @@ export interface StartLocation {
3399
3625
  *
3400
3626
  * Everything here is a read: nothing changes the map, and nothing throws without one
3401
3627
  * (an empty list, or null).
3628
+ *
3629
+ * @example
3630
+ * // Check Map's findings in the log, and the view taken to the first one that has a place.
3631
+ * const issues = api.query.validate();
3632
+ * for (const issue of issues) api.log(issue.level, issue.where, issue.text);
3633
+ * const target = issues.find((i) => i.target)?.target;
3634
+ * if (target?.kind === "dialog") api.ui.open(target.id);
3635
+ * else if (target) api.view.goTo(target);
3402
3636
  */
3403
3637
  export interface QueryApi {
3404
3638
  /** The topmost unit whose sprite box covers a map pixel, or -1. */
@@ -3417,7 +3651,13 @@ export interface QueryApi {
3417
3651
  locationsIn(rect: Rect): number[];
3418
3652
  /** The map's start locations, by player. */
3419
3653
  startLocations(): StartLocation[];
3420
- /** Whether a unit type may be placed centred there, and what stops it; null with no map. */
3654
+ /**
3655
+ * Whether a unit type may be placed centred there, and what stops it; null with no map.
3656
+ *
3657
+ * @example
3658
+ * const verdict = api.query.placement(106, 640, 640); // a Terran Command Center
3659
+ * api.ui.status(verdict?.problem ? `Blocked: ${verdict.reason}` : "It fits.");
3660
+ */
3421
3661
  placement(unitId: number, x: number, y: number): PlacementVerdict | null;
3422
3662
  /**
3423
3663
  * StarEdit's ground check for a doodad with its top-left tile at (tx, ty): `ok`, or the
@@ -3433,13 +3673,32 @@ export interface QueryApi {
3433
3673
  strings(): (string | null)[];
3434
3674
  /** Check Map: every issue the editor knows how to spot, with a `target` to go to. */
3435
3675
  validate(): Issue[];
3436
- /** Tools ▸ Statistics: tile, unit, resource and player counts. Null without a map. */
3676
+ /**
3677
+ * Tools ▸ Statistics: tile, unit, resource and player counts. Null without a map.
3678
+ *
3679
+ * @example
3680
+ * const stats = api.query.statistics();
3681
+ * if (stats) api.ui.status(`${stats.units.total} units, ${stats.resources.fields} mineral fields, ${stats.triggers.count} triggers`);
3682
+ */
3437
3683
  statistics(): MapStatistics | null;
3438
- /** The Ctrl+F search over units, locations, sprites, strings and triggers. */
3684
+ /**
3685
+ * The Ctrl+F search over units, locations, sprites, strings and triggers.
3686
+ *
3687
+ * @example
3688
+ * // Select every unit whose name contains "marine".
3689
+ * const hits = api.query.find({ kind: "units", query: "marine" });
3690
+ * api.selection.setLayer("units");
3691
+ * api.selection.setUnits(hits.map((h) => h.index));
3692
+ */
3439
3693
  find(options: FindOptions): FindResult[];
3440
3694
  /** Every record that refers to each string index. */
3441
3695
  stringUsage(): Map<number, StringUsage[]>;
3442
- /** String slots nothing refers to. */
3696
+ /**
3697
+ * String slots nothing refers to.
3698
+ *
3699
+ * @example
3700
+ * for (const index of api.query.unusedStrings()) api.log(index, api.names.string(index));
3701
+ */
3443
3702
  unusedStrings(): number[];
3444
3703
  }
3445
3704
  /** Where `view.goTo` should take the user. Issues from `query.validate` carry one. */
@@ -3483,10 +3742,18 @@ export interface ViewApi {
3483
3742
  * own work around the map takes that as its cue to stop.
3484
3743
  *
3485
3744
  * @example
3486
- * if (!(await api.view.reveal(rect))) following = false;
3745
+ * const shown = await api.view.reveal({ x0: 0, y0: 0, x1: 16, y1: 16 }, { fit: true });
3746
+ * if (!shown) api.log("the user moved the view first");
3487
3747
  */
3488
3748
  reveal(rect: Rect, options?: RevealOptions): Promise<boolean>;
3489
- /** Scroll to an object (and select it, for a unit, sprite or location). */
3749
+ /**
3750
+ * Scroll to an object (and select it, for a unit, sprite or location).
3751
+ *
3752
+ * @example
3753
+ * // Player 1's start location, selected and in the middle of the view.
3754
+ * const start = api.query.startLocations().find((s) => s.owner === 0);
3755
+ * if (start) api.view.goTo({ kind: "unit", index: start.index });
3756
+ */
3490
3757
  goTo(target: GoTo): void;
3491
3758
  /** The tile under the pointer, as the status bar shows it. */
3492
3759
  cursorTile(): {
@@ -3495,6 +3762,10 @@ export interface ViewApi {
3495
3762
  };
3496
3763
  /** The View menu's ticks: grid, locations, units, sprites, doodads, fog, … */
3497
3764
  flags(): ViewFlags;
3765
+ /**
3766
+ * @example
3767
+ * api.view.setFlags({ grid: !api.view.flags().grid });
3768
+ */
3498
3769
  setFlags(patch: Partial<ViewFlags>): void;
3499
3770
  /** Grid spacing in map pixels (32 = one tile). */
3500
3771
  gridSize(): number;
@@ -3505,8 +3776,9 @@ export interface ViewApi {
3505
3776
  * clean up.
3506
3777
  *
3507
3778
  * @example
3508
- * const rect = { x0: 4, y0: 4, x1: 12, y1: 12 };
3509
- * const r = api.document.edit("Fill", (tx) => tx.fillFlat(rect, api.terrain.types()[1].id));
3779
+ * // Mark the top-left corner for Player 1's fog, and show where that was.
3780
+ * const rect = { x0: 0, y0: 0, x1: 8, y1: 8 };
3781
+ * const r = api.document.edit("Fog a corner", (tx) => tx.setFog(rect, 0b1, "fog"));
3510
3782
  * if (r.changed) api.view.flash({ rect });
3511
3783
  */
3512
3784
  flash(target: FlashTarget): void;
@@ -3542,6 +3814,13 @@ export interface CommandInfo {
3542
3814
  * Named things a plugin can do, so a menu item, a hotkey, a context entry and another
3543
3815
  * plugin all reach the same one. `menu.add`, `contextMenu.add` and `hotkeys.add` take a
3544
3816
  * `command` id in place of a `run`.
3817
+ *
3818
+ * @example
3819
+ * api.commands.register({ id: "count", title: "Count units", run: () => api.document.scenario()?.units.length ?? 0 });
3820
+ * api.menu.add("Tools", { label: "Count units", command: "count" });
3821
+ * api.hotkeys.add("Ctrl+Alt+U", { command: "count" });
3822
+ * // Any plugin can run it by its full id, and gets what it returns.
3823
+ * api.log(api.commands.run(`${api.plugin.id}.count`));
3545
3824
  */
3546
3825
  export interface CommandsApi {
3547
3826
  register(spec: CommandSpec): Disposable;
@@ -3573,11 +3852,12 @@ export interface ServiceOptions {
3573
3852
  * the plugin (`"account"` → `"scmjs-dev.account"`); one with a dot is taken as it is.
3574
3853
  *
3575
3854
  * @example
3576
- * // The provider:
3577
- * api.services.provide("account", accountService);
3578
- * // A consumer:
3579
- * api.services.watch<AccountService>("scmjs-dev.account", (account) => {
3580
- * if (account) useSessionFrom(account); else useOwnSignIn();
3855
+ * interface Clock { now(): number }
3856
+ * // The provider. The name becomes "<plugin id>.clock".
3857
+ * api.services.provide<Clock>("clock", { now: () => Date.now() }, { version: 1 });
3858
+ * // A consumer, in any plugin, whichever of the two started first.
3859
+ * api.services.watch<Clock>(`${api.plugin.id}.clock`, (clock, info) => {
3860
+ * api.log(clock ? `clock v${info?.version}: ${clock.now()}` : "no clock yet");
3581
3861
  * });
3582
3862
  */
3583
3863
  export interface ServicesApi {
@@ -3607,6 +3887,27 @@ export interface RenderClipOptions {
3607
3887
  * The pictures the viewport draws, for a plugin's own lists and previews: the same
3608
3888
  * cached canvases, so asking for one costs nothing after the first time. Everything is
3609
3889
  * null when the graphics it needs were never extracted — a plugin shows a name instead.
3890
+ *
3891
+ * @example
3892
+ * // A marine in Player 2's colours. The picture is the editor's own cached canvas, so draw
3893
+ * // from it instead of moving it into your page.
3894
+ * await api.graphics.load();
3895
+ * api.ui.panel({
3896
+ * title: "Marine",
3897
+ * mount(body) {
3898
+ * const show = () => {
3899
+ * const picture = api.graphics.unitImage(0, { owner: 1 });
3900
+ * if (!picture) { body.textContent = api.names.unit(0); return; } // not loaded, or no graphics
3901
+ * const canvas = api.ui.el("canvas", { width: picture.width, height: picture.height });
3902
+ * canvas.getContext("2d")?.drawImage(picture.image, 0, 0);
3903
+ * body.replaceChildren(canvas);
3904
+ * };
3905
+ * api.graphics.requestUnit(0);
3906
+ * const loaded = api.graphics.onImageLoaded(show);
3907
+ * show();
3908
+ * return () => loaded.dispose();
3909
+ * },
3910
+ * });
3610
3911
  */
3611
3912
  export interface GraphicsApi {
3612
3913
  /** Whether the tileset graphics and the unit tables are in memory. */
@@ -3668,6 +3969,15 @@ export interface GraphicsApi {
3668
3969
  *
3669
3970
  * Everything is null until the tables are loaded (`load`), and stays null when the game
3670
3971
  * data was never extracted.
3972
+ *
3973
+ * @example
3974
+ * if (await api.data.load()) {
3975
+ * const units = api.data.units()!;
3976
+ * // units.dat keeps hit points in 256ths.
3977
+ * api.log(`Marine: ${units.hitPoints[0] / 256} HP, ${units.mineralCost[0]} minerals, ${api.data.race(0)}`);
3978
+ * } else {
3979
+ * api.log("no game data installed");
3980
+ * }
3671
3981
  */
3672
3982
  export interface DataApi {
3673
3983
  ready(): boolean;
@@ -3740,8 +4050,8 @@ export interface GameDataFiles {
3740
4050
  * // A mod plugin: install its files once, then draw with them.
3741
4051
  * const sets = await api.gameData.profiles();
3742
4052
  * if (!sets.some((p) => p.id === "my-mod")) {
3743
- * const files = await api.ui.pickFiles({ multiple: true });
3744
- * if (files) await api.gameData.install({ id: "my-mod", name: "My Mod" }, { archives: files.map((f) => ({ name: f.name, data: f })) });
4053
+ * const files = await api.ui.pickFiles({ multiple: true }); // an empty list when the user cancels
4054
+ * if (files.length > 0) await api.gameData.install({ id: "my-mod", name: "My Mod" }, { archives: files.map((f) => ({ name: f.name, data: f })) });
3745
4055
  * }
3746
4056
  * await api.gameData.select("my-mod");
3747
4057
  */
@@ -3786,6 +4096,16 @@ export interface GameDataApi {
3786
4096
  * type-checks and is then undefined. Anything a plugin needs while it runs has to come
3787
4097
  * off `api`. Without this a plugin writes the hex itself, which is how a sprite's kind
3788
4098
  * came to be read as `flags & 0x1000` in three places.
4099
+ *
4100
+ * @example
4101
+ * // Make the selected units invincible: the state bit, and the two masks that say it is set.
4102
+ * const { state, valid, used } = api.consts.unit;
4103
+ * const selected = api.selection.units();
4104
+ * api.document.edit("Invincible", (tx) => tx.updateUnits(selected, (u) => ({
4105
+ * stateFlags: u.stateFlags | state.Invincible,
4106
+ * validProperties: u.validProperties | valid.Invincible,
4107
+ * validStates: u.validStates | used.State,
4108
+ * })));
3789
4109
  */
3790
4110
  export interface ConstsApi {
3791
4111
  /** Map pixels to a tile. UNIT and THG2 store pixels; MTXM, MRGN and the brushes count tiles. */
@@ -3890,6 +4210,13 @@ export interface LocationConsts {
3890
4210
  * Generating a whole run of triggers is usually better done through
3891
4211
  * `tx.triggers.fromText`, which resolves names against the open map; these are for
3892
4212
  * editing a field of an existing record, and for reading one back.
4213
+ *
4214
+ * @example
4215
+ * // "At most 30 seconds on the countdown timer", written field by field.
4216
+ * const { condition, comparison } = api.consts.triggers;
4217
+ * const timer = api.triggers.newCondition(condition.CountdownTimer);
4218
+ * timer.comparison = comparison.AtMost;
4219
+ * timer.amount = 30;
3893
4220
  */
3894
4221
  export interface TriggerConsts {
3895
4222
  /** Condition `type`. */
@@ -3943,7 +4270,13 @@ export interface ActiveBrush {
3943
4270
  variation: number;
3944
4271
  }
3945
4272
  export interface TerrainApi {
3946
- /** Paintable flat terrains of the open map's tileset (empty without the graphics). */
4273
+ /**
4274
+ * Paintable flat terrains of the open map's tileset (empty without the graphics).
4275
+ *
4276
+ * @example
4277
+ * await api.tileset.load();
4278
+ * for (const t of api.terrain.types()) api.log(t.id, t.name, `height ${t.height}`, t.buildable ? "buildable" : "");
4279
+ */
3947
4280
  types(): TerrainType[];
3948
4281
  /** Terrain ids the isometric brush can paint on this tileset. */
3949
4282
  isomTypes(): number[];
@@ -3962,7 +4295,7 @@ export interface TerrainApi {
3962
4295
  *
3963
4296
  * @example
3964
4297
  * const r = await api.terrain.checkIsom();
3965
- * if (r?.stale) await api.document.edit("Rebuild ISOM", (tx) => { tx.rebuildIsom(); });
4298
+ * if (r?.stale) api.document.edit("Rebuild ISOM", (tx) => { tx.rebuildIsom(); });
3966
4299
  */
3967
4300
  checkIsom(): Promise<IsomReport | null>;
3968
4301
  tileInfo(id: number): TileInfo | null;
@@ -3971,6 +4304,12 @@ export interface TerrainApi {
3971
4304
  * the tile's own group when it is flat ground, else — under a cliff, a shore or a
3972
4305
  * doodad — what the ISOM lattice says the diamond there is. Null off the map, without
3973
4306
  * the tileset graphics, or when neither tells.
4307
+ *
4308
+ * @example
4309
+ * // Name the terrain under the pointer.
4310
+ * const { x, y } = api.view.cursorTile();
4311
+ * const id = api.terrain.terrainAt(x, y);
4312
+ * api.ui.status(api.terrain.types().find((t) => t.id === id)?.name ?? "unknown terrain");
3974
4313
  */
3975
4314
  terrainAt(tx: number, ty: number): number | null;
3976
4315
  /** The atlas average of a tile, packed `0xRRGGBB`, or null without graphics. */
@@ -3996,6 +4335,10 @@ export interface TerrainApi {
3996
4335
  flatGroupOf(terrainId: number): number;
3997
4336
  /** Tools ▸ Symmetry…: the mode the brushes paint and the palettes place under. */
3998
4337
  symmetry(): SymmetryMode;
4338
+ /**
4339
+ * @example
4340
+ * if (api.terrain.symmetryAvailable("rot180")) api.terrain.setSymmetry("rot180");
4341
+ */
3999
4342
  setSymmetry(mode: SymmetryMode): void;
4000
4343
  /** Whether a mode can run on the open map (the rotations and diagonals need a square one). */
4001
4344
  symmetryAvailable(mode: SymmetryMode): boolean;
@@ -4019,6 +4362,11 @@ export interface TilesetApi {
4019
4362
  * tileset changes nothing about the map: it is for `graphics.renderClip` drawing a clip
4020
4363
  * that came from a map on that tileset (a stamp, a recording), which answers null until
4021
4364
  * its tileset's graphics are loaded.
4365
+ *
4366
+ * @example
4367
+ * if (!(await api.tileset.load())) {
4368
+ * api.ui.toast({ kind: "warn", title: "No tileset graphics", detail: "Install them from Help ▸ Game Data…" });
4369
+ * }
4022
4370
  */
4023
4371
  load(tileset?: TilesetId): Promise<boolean>;
4024
4372
  raw(): LoadedTileset | null;
@@ -4060,6 +4408,14 @@ export interface PasteOptionsSpec {
4060
4408
  * The Cut / Copy / Paste layer. The clip is self-contained (it outlives the map it came
4061
4409
  * from and pastes into another) and shared with the user's own clipboard, so a plugin
4062
4410
  * that copies here is copying for the user too.
4411
+ *
4412
+ * @example
4413
+ * // Copy the top-left 8 × 8 tiles and paste them 16 tiles to the right.
4414
+ * const clip = api.clipboard.copy({ rect: { x0: 0, y0: 0, x1: 8, y1: 8 } });
4415
+ * if (clip) {
4416
+ * api.ui.status(api.clipboard.summary(clip));
4417
+ * api.clipboard.paste(16, 0);
4418
+ * }
4063
4419
  */
4064
4420
  export interface ClipboardApi {
4065
4421
  /** What is on the clipboard, or null. */
@@ -4101,6 +4457,16 @@ export interface ClipboardApi {
4101
4457
  /**
4102
4458
  * The file formats File ▸ Import / Export read and write, so a plugin can keep triggers
4103
4459
  * or strings outside the map in a form the editor (and SCMDraft) take back.
4460
+ *
4461
+ * @example
4462
+ * // The string table out to a text file, and back in after editing it elsewhere.
4463
+ * await api.ui.saveFile(new Blob([api.exchange.formatStrings()], { type: "text/plain" }), "strings.txt");
4464
+ * const [file] = await api.ui.pickFiles({ accept: ".txt" });
4465
+ * if (file) {
4466
+ * const text = await file.text();
4467
+ * const { errors } = api.exchange.parseStrings(text);
4468
+ * if (errors.length === 0) api.document.update("Import strings", (tx) => { tx.strings.import(text); });
4469
+ * }
4104
4470
  */
4105
4471
  export interface ExchangeApi {
4106
4472
  /** Raw 2400-byte TRIG records, SCMDraft's `.trg`; string indices are the map's own. */
@@ -4166,6 +4532,16 @@ export interface DoodadInfo {
4166
4532
  */
4167
4533
  required: number[];
4168
4534
  }
4535
+ /**
4536
+ * @example
4537
+ * // Place whatever the Units palette has picked, for the player it has picked.
4538
+ * const { unit, owner } = api.palette.active();
4539
+ * const at = await api.ui.pickTile({ prompt: `Click where the ${api.palette.unitName(unit)} goes` });
4540
+ * if (at) {
4541
+ * const t = api.consts.tile;
4542
+ * api.document.edit("Place", (tx) => { tx.placeUnit(unit, owner, at.x * t + t / 2, at.y * t + t / 2); });
4543
+ * }
4544
+ */
4169
4545
  export interface PaletteApi {
4170
4546
  active(): PaletteChoice;
4171
4547
  setActive(choice: Partial<PaletteChoice>): void;
@@ -4204,6 +4580,11 @@ export interface NamedValue {
4204
4580
  * carry the game's tables itself. The per-map ones (`string`, `location`, `switch`,
4205
4581
  * `player`) read the open scenario and answer a placeholder without one; the rest are
4206
4582
  * the editor's own tables — the same names StarEdit shows.
4583
+ *
4584
+ * @example
4585
+ * api.log(api.names.unit(0), api.names.playerGroup(api.consts.triggers.player.Player1), api.names.location(api.consts.location.anywhere));
4586
+ * // The list forms are ready for a drop-down.
4587
+ * const upgrade = api.ui.widgets.select(api.names.upgrades());
4207
4588
  */
4208
4589
  export interface NamesApi {
4209
4590
  /** StarEdit's name for a units.dat id; `Any unit` / `Men` / `Buildings` / `Factories` for the trigger classes 229–232 (228 is the game's `None`). */
@@ -4264,6 +4645,21 @@ export interface NamesApi {
4264
4645
  * alignment code on a line, stacking the pieces between them in one place: `stackedLines`
4265
4646
  * finds those lines and `flattenStacks` lays them out left to right. `runs` is one of the
4266
4647
  * renderers that cannot show them — `TextLine.align` is one alignment for the whole line.
4648
+ *
4649
+ * @example
4650
+ * const text = "\x07Mission\nKill them all"; // byte 0x07 colours the first line
4651
+ * api.log(api.text.plain(text)); // "Mission\nKill them all"
4652
+ * api.log(api.text.bleedingLines(text).map((l) => l.line)); // the second line inherits the colour
4653
+ * api.log(api.text.fixBleeding(text) === text); // false: a reset was written
4654
+ *
4655
+ * @example
4656
+ * // Repair every string in the map that has the problem.
4657
+ * const strings = api.query.strings();
4658
+ * api.document.update("Fix carried colours", (tx) => {
4659
+ * strings.forEach((s, i) => {
4660
+ * if (s && api.text.bleedingLines(s).length > 0) tx.strings.set(i, api.text.fixBleeding(s));
4661
+ * });
4662
+ * });
4267
4663
  */
4268
4664
  export interface TextApi {
4269
4665
  /** Every byte the game gives a meaning, in order; `rgb` is set for the colours only. */
@@ -4318,6 +4714,19 @@ export interface DialogTransfer {
4318
4714
  files: File[];
4319
4715
  text: string;
4320
4716
  }
4717
+ /**
4718
+ * @example
4719
+ * const name = api.ui.widgets.text({ value: api.document.info()?.name ?? "" });
4720
+ * api.ui.dialog({
4721
+ * title: "Rename the map",
4722
+ * size: "sm",
4723
+ * mount: (body) => body.append(api.ui.widgets.form([{ label: "Name", field: name }])),
4724
+ * buttons: [
4725
+ * { label: "Cancel" },
4726
+ * { label: "Rename", primary: true, run: () => { api.document.update("Rename", (tx) => tx.properties({ name: name.value })); } },
4727
+ * ],
4728
+ * });
4729
+ */
4321
4730
  export interface DialogSpec {
4322
4731
  title: string;
4323
4732
  size?: DialogSize;
@@ -4378,6 +4787,19 @@ export interface DialogHandle {
4378
4787
  * A panel floats over the map and does not block it: the user keeps drawing, scrolling
4379
4788
  * and using hotkeys while it is open (except while typing in one of its fields). It
4380
4789
  * is dragged by its title bar and closed with the × or `close()`.
4790
+ *
4791
+ * @example
4792
+ * // A unit count that follows the map, docked under the built-in panels.
4793
+ * api.ui.panel({
4794
+ * title: "Unit count",
4795
+ * dock: "right",
4796
+ * mount(body) {
4797
+ * const show = () => { body.textContent = `${api.document.scenario()?.units.length ?? 0} units`; };
4798
+ * show();
4799
+ * const sub = api.events.on("units", show);
4800
+ * return () => sub.dispose();
4801
+ * },
4802
+ * });
4381
4803
  */
4382
4804
  export interface PanelSpec {
4383
4805
  title: string;
@@ -4419,6 +4841,14 @@ export interface PanelHandle {
4419
4841
  * One cell in the status bar (`ui.statusItem`): a line of text with the plugin's icon,
4420
4842
  * a spinner while `busy`, and a click. It is how a plugin that works in the background
4421
4843
  * stays visible without a panel — "AI · working 12 s", "3 problems", "Synced".
4844
+ *
4845
+ * @example
4846
+ * const count = () => api.query.validate().filter((i) => i.level !== "info").length;
4847
+ * const cell = api.ui.statusItem({ text: `${count()} problems`, onClick: () => api.ui.open("validateMap") });
4848
+ * api.events.on("commit", () => {
4849
+ * const n = count();
4850
+ * cell.set({ text: `${n} problems`, warn: n > 0 });
4851
+ * });
4422
4852
  */
4423
4853
  export interface StatusItemSpec {
4424
4854
  text: string;
@@ -4436,7 +4866,17 @@ export interface StatusItemHandle {
4436
4866
  remove(): void;
4437
4867
  isShown(): boolean;
4438
4868
  }
4439
- /** A button on the map itself (`ui.mapButton`), in the row at its bottom-right corner. */
4869
+ /**
4870
+ * A button on the map itself (`ui.mapButton`), in the row at its bottom-right corner.
4871
+ *
4872
+ * @example
4873
+ * let notes = 3;
4874
+ * const button = api.ui.mapButton({
4875
+ * label: "Notes",
4876
+ * badge: notes,
4877
+ * onClick: () => { notes = 0; button.set({ badge: null }); },
4878
+ * });
4879
+ */
4440
4880
  export interface MapButtonSpec {
4441
4881
  /** A word or two; the button is as wide as it. */
4442
4882
  label: string;
@@ -4505,13 +4945,16 @@ export interface DialogSlotSpec {
4505
4945
  * which is the simpler shape when nothing needs undoing on Cancel.
4506
4946
  *
4507
4947
  * @example
4948
+ * const settings = api.storage.get("settings", { dock: "float" });
4949
+ * let pending: (() => void) | null = null;
4508
4950
  * api.ui.preferencesPage({
4509
4951
  * mount(body) {
4510
4952
  * const w = api.ui.widgets;
4511
4953
  * const dock = w.select([{ value: "float", label: "Floating" }, { value: "right", label: "Docked" }], { value: settings.dock });
4512
4954
  * body.append(w.form([{ label: "Panel", field: dock }]), w.hint("Where the panel opens."));
4513
- * pending = () => save({ dock: dock.value });
4955
+ * pending = () => api.storage.set("settings", { dock: dock.value });
4514
4956
  * },
4957
+ * // OK and Apply write it; Cancel leaves the stored value alone.
4515
4958
  * apply: () => pending?.(),
4516
4959
  * });
4517
4960
  */
@@ -4599,6 +5042,23 @@ export type MapToolStopReason =
4599
5042
  * release ahead of the active layer's own tools, hides the layer's brush ghost, and
4600
5043
  * lets it draw an overlay. The map stays visible and scrollable, and a panel can stay
4601
5044
  * open beside it — which is how a plugin gets a drawing mode of its own.
5045
+ *
5046
+ * @example
5047
+ * // Click to drop a marine, with a ring under the pointer. Esc or a right-click ends the tool.
5048
+ * let at: { px: number; py: number } | null = null;
5049
+ * const tool = api.ui.mapTool({
5050
+ * name: "Drop marines",
5051
+ * hint: "click to place, Esc to stop",
5052
+ * onMove(p) { at = p.inMap ? p : null; tool.redraw(); },
5053
+ * onDown(p) { api.document.edit("Drop a marine", (tx) => { tx.placeUnit(0, 0, p.px, p.py); }); },
5054
+ * draw(ctx, view) {
5055
+ * if (!at) return;
5056
+ * ctx.strokeStyle = "#ffd24a";
5057
+ * ctx.beginPath();
5058
+ * ctx.arc(view.x(at.px), view.y(at.py), view.tilePx / 2, 0, Math.PI * 2);
5059
+ * ctx.stroke();
5060
+ * },
5061
+ * });
4602
5062
  */
4603
5063
  export interface MapToolSpec {
4604
5064
  /** Shown in the viewport's HUD and the status bar while the tool runs. */
@@ -4639,6 +5099,23 @@ export type OverlayAbove =
4639
5099
  * as usual. It hears the pointer through `onHover`, which is how a readout follows the
4640
5100
  * mouse while the user places units. Register one at activation and keep the handle;
4641
5101
  * it goes away with the plugin.
5102
+ *
5103
+ * @example
5104
+ * // A ring twelve tiles wide round every start location, listed under View ▸ Overlays.
5105
+ * const overlay = api.ui.overlay({
5106
+ * name: "Start rings",
5107
+ * above: "objects",
5108
+ * draw(ctx, view) {
5109
+ * ctx.strokeStyle = "#ffd24a";
5110
+ * ctx.lineWidth = 2;
5111
+ * for (const start of api.query.startLocations()) {
5112
+ * ctx.beginPath();
5113
+ * ctx.arc(view.x(start.x), view.y(start.y), 12 * view.tilePx, 0, Math.PI * 2);
5114
+ * ctx.stroke();
5115
+ * }
5116
+ * },
5117
+ * });
5118
+ * api.events.on("units", () => overlay.redraw());
4642
5119
  */
4643
5120
  export interface OverlaySpec {
4644
5121
  /** Shown in View ▸ Overlays and the Layers panel. Unique per plugin. In English, like a menu label: shown through the plugin's registered catalogues. */
@@ -4698,7 +5175,12 @@ export interface UiApi {
4698
5175
  status(text: string): void;
4699
5176
  /** The status bar line as it stands. */
4700
5177
  statusText(): string;
4701
- /** A short notice over the map that leaves by itself — how Save reports; `ttl` 0 keeps it until dismissed. */
5178
+ /**
5179
+ * A short notice over the map that leaves by itself — how Save reports; `ttl` 0 keeps it until dismissed.
5180
+ *
5181
+ * @example
5182
+ * api.ui.toast({ kind: "ok", title: "Export finished", detail: "12 triggers written." });
5183
+ */
4702
5184
  toast(toast: {
4703
5185
  kind?: Toast["kind"];
4704
5186
  title: string;
@@ -4741,10 +5223,11 @@ export interface UiApi {
4741
5223
  * `dispose()` or the plugin.
4742
5224
  *
4743
5225
  * @example
5226
+ * // A button in Map Properties that tidies the name in the form. The user still presses OK.
4744
5227
  * api.ui.dialogSlot("mapProperties", {
4745
5228
  * mount(body, dlg) {
4746
- * body.append(api.ui.widgets.button("Suggest a name", { onClick: async () => {
4747
- * dlg.fields.name.set(await suggestName(dlg.fields.description.get()));
5229
+ * body.append(api.ui.widgets.button("Tidy name", { onClick: () => {
5230
+ * dlg.fields.name.set(dlg.fields.name.get().trim().replace(/\s+/g, " "));
4748
5231
  * } }));
4749
5232
  * },
4750
5233
  * });
@@ -4788,6 +5271,11 @@ export interface UiApi {
4788
5271
  * As `pickTile`, for an object: the user clicks a unit or a location. The viewport
4789
5272
  * outlines and names what is under the pointer as it moves; a click on nothing keeps
4790
5273
  * picking. Resolves with the kind and the record's index, or null as `pickArea` does.
5274
+ *
5275
+ * @example
5276
+ * const picked = await api.ui.pickObject({ prompt: "Click a unit", kinds: ["unit"] });
5277
+ * const unit = picked && api.document.scenario()?.units[picked.index];
5278
+ * if (unit) await api.ui.alert(`${api.names.unit(unit.unitId)}, owned by ${api.names.player(unit.owner)}`);
4791
5279
  */
4792
5280
  pickObject(options?: PickObjectOptions): Promise<PickedObject | null>;
4793
5281
  /**
@@ -4802,9 +5290,29 @@ export interface UiApi {
4802
5290
  confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
4803
5291
  /** Say something with a single OK. */
4804
5292
  alert(message: string, options?: ConfirmOptions): Promise<void>;
4805
- /** Ask for a line of text; null when the user cancelled. */
5293
+ /**
5294
+ * Ask for a line of text; null when the user cancelled.
5295
+ *
5296
+ * @example
5297
+ * const name = await api.ui.prompt("Name the map", { value: api.document.info()?.name, confirmLabel: "Rename" });
5298
+ * if (name) api.document.update("Rename", (tx) => tx.properties({ name }));
5299
+ */
4806
5300
  prompt(message: string, options?: PromptOptions): Promise<string | null>;
4807
- /** A progress bar over the map for long work, with an optional Cancel. */
5301
+ /**
5302
+ * A progress bar over the map for long work, with an optional Cancel.
5303
+ *
5304
+ * @example
5305
+ * const triggers = api.triggers.list();
5306
+ * const job = api.ui.progress("Reading triggers", { cancellable: true });
5307
+ * try {
5308
+ * for (let i = 0; i < triggers.length && !job.cancelled(); i++) {
5309
+ * job.report(i / triggers.length, `Trigger ${i + 1} of ${triggers.length}`);
5310
+ * await new Promise((next) => setTimeout(next, 20)); // the slow part of your work goes here
5311
+ * }
5312
+ * } finally {
5313
+ * job.done();
5314
+ * }
5315
+ */
4808
5316
  progress(label: string, options?: ProgressOptions): ProgressHandle;
4809
5317
  /** `el("div", { className: "row" }, …)`: the DOM helper the widgets are built from. */
4810
5318
  el<K extends keyof HTMLElementTagNameMap>(tag: K, props?: Record<string, unknown>, ...children: WidgetChild[]): HTMLElementTagNameMap[K];
@@ -5103,6 +5611,18 @@ export interface WidgetsApi {
5103
5611
  /** Small dimmed explanatory text. */
5104
5612
  hint(text: string): HTMLElement;
5105
5613
  separator(): HTMLElement;
5614
+ /**
5615
+ * @example
5616
+ * // Player 1's units in a dialog; picking one closes it and goes there.
5617
+ * const units = api.document.scenario()?.units ?? [];
5618
+ * const items = api.query.unitsOf(0).map((index) => ({ label: api.names.unit(units[index].unitId), value: index, index }));
5619
+ * api.ui.dialog({
5620
+ * title: "Player 1's units",
5621
+ * mount(body, dialog) {
5622
+ * body.append(api.ui.widgets.list(items, { height: 320, onPick: (index) => { dialog.close(); api.view.goTo({ kind: "unit", index }); } }));
5623
+ * },
5624
+ * });
5625
+ */
5106
5626
  list<T>(items: ListItem<T>[], options?: ListOptions<T>): HTMLElement;
5107
5627
  /** A turning ring: on its own to put beside your own text, or with a `label` beside it. */
5108
5628
  spinner(options?: SpinnerOptions): HTMLElement;
@@ -5172,18 +5692,21 @@ export interface MenuItemSpec {
5172
5692
  }
5173
5693
  export interface MenuApi {
5174
5694
  /**
5175
- * Items in the editor's menu bar.
5695
+ * Add an item to the editor's menu bar.
5176
5696
  *
5177
5697
  * @example
5178
5698
  * api.menu.add("Tools", {
5179
- * label: "Count units\u2026",
5699
+ * label: "Count units…",
5180
5700
  * icon: "plugin",
5701
+ * enabled: () => api.document.isOpen(),
5181
5702
  * run: () => api.ui.alert(`Player 1 has ${api.query.unitsOf(0).length} units.`),
5182
5703
  * });
5183
5704
  *
5184
5705
  * @example
5185
5706
  * // A path whose last segment names no submenu makes one, at the end of that menu.
5186
- * api.menu.add("Tools/My plugin", { label: "Settings\u2026", command: "settings" });
5707
+ * api.menu.add("Tools/My plugin", { label: "About…", run: () => api.ui.alert(`${api.plugin.name} ${api.plugin.version ?? ""}`) });
5708
+ * // `after` puts an item directly under a built-in one, named by its English label.
5709
+ * api.menu.add("File", { label: "Open from My Site…", after: "Open Recent", run: () => api.ui.status("…") });
5187
5710
  */
5188
5711
  add(path: MenuPath, item: MenuItemSpec): Disposable;
5189
5712
  }
@@ -5219,12 +5742,23 @@ export interface ContextItemSpec {
5219
5742
  command?: string;
5220
5743
  }
5221
5744
  export interface ContextMenuApi {
5745
+ /**
5746
+ * @example
5747
+ * // Right-click the map: copy the tile's position.
5748
+ * api.contextMenu.add("viewport", {
5749
+ * label: (ctx) => (ctx.tile ? `Copy position (${ctx.tile.x}, ${ctx.tile.y})` : "Copy position"),
5750
+ * enabled: (ctx) => ctx.tile !== null,
5751
+ * run: (ctx) => { if (ctx.tile) void navigator.clipboard.writeText(`${ctx.tile.x}, ${ctx.tile.y}`); },
5752
+ * });
5753
+ */
5222
5754
  add(surface: ContextSurface, item: ContextItemSpec): Disposable;
5223
5755
  }
5224
5756
  export interface HotkeyApi {
5225
- /** `"Ctrl+Shift+I"`, `"Alt+F9"`, `"F8"` — modifiers in any order, then a key name. *
5757
+ /**
5758
+ * `"Ctrl+Shift+I"`, `"Alt+F9"`, `"F8"` — modifiers in any order, then a key name.
5759
+ *
5226
5760
  * @example
5227
- * api.hotkeys.add("Ctrl+Shift+W", () => handle.setVisible(!handle.visible()));
5761
+ * api.hotkeys.add("Ctrl+Alt+G", () => api.view.setFlags({ grid: !api.view.flags().grid }));
5228
5762
  */
5229
5763
  add(combo: string, run: (() => void) | {
5230
5764
  command: string;
@@ -5272,9 +5806,11 @@ export type PluginEvent =
5272
5806
  * closing and switching maps are not commits — the `"document"` event says those.
5273
5807
  *
5274
5808
  * @example
5809
+ * // Flash where each change landed, and log what kind of change it was.
5275
5810
  * api.events.on("commit", (e) => {
5276
- * if (e.parts.terrain && e.area) redrawTiles(e.area);
5277
- * else if (e.reason === "whole" || e.reason === "remote") redrawAll();
5811
+ * if (e.area) api.view.flash({ rect: e.area, kind: "attention" });
5812
+ * const parts = Object.entries(e.parts).filter(([, changed]) => changed).map(([part]) => part);
5813
+ * api.log(e.reason, e.label, parts);
5278
5814
  * });
5279
5815
  */
5280
5816
  export interface CommitEvent {
@@ -5396,9 +5932,15 @@ export interface DocumentEvent {
5396
5932
  * `{x, select, …}` and, for Korean, `{name|을}` (the particle agrees with the value).
5397
5933
  *
5398
5934
  * @example
5399
- * api.i18n.register({ ko: { "Count units\u2026": "유닛 세기…", "{n} units": "유닛 {n}개" } });
5400
- * api.menu.add("Tools", { label: api.i18n.t("Count units\u2026"), run: () => api.ui.alert(api.i18n.t("{n} units", { n: 3 })) });
5401
- * api.events.on("language", () => relabel());
5935
+ * api.i18n.register({ ko: { "Count units…": "유닛 세기…", "{n} units": "유닛 {n}개" } });
5936
+ * // A menu label stays English: the menu shows it through the catalogue above.
5937
+ * api.menu.add("Tools", {
5938
+ * label: "Count units…",
5939
+ * run: () => api.ui.alert(api.i18n.t("{n} units", { n: api.document.scenario()?.units.length ?? 0 })),
5940
+ * });
5941
+ * // Anything you drew yourself is yours to redraw when the language changes.
5942
+ * const cell = api.ui.statusItem({ text: api.i18n.t("{n} units", { n: 0 }) });
5943
+ * api.events.on("language", () => cell.set({ text: api.i18n.t("{n} units", { n: 0 }) }));
5402
5944
  */
5403
5945
  export interface I18nApi {
5404
5946
  /** The editor's language, a BCP 47 primary tag: `"en"`, `"ko"`. */
@@ -5419,12 +5961,12 @@ export interface I18nApi {
5419
5961
  export interface EventsApi {
5420
5962
  /**
5421
5963
  * @example
5422
- * // Notifications, in activation order; a listener never intercepts what it hears.
5423
5964
  * api.events.on("document", (e) => {
5424
- * if (e.reason === "open") check(e.fileName);
5965
+ * if (e.reason === "open") api.ui.status(`Opened ${e.fileName ?? "a map"}`);
5425
5966
  * });
5426
- * api.events.on("terrain", () => redraw());
5427
- * api.events.on("commit", (e) => console.log(e.reason, e.label, e.area));
5967
+ * const selection = api.events.on("selection", () => api.log(`${api.selection.units().length} units selected`));
5968
+ * // Stop listening early; otherwise the listener goes when the plugin is turned off.
5969
+ * setTimeout(() => selection.dispose(), 60_000);
5428
5970
  */
5429
5971
  on(event: "document", listener: (event: DocumentEvent) => void): Disposable;
5430
5972
  on(event: "commit", listener: (event: CommitEvent) => void): Disposable;
@@ -5432,12 +5974,12 @@ export interface EventsApi {
5432
5974
  }
5433
5975
  export interface StorageApi {
5434
5976
  /**
5435
- * A small key-value store of the plugin's own, under its id in the browser's local
5436
- * storage and listed in Preferences \u25b8 Browser storage.
5977
+ * Read a value, or `fallback` when the key was never set.
5437
5978
  *
5438
5979
  * @example
5439
- * const opts = api.storage.get("options", { showGrid: true });
5440
- * api.storage.set("options", { ...opts, showGrid: false });
5980
+ * const options = api.storage.get("options", { showGrid: true });
5981
+ * const kept = api.storage.set("options", { ...options, showGrid: false });
5982
+ * if (!kept) api.ui.toast({ kind: "error", title: "Could not save the options", detail: "The browser's storage is full." });
5441
5983
  */
5442
5984
  get<T>(key: string, fallback: T): T;
5443
5985
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scm-js/plugin-api",
3
- "version": "1.41.0",
3
+ "version": "1.42.0",
4
4
  "description": "Type declarations for the scmJS plugin API",
5
5
  "license": "MIT",
6
6
  "types": "index.d.ts",