Repository navigation
Expand file tree
/
Copy patheditor.ts
More file actions
1575 lines (1485 loc) · 96.8 KB
/
Copy patheditor.ts
File metadata and controls
1575 lines (1485 loc) · 96.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* The TrigScript workspace: Monaco over the map's script files, checked live against
* declarations generated from the map (`compiler/declarations.ts`) and compiled — run —
* in the worker (`compile.ts`). Apply writes the script's `trigger()` records into the
* trigger list as its block (`service.ts`); saving or testing the map does the same by
* itself, and that is also when the programs are built — into the file, by the eudplib
* plugin. Play does both and hands the result to Test Map.
*
* It is laid out as VS Code is (`shell.ts`): the files in an Explorer with the programs
* and their variables under them, tabs over the editor with the run controls at their
* right, Problems / Output / Simulate as views of a panel under it, the state of the
* script in a status bar, and what needs an answer as a notification in the corner.
* Every command is also in Monaco's command palette (F1) with VS Code's own keys where
* it has one: F5 tests, Ctrl+Shift+B applies, Ctrl+J is the panel, Ctrl+B the Explorer.
* Nothing that appears moves the text.
*
* It opens two ways. As a full-screen dialog, for a long session on the script; or
* *beside the map*, as a resizable panel that blocks nothing, so the map and the code
* are worked on together: Ctrl+click on `locations.Beacon` shows the location, *Pick
* from map* puts the name of a clicked location or unit at the cursor, and a location
* the map renames offers to update the references. The same workspace mounts in either;
* a button switches.
*
* The files are the map's: every edit is written straight into the archive (they are
* members of the .scx, like a WAV), so closing loses nothing — only applying changes
* triggers. `main.ts` is where the script starts and cannot be renamed or removed.
* Monaco and TypeScript come from the CDN on first open.
*/
import type { PluginApi } from "@scm-js/plugin-api";
import { CompileSuperseded, retainCompileWorker } from "./compile";
import { ENTRY_FILE, normalizePath, type CompileResult, type ScriptDiagnostic, type ScriptFiles, type TriggerSource } from "./compiler/compiler";
import { HEAP_CELLS, HEAP_CELLS_MAX, HEAP_CELLS_MIN, STACK_CELLS_MAX, STACK_DEPTH, STACK_DEPTH_MAX, STACK_DEPTH_MIN, heapCells, stackDepth } from "./compiler/ir";
import { largestFrame } from "./compiler/recursion";
import { entryFor } from "./compiler/names";
import { printScript } from "./compiler/print";
import { Simulation, type SimulationEvent } from "./compiler/simulate";
import { actionDef } from "./vendor/triggerDefs";
import { PlayerGroup } from "./vendor/triggers";
import {
BUILD_TIME_CLASS, TEST_MARK_CLASS, createScriptEditor, loadMonaco, refreshLineHints, releaseScriptEditor, setCompilerMarkers, setLineHints, setDeclarations, setHoverVariables, setMapRefs, setMonacoTranslator,
type LocationRef, type MonacoApi, type ScriptEditor,
} from "./monaco";
import { renamedKeys, renamesInUse, replaceReferences, type Renamed } from "./refs";
import { FILE_NAME } from "./script";
import { isTestFile, type TestInfo, type TestRunOptions } from "./compiler/testing";
import { countTests, foldPlayers, idsUnder, marksOf, mergeReport, NO_TESTS, notesOf, playersLabel, stateOf, stateOfCounts, summary, testTree, warningsOf, type TestNode, type TestState, type TestStateName } from "./testState";
import { testName } from "./service";
import { applyMoves, buildTree, filesUnder, folderOf, movesOf, refuseMoves, tabLabels, validFolder, type Moves, type TreeNode } from "./tree";
import { ProgramSimulation, type ProgramEvent, type ProgramSimulationOptions, type SimBounds, type SimUnitInit } from "./compiler/simulateIr";
import { positionIn, type BuildRefusal, type MapNames, type ScriptArtifact, type ScriptService } from "./service";
import { COMPACT_LAYOUT, DEFAULT_LAYOUT, SHELL_STYLE, createShell, type IconName, type ShellLayout } from "./shell";
import type { EudplibBuildEvent, EudplibService } from "./vendor/eudplib";
import type { Translate } from "./i18n";
export const TEMPLATE = `// TrigScript: ordinary TypeScript that runs when you build. Every trigger() call becomes
// one trigger of the map, in order; code inside program(() => { … }) runs in the game.
// Names come from the map: units.*, locations.*, switches.*, players.*, P1 … P12.
import { trigger, program, bring, displayText, preserve, units, locations, P1, AllPlayers, CurrentPlayer } from "trigscript";
trigger(AllPlayers, [
bring(CurrentPlayer, units.AnyUnit, locations.Anywhere, ">=", 1),
], [
displayText("Hello from TrigScript."),
preserve(),
]);
// A program is code that runs in the game: variables, if, while, for, functions.
// It runs every frame from where it left off, until it sleeps. The underlined parts
// are computed when the script is applied, everything else runs in the game.
//
// A map with a program is built by the eudplib plugin when you save it, and needs
// StarCraft: Remastered to play. trigger() alone plays on every version.
//
// program(() => {
// let elapsed = 0;
// while (true) {
// elapsed++;
// if (elapsed == 10) displayText("Ten seconds have passed.");
// sleep(seconds(1));
// }
// }, { owner: P1 });
`;
/** What a new file starts with. */
export const FILE_TEMPLATE = `import { trigger, units, locations, P1 } from "trigscript";
// Helpers this file exports are imported by main.ts: import { … } from "./name";
`;
/** What a new test file starts with: one test, which passes. */
export const TEST_TEMPLATE = `import { test, expect, units, locations, P1 } from "trigscript";
// A test runs here, in the simulator, after every change that compiles: never in the game, and it costs the map nothing.
// sim is a world of its own for each test: the map's placed units and locations, and the script's programs at frame 0.
test("the script runs for a second", (sim) => {
sim.seconds(1);
expect(sim.frame).toBe(24);
});
`;
/** Frames the Simulate button runs. */
/** Twenty seconds of the game at Fastest. */
export const SIMULATE_FRAMES = 480;
/** Start Location: a marker of the map, not a unit of the game. */
const START_LOCATION_UNIT = 214;
/** OWNR: the slots that are in a game. */
const OWNER_COMPUTER = 5;
const OWNER_HUMAN = 6;
/** How many of a simulation's events the list shows before it says how many more there were. */
const SIMULATE_ROWS = 200;
/** The most lines with a fault the Simulate view lists. */
const SIMULATE_FAULTS = 20;
const CHECK_DELAY_MS = 350;
/** Lines the Output view keeps. */
const OUTPUT_LINES = 2000;
/** How long a good outcome stays in the status bar, and an answer in the corner. */
const STATUS_MS = 10_000;
const NOTICE_MS = 8_000;
/** When running every test takes longer than this, only the open file's run by themselves after a compile. */
const TESTS_SLOW_MS = 2_000;
/** The key the shortcuts are written with. */
const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "Cmd" : "Ctrl";
/** The panel beside the map starts this big; the user resizes it and the size is kept for the session. */
export const PANEL_WIDTH = 760;
export const PANEL_HEIGHT = 540;
const STYLE = `${SHELL_STYLE}
.tsd .tsd-settings { box-sizing: border-box; height: 100%; overflow: auto; padding: 8px 20px 12px; font-size: var(--fs-md); }
.tsd .tsd-settings > * { max-width: 680px; }
.tsd .tsd-settings h4 { margin: 0 0 4px; font-size: var(--fs-md); font-weight: 600; }
.tsd .tsd-settings p { margin: 0 0 8px; color: var(--text-dim); line-height: 1.45; }
.tsd .tsd-settings .row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.tsd .tsd-settings input { width: 110px; padding: 2px 6px; font: inherit; font-family: var(--font-mono); color: var(--text); background: var(--bg-1); border: 1px solid var(--border); border-radius: 2px; }
.tsd .tsd-settings input:focus { outline: 1px solid var(--accent); outline-offset: -1px; }
.tsd .tsd-settings button { padding: 2px 8px; font: inherit; color: var(--text); background: var(--bg-3); border: 1px solid var(--border); border-radius: 2px; cursor: pointer; }
.tsd .tsd-settings button:disabled { opacity: 0.5; cursor: default; }
.tsd .tsd-settings .now { color: var(--text-faint); }
.tsd .tsd-list { margin: 0; padding: 2px 0; list-style: none; font-size: var(--fs-md); }
.tsd .tsd-list li { display: flex; align-items: baseline; gap: 8px; padding: 2px 12px 2px 20px; line-height: 18px; cursor: pointer; }
.tsd .tsd-list li:hover { background: var(--bg-3); }
.tsd .tsd-list li.tsd-plain { cursor: default; }
.tsd .tsd-list .tsd-i { align-self: center; font-size: 14px; color: var(--danger); }
.tsd .tsd-list .msg { flex: 0 1 auto; min-width: 0; white-space: pre-wrap; }
.tsd .tsd-list .src, .tsd .tsd-list .where { flex: none; color: var(--text-faint); font-size: var(--fs-sm); }
.tsd .tsd-list .frame { flex: none; min-width: 72px; color: var(--text-faint); font-family: var(--font-mono); font-size: var(--fs-sm); }
.tsd .tsd-list .note { color: var(--text-dim); }
.tsd .tsd-output { margin: 0; padding: 4px 20px; font-family: var(--font-mono); font-size: var(--fs-sm); line-height: 17px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-dim); }
.tsd .tsd-list li.tsd-warning .tsd-i { color: var(--warn); }
.tsd .tsd-rows .tsd-empty { padding: 6px 12px 6px 20px; color: var(--text-faint); white-space: normal; line-height: 1.45; cursor: default; }
.tsd .tsd-test-passed { color: var(--ok); }
.tsd .tsd-test-failed { color: var(--danger); }
.tsd .tsd-test-none, .tsd .tsd-test-skipped { color: var(--text-faint); }
.${TEST_MARK_CLASS} { cursor: pointer; font: normal normal normal 14px/18px codicon; text-align: center; color: var(--text-faint); }
.${TEST_MARK_CLASS}::before { content: "\\eabc"; }
.${TEST_MARK_CLASS}-passed { color: var(--ok); }
.${TEST_MARK_CLASS}-passed::before { content: "\\eba4"; }
.${TEST_MARK_CLASS}-failed { color: var(--danger); }
.${TEST_MARK_CLASS}-failed::before { content: "\\ea87"; }
.${TEST_MARK_CLASS}-skipped::before { content: "\\eabd"; }
.${TEST_MARK_CLASS}-note { color: var(--danger); font-style: italic; opacity: 0.9; }
.${BUILD_TIME_CLASS} { text-decoration: underline dotted rgba(153, 162, 179, 0.55); text-underline-offset: 3px; }
`;
/** "P1", "all players", "Force 2", "P1, P2" — who a program runs for. */
function ownerLabel(p: { owners: number[] }, t: Translate): string {
return p.owners.map((o) => (o === PlayerGroup.AllPlayers ? t("all players") : o >= PlayerGroup.Force1 && o <= PlayerGroup.Force4 ? t("Force {n}", { n: o - PlayerGroup.Force1 + 1 }) : `P${o + 1}`)).join(", ");
}
/** One line of the simulation log: "Display Text — hello". */
function describeEvent(e: SimulationEvent | ProgramEvent): string {
const name = actionDef(e.action.type)?.name ?? `Action ${e.action.type}`;
return e.text !== undefined ? `${name} — ${e.text}` : name;
}
/** How the workspace is shown: a full-screen dialog, or a resizable panel beside the map. */
export type WorkspaceMode = "dialog" | "panel";
export interface OpenOptions {
file?: string;
line?: number;
/** Beside the map (a panel) rather than in a dialog. Unset: however it is open already, else a dialog. */
dock?: boolean;
/** Start a Pick from map as soon as the editor is up. */
pick?: boolean;
}
interface OpenWorkspace {
mode: WorkspaceMode;
isOpen(): boolean;
close(): void;
reveal(file?: string, line?: number): void;
/** The file and line the cursor is on, to carry across a mode switch. */
cursor(): { file: string; line: number } | null;
}
let current: OpenWorkspace | null = null;
/** The editor's language changed: the open workspace is built again in it, in the same place and at the same line. */
export function relabelScriptEditor(svc: ScriptService): void {
if (!current?.isOpen()) return;
const at = current.cursor();
const dock = current.mode === "panel";
current.close();
current = null;
openScriptEditor(svc, { dock, file: at?.file, line: at?.line });
}
/** Open the workspace (or bring the open one to the file and line, or move it to the other mode when `dock` says so). */
export function openScriptEditor(svc: ScriptService, options: OpenOptions = {}): void {
const api = svc.api;
if (current?.isOpen()) {
const wanted: WorkspaceMode | null = options.dock === undefined ? null : options.dock ? "panel" : "dialog";
if ((!wanted || wanted === current.mode) && !options.pick) { current.reveal(options.file, options.line); return; }
const at = current.cursor();
const modeNow = current.mode;
current.close();
current = null;
if (options.file === undefined && at) options = { ...options, file: at.file, line: at.line };
if (options.dock === undefined) options = { ...options, dock: modeNow === "panel" };
}
if (!api.document.isOpen()) { api.ui.toast({ kind: "info", title: api.i18n.t("Open or create a map first.") }); return; }
const mode: WorkspaceMode = options.dock ? "panel" : "dialog";
const ws = createWorkspace(svc, options, mode);
if (mode === "dialog") {
const handle = api.ui.dialog({
title: "TrigScript",
size: "full",
tall: true,
// The workspace is the whole dialog, edge to edge, with its own status bar for a footer.
flush: true,
buttons: [],
// Escape inside the workspace dismisses its own popups (suggestions, a menu, the palette); it must not close the dialog.
keepOpenOnEscape: (target) => target instanceof Node && ws.root.contains(target),
mount(body, dialog) {
body.append(ws.root);
return ws.attach(() => dialog.close());
},
});
current = { mode, isOpen: () => handle.isOpen(), close: () => handle.close(), reveal: ws.reveal, cursor: ws.cursor };
} else {
const handle = api.ui.panel({
title: "TrigScript",
width: PANEL_WIDTH,
height: PANEL_HEIGHT,
resizable: true,
flush: true,
mount(body, panel) {
body.append(ws.root);
return ws.attach(() => panel.close());
},
});
current = { mode, isOpen: () => handle.isOpen(), close: () => handle.close(), reveal: ws.reveal, cursor: ws.cursor };
}
}
interface Workspace {
root: HTMLElement;
/** Subscribe, load Monaco, and hand back the cleanup; `close` shuts the shell (the map went away). */
attach(close: () => void): () => void;
reveal(file?: string, line?: number): void;
cursor(): { file: string; line: number } | null;
}
function createWorkspace(svc: ScriptService, options: OpenOptions, mode: WorkspaceMode): Workspace {
const api: PluginApi = svc.api;
const el = api.ui.el;
const w = api.ui.widgets;
const t: Translate = (text, params) => api.i18n.t(text, params);
setMonacoTranslator(t);
const initial = svc.state();
let files: ScriptFiles = initial?.files ?? { [ENTRY_FILE]: TEMPLATE };
const fresh = !initial?.files;
let generated: MapNames | null = svc.names();
let editor: ScriptEditor | null = null;
let monaco: MonacoApi | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let ready = false;
/** Monaco could not be loaded: there is nothing to wait for. */
let failed = false;
let building = false;
let picking = false;
let diagnostics: ScriptDiagnostic[] = [];
let result: CompileResult | null = null;
let simulation: { sim: Simulation; programs: ProgramSimulation | null; result: CompileResult } | null = null;
/** The programs and variables of the last compile that went through: a typo does not empty the Explorer. */
let outline: Pick<CompileResult, "programs" | "variables"> | null = null;
/** The eudplib plugin's service, followed while the workspace is open. */
let library: EudplibService | null = svc.library();
/** Play is under way: the script applied, the map built as Save would, handed to Test Map. */
let testing = false;
let cancelled = false;
/** Renames the map made to things the script names, waiting for the user's word. */
let renames: { object: string; list: Renamed[] }[] = [];
/** With a stale block that can be taken apart: the user chose to append a fresh block instead of replacing what is still the build's. */
let appendInstead = false;
/* ── The frame ── */
const layoutKey = mode === "panel" ? "layout.panel" : "layout.dialog";
const shell = createShell({
el,
compact: mode === "panel",
layout: { ...(mode === "panel" ? COMPACT_LAYOUT : DEFAULT_LAYOUT), ...api.storage.get<Partial<ShellLayout>>(layoutKey, {}) },
onLayout: (layout) => { api.storage.set(layoutKey, layout); },
onTabSelect: (id) => openFile(id),
onTabClose: (id) => closeTab(id),
t,
});
const root = shell.root;
root.prepend(el("style", undefined, STYLE));
const hostEl = shell.editorHost;
// "Test" is what the script's test() blocks are; building the map and starting it in the game is Play.
const testAction = shell.action({ icon: "play", title: t("Play (F5): apply the script, build the map as Save would and start it in the game through Test Map"), run: () => { void test(); } });
const simulateAction = shell.action({ icon: "beaker", title: t("Simulate ({mod}+F5): run the script's triggers and programs for {frames} frames ({seconds} seconds of the game) in a built-in interpreter and list what happened", { mod: MOD, frames: SIMULATE_FRAMES, seconds: SIMULATE_FRAMES / 24 }), run: () => { void simulateNow(); } });
const applyAction = shell.action({ icon: "check", title: t("Apply ({mod}+Shift+B): run the script and write its triggers into the map now. Saving and testing the map do this by themselves; programs are built into the saved file, not into the trigger list", { mod: MOD }), run: () => { void build(); } });
const pickAction = shell.action({ icon: "target", title: t("Pick from map: click a location or a unit on the map to put its name at the cursor"), run: () => { void pickFromMap(); } });
shell.action(mode === "dialog"
? { icon: "multiple-windows", title: t("Beside the map: open the script as a panel, so the map stays in reach"), run: () => switchMode() }
: { icon: "screen-full", title: t("In a window: open the script full-screen"), run: () => switchMode() });
const moreAction = shell.action({ icon: "ellipsis", title: t("More actions…"), run: () => shell.menu(moreAction.element, [
menuItem("save"),
null,
...["import", "newFile", "newFolder"].map(menuItem),
null,
...["runTests", "runFailedTests"].map(menuItem),
null,
...["problems", "output", "panel", "explorer", "testing"].map(menuItem),
null,
menuItem("settings"),
null,
{ label: t("Command Palette…"), keys: "F1", disabled: !ready, run: () => palette() },
]) });
const fileList = el("ul", { className: "tsd-rows" });
shell.section({ title: t("Script"), actions: [
{ icon: "new-file", title: t("New file…: main.ts imports it with import { … } from \"./name\""), run: () => { void newFile(); } },
{ icon: "new-folder", title: t("New folder…: a folder is there while a file is in it, so its first file is asked for next"), run: () => { void newFolder(); } },
] }).body.append(fileList);
const programList = el("ul", { className: "tsd-rows" });
const programsSection = shell.section({ title: t("Programs") });
programsSection.body.append(programList);
programsSection.setHidden(true);
const problemsView = shell.view({ id: "problems", title: t("Problems") });
const outputEl = el("pre", { className: "tsd-output" });
const outputView = shell.view({ id: "output", title: t("Output"), onShow: () => renderOutput(true), actions: [{ icon: "clear-all", title: t("Clear the output"), run: () => { output = []; renderOutput(); } }] });
const simulateView = shell.view({ id: "simulate", title: t("Simulate") });
const resultsView = shell.view({ id: "tests", title: t("Test Results") });
const settingsView = shell.view({ id: "settings", title: t("Settings"), onShow: () => renderSettings() });
const testingList = el("ul", { className: "tsd-rows" });
const testingView = shell.sidebarView({ id: "testing", title: t("Testing"), icon: "beaker", actions: [
{ icon: "run-all", title: t("Run all tests"), run: () => { void runTests(); } },
{ icon: "run-errors", title: t("Run the tests that failed"), run: () => { void runFailedTests(); } },
{ icon: "filter", title: t("Show only the tests that fail"), run: () => { onlyFailing = !onlyFailing; renderTesting(); } },
] });
testingView.body.append(testingList);
/**
* The map's script settings (`script.ts#ScriptSettings`): kept in the map beside the script, because the built map
* depends on them — how much memory the arrays that grow share, and how deep a function that calls itself may go.
*/
function renderSettings() {
const open = api.document.isOpen();
const settings = svc.settings();
const count = (n: number) => n.toLocaleString("en-US");
const size = (cells: number) => (cells * 4 >= 1 << 20 ? `${(cells * 4 / (1 << 20)).toFixed(cells * 4 % (1 << 20) ? 1 : 0)} MB` : `${Math.round(cells * 4 / 1024)} KB`);
/** One number of the settings: the field, what it comes to, and the way back to the default. */
const row = (o: { key: "heapCells" | "stackDepth"; min: number; max: number; step: number; normal: number; fit: (v: unknown) => number; said: (now: number) => string }) => {
const now = settings[o.key];
const input = el("input", { type: "number", min: String(o.min), max: String(o.max), step: String(o.step), value: String(now), disabled: !open }) as HTMLInputElement;
const reset = el("button", { type: "button", disabled: !open || now === o.normal }, t("Default ({n})", { n: count(o.normal) })) as HTMLButtonElement;
const commit = (value: unknown) => {
const next = o.fit(typeof value === "number" && Number.isFinite(value) ? value : o.normal);
if (next !== svc.settings()[o.key]) { svc.writeSettings({ ...svc.settings(), [o.key]: next }); simulation = null; renderSimulation(); }
renderSettings();
};
input.addEventListener("change", () => commit(input.value === "" ? o.normal : Number(input.value)));
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") input.blur(); });
reset.addEventListener("click", () => commit(o.normal));
return el("div", { className: "row" }, input, el("span", { className: "now" }, o.said(now)), reset);
};
const guardRow = () => {
const box = el("input", { type: "checkbox", checked: settings.testsGuardBuild, disabled: !open }) as HTMLInputElement;
box.style.width = "auto";
box.addEventListener("change", () => { svc.writeSettings({ ...svc.settings(), testsGuardBuild: box.checked }); renderSettings(); });
return el("label", { className: "row" }, box, el("span", {}, t("A failing test refuses the build")));
};
// What a call deep costs this script: the largest frame any of its functions keeps, as it was last compiled.
const frame = result?.ok ? largestFrame(result.ir) : 0;
const stack = (depth: number) => {
if (!frame) return t("calls deep — no function of this script calls itself");
const cells = frame * depth;
return cells > STACK_CELLS_MAX
? t("calls deep — {frame} cells a call here, {size} while the map is played: more than the {max} a map may use, and it will not build", { frame, size: size(cells), max: size(STACK_CELLS_MAX) })
: t("calls deep — {frame} cells a call here, {size} while the map is played", { frame, size: size(cells) });
};
settingsView.body.replaceChildren(el("div", { className: "tsd-settings" },
el("h4", {}, t("Memory for arrays that grow")),
el("p", {}, t("Arrays a program pushes to share one pool of cells; this is its size. A single array can reach between a quarter and a half of it. When the pool runs out, nothing more is pushed and the game says so once.")),
row({ key: "heapCells", min: HEAP_CELLS_MIN, max: HEAP_CELLS_MAX, step: 1024, normal: HEAP_CELLS, fit: heapCells, said: (now) => t("cells — {size} of the built map", { size: size(now) }) }),
el("p", {}, t("{min} to {max} cells, four bytes each. A larger pool does not slow the game and hardly grows the saved file; it takes more memory while the map is played. Kept in the map, so it builds the same on any computer.", { min: count(HEAP_CELLS_MIN), max: count(HEAP_CELLS_MAX) })),
el("h4", {}, t("Recursion depth")),
el("p", {}, t("How many calls deep a function that calls itself may go. Around each such call the function's variables are kept on a stack, which is only in the built map when some function calls itself. A call past the limit stops the program, and the game says where; Simulate stops at the same call.")),
row({ key: "stackDepth", min: STACK_DEPTH_MIN, max: STACK_DEPTH_MAX, step: 256, normal: STACK_DEPTH, fit: stackDepth, said: stack }),
el("p", {}, t("{min} to {max} calls. The limit costs nothing until it is reached, but each call deep keeps and brings back every variable of its function, so thousands of calls within one frame make the game stutter. Kept in the map, like the pool above.", { min: count(STACK_DEPTH_MIN), max: count(STACK_DEPTH_MAX) })),
el("h4", {}, t("Tests")),
el("p", {}, t("The script's test() blocks run in the simulator after every compile that goes through. A failing test is a warning. With this on it also refuses the build: Save, Test Map and an export then say which test fails and write the map without the script applied again.")),
guardRow(),
));
}
const problemsItem = shell.statusItem("left");
const testsItem = shell.statusItem("left");
const blockItem = shell.statusItem("left");
const staleItem = shell.statusItem("left");
const renamesItem = shell.statusItem("left");
const messageItem = shell.statusItem("left");
const buildItem = shell.statusItem("right");
const libraryItem = shell.statusItem("right");
const programsItem = shell.statusItem("right");
const cursorItem = shell.statusItem("right");
/** The script's tests and what they last said. */
let tests: TestState = NO_TESTS;
/** The test the Test Results view is on. */
let chosenTest: string | null = null;
let onlyFailing = false;
/** Running every test took more than `TESTS_SLOW_MS` the last time. */
let testsSlow = false;
let testsRunning = false;
/** The files with a tab, in the order they were opened. */
let openTabs: string[] = [normalizePath(options.file ?? ENTRY_FILE)];
/** What Apply, Play and the builds of the programs reported, oldest first. */
let output: string[] = [];
/** The last build of the programs (Save, Test Map or Play ran it), for the status bar. */
/** The last build of the programs. `of` is the files it was started from: an edit replaces `files`, so a build of other files is of an older script. */
let buildState: { kind: "busy" | "ok" | "error"; text: string; title?: string; of: ScriptFiles } | null = null;
/** Lines the build under way has streamed; a build that streams none hands its log over at the end. */
let streamed = 0;
/** The log, kept at its end unless the user has scrolled up to read. */
const renderOutput = (toEnd = false) => {
if (output.length === 0) { outputView.body.replaceChildren(el("div", { className: "tsd-empty" }, t("What Apply, Play and the builds of the programs report is kept here."))); return; }
const scroller = outputView.body.parentElement;
const atEnd = toEnd || !scroller || scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 24;
outputEl.textContent = output.join("\n");
if (outputEl.parentElement !== outputView.body) outputView.body.replaceChildren(outputEl);
if (scroller && atEnd) scroller.scrollTop = scroller.scrollHeight;
};
const log = (text: string, stamped = true) => {
output.push(stamped ? `[${new Date().toLocaleTimeString()}] ${text}` : text);
if (output.length > OUTPUT_LINES) output = output.slice(-OUTPUT_LINES);
renderOutput();
};
/**
* What just happened. Work under way and its good outcome are a line of the status bar;
* a failure, or an answer to something the user asked for, is a notification — a failure
* stays until it is dismissed or the next piece of work starts. All of it is kept in Output.
*/
let status: { kind: "ok" | "busy"; text: string } | null = null;
let statusTimer: ReturnType<typeof setTimeout> | null = null;
const setStatus = (kind: "info" | "ok" | "error" | "busy", text: string, timeout?: number) => {
if (cancelled) return;
if (statusTimer !== null) { clearTimeout(statusTimer); statusTimer = null; }
if (kind === "busy" || kind === "ok") {
status = { kind, text };
shell.dismiss("status");
if (kind === "ok") statusTimer = setTimeout(() => { statusTimer = null; status = null; render(); }, STATUS_MS);
} else {
status = null;
shell.notify({ key: "status", kind: kind === "error" ? "error" : "info", text, timeout: timeout ?? (kind === "info" ? NOTICE_MS : undefined) });
}
if (kind !== "busy") log(text);
render();
};
const goTo = (file: string, line: number, column = 1) => {
if (!editor) return;
openFile(file);
editor.editor.revealLineInCenter(line);
editor.editor.setPosition({ lineNumber: line, column });
editor.editor.focus();
};
const where = (s: TriggerSource | null | undefined) => (s ? (Object.keys(files).length > 1 ? `${s.file}:${s.line}` : t("Ln {line}", { line: s.line })) : "?");
/** Show a file, in a tab of its own. */
const openFile = (path: string) => {
if (!editor) return;
const p = normalizePath(path);
if (files[p] === undefined) return;
if (!openTabs.includes(p)) openTabs.push(p);
editor.show(p);
renderFiles();
editor.editor.focus();
};
/** Closing a tab closes nothing but the tab: the file stays in the Explorer. The last one stays open. */
const closeTab = (path: string) => {
if (!editor || openTabs.length < 2) return;
const at = openTabs.indexOf(path);
if (at < 0) return;
openTabs.splice(at, 1);
if (editor.active() === path) editor.show(openTabs[Math.min(at, openTabs.length - 1)]);
renderFiles();
editor.editor.focus();
};
/** The folders of the Explorer that are closed; kept with the layout, so the tree comes back as it was left. */
const COLLAPSED_KEY = "explorer.collapsed";
let collapsed = new Set(api.storage.get<string[]>(COLLAPSED_KEY, []));
const setCollapsed = (next: Set<string>) => { collapsed = next; api.storage.set(COLLAPSED_KEY, [...next]); };
/** What is being dragged in the Explorer: a file's path or a folder's. */
let dragged: string | null = null;
/** Rows take a drop by being a folder (or the list, for the top): the dragged file or folder goes into it. */
const acceptDrops = (target: HTMLElement, folder: string) => {
const can = () => dragged !== null && folderOf(dragged) !== folder && dragged !== folder && !`${folder}/`.startsWith(`${dragged}/`);
target.addEventListener("dragover", (e) => { if (!can()) return; e.preventDefault(); e.stopPropagation(); target.classList.add("tsd-drop"); });
target.addEventListener("dragleave", () => target.classList.remove("tsd-drop"));
target.addEventListener("drop", (e) => {
target.classList.remove("tsd-drop");
if (!can() || dragged === null) return;
e.preventDefault();
e.stopPropagation();
const name = dragged.split("/").pop()!;
moveTo(dragged, folder ? `${folder}/${name}` : name);
});
};
const draggable = (row: HTMLElement, path: string) => {
row.draggable = true;
row.addEventListener("dragstart", (e) => { dragged = path; e.dataTransfer?.setData("text/plain", path); if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; row.classList.add("tsd-dragged"); });
row.addEventListener("dragend", () => { dragged = null; row.classList.remove("tsd-dragged"); });
};
// Below the last row is the top of the script.
acceptDrops(fileList, "");
const renderFiles = () => {
const active = editor?.active() ?? openTabs[0] ?? ENTRY_FILE;
const paths = Object.keys(files);
const broken = new Map<string, number>();
for (const d of diagnostics) { const p = normalizePath(d.file); broken.set(p, (broken.get(p) ?? 0) + 1); }
openTabs = openTabs.filter((p) => files[p] !== undefined);
if (!openTabs.includes(active)) openTabs.push(active);
const labels = tabLabels(openTabs);
shell.setTabs(openTabs.map((p) => ({ id: p, label: labels.get(p)?.label ?? p, about: labels.get(p)?.folder, title: p, problems: broken.get(p), closable: openTabs.length > 1 })), active);
// A folder that is no longer there is forgotten, so one made later under its name starts open.
const folders = new Set(paths.flatMap((p) => { const parts = p.split("/").slice(0, -1); return parts.map((_, i) => parts.slice(0, i + 1).join("/")); }));
if ([...collapsed].some((f) => !folders.has(f))) setCollapsed(new Set([...collapsed].filter((f) => folders.has(f))));
const rows: HTMLElement[] = [];
const indent = (depth: number) => `${20 + depth * 12}px`;
const walk = (nodes: TreeNode[], depth: number) => {
for (const node of nodes) {
if (node.kind === "folder") {
const open = !collapsed.has(node.path);
const under = filesUnder(paths, node.path);
const problems = under.reduce((n, p) => n + (broken.get(p) ?? 0), 0);
const toggle = () => { const next = new Set(collapsed); if (open) next.add(node.path); else next.delete(node.path); setCollapsed(next); renderFiles(); };
const items = () => [
{ label: t("New File…"), run: () => { void newFile(node.path); } },
{ label: t("New Folder…"), run: () => { void newFolder(node.path); } },
null,
{ label: t("Rename…"), run: () => { void renameFolder(node.path); } },
{ label: t("Remove…"), run: () => { void removeFolder(node.path); } },
];
const row = el("li", { className: "tsd-row tsd-folder", title: node.path, role: "button", ariaExpanded: String(open), onClick: toggle },
shell.icon(open ? "chevron-down" : "chevron-right"),
el("span", { className: problems ? "tsd-name tsd-problem" : "tsd-name" }, node.name),
el("span", { className: "tsd-row-actions" },
shell.iconButton({ icon: "new-file", title: t("New file in {folder}…", { folder: node.path }), run: () => { void newFile(node.path); } }).element,
shell.iconButton({ icon: "edit", title: t("Rename or move…"), run: () => { void renameFolder(node.path); } }).element,
shell.iconButton({ icon: "trash", title: t("Remove…"), run: () => { void removeFolder(node.path); } }).element,
problems ? el("span", { className: "tsd-count" }, String(problems)) : undefined,
),
);
row.style.paddingLeft = `${6 + depth * 12}px`;
row.addEventListener("contextmenu", (e) => { e.preventDefault(); shell.menu(row, items(), { x: e.clientX, y: e.clientY }); });
draggable(row, node.path);
acceptDrops(row, node.path);
rows.push(row);
if (open) walk(node.children, depth + 1);
continue;
}
const path = node.path;
const fixed = path === ENTRY_FILE;
const row = el("li", { className: path === active ? "tsd-row tsd-active" : "tsd-row", title: path, onClick: () => openFile(path) },
el("span", { className: "tsd-ts" }, "TS"),
el("span", { className: broken.has(path) ? "tsd-name tsd-problem" : "tsd-name" }, node.name),
el("span", { className: "tsd-row-actions" },
!fixed ? shell.iconButton({ icon: "edit", title: t("Rename or move…"), run: () => { void renameFile(path); } }).element : undefined,
!fixed ? shell.iconButton({ icon: "trash", title: t("Remove…"), run: () => { void removeFile(path); } }).element : undefined,
broken.has(path) ? el("span", { className: "tsd-count" }, String(broken.get(path))) : undefined,
),
);
row.style.paddingLeft = indent(depth);
if (!fixed) {
row.addEventListener("contextmenu", (e) => { e.preventDefault(); shell.menu(row, [
{ label: t("Rename…"), run: () => { void renameFile(path); } },
{ label: t("Remove…"), run: () => { void removeFile(path); } },
], { x: e.clientX, y: e.clientY }); });
draggable(row, path);
}
// Onto a file is into the folder it is in.
acceptDrops(row, folderOf(path));
rows.push(row);
}
};
walk(buildTree(paths), 0);
fileList.replaceChildren(...rows);
};
const typeOf = (v: { kind: "number" | "boolean" | "unit" | "text"; bits?: number; unsigned?: boolean }) => (v.kind === "number" ? (v.bits ? `u${v.bits}` : v.unsigned ? "u32" : "number") : v.kind === "unit" ? "Unit" : v.kind === "text" ? "string" : "boolean");
/** The programs and, under each, the variables it keeps in the game. */
const renderPrograms = () => {
const programs = outline?.programs ?? [];
programsSection.setHidden(programs.length === 0);
programList.replaceChildren(...programs.flatMap((p, i) => [
el("li", { className: "tsd-row", title: t("{name}, run as {owner}", { name: p.name ?? t("Program {n}", { n: i + 1 }), owner: ownerLabel(p, t) }), onClick: () => goTo(p.source.file, p.source.line) },
shell.icon("symbol-method"),
el("span", { className: "tsd-name" }, p.name ?? t("program {n}", { n: i + 1 })),
el("span", { className: "tsd-about" }, `${ownerLabel(p, t)}${p.perPlayer ? ` · ${t("per player")}` : ""}`),
),
...(outline?.variables ?? []).filter((v) => v.program === i).map((v) =>
el("li", { className: "tsd-row tsd-child", title: `${v.name}: ${typeOf(v)}${v.shared ? t(", one value shared by every player") : p.perPlayer ? t(", one per player") : ""}`, onClick: () => goTo(v.at.file, v.at.line, v.at.column) },
shell.icon("symbol-variable"),
el("span", { className: "tsd-name" }, v.name),
el("span", { className: "tsd-about" }, `${typeOf(v)}${v.shared ? ` · ${t("shared")}` : ""}`),
)),
]));
};
const renderProblems = () => {
// A failing test and an only left in are warnings: said here, and no obstacle to a build unless the map's settings make one of them.
const warnings = warningsOf(tests, t);
problemsView.badge(diagnostics.length + warnings.length);
if (diagnostics.length + warnings.length === 0) { problemsView.body.replaceChildren(el("div", { className: "tsd-empty" }, !ready ? "" : result ? t("No problems have been detected in the script.") : t("Checking…"))); return; }
problemsView.body.replaceChildren(el("ul", { className: "tsd-list" },
...diagnostics.map((d) =>
el("li", { title: d.message, onClick: () => goTo(d.file, d.line, d.column) },
shell.icon("error"),
el("span", { className: "msg" }, d.message.split("\n")[0]),
el("span", { className: "src" }, d.source === "typescript" ? t("types") : d.source === "script" ? t("script") : t("compiler")),
el("span", { className: "where" }, t("{file} [Ln {line}, Col {column}]", { file: d.file, line: d.line, column: d.column })),
)),
...warnings.map((w) =>
el("li", { className: "tsd-warning", title: w.message, onClick: () => goTo(w.file, w.line) },
shell.icon("warning"),
el("span", { className: "msg" }, w.message),
el("span", { className: "src" }, t("tests")),
el("span", { className: "where" }, t("{file} [Ln {line}]", { file: w.file, line: w.line })),
)),
));
};
/* ── Tests ── */
const STATE_ICON: Record<TestStateName, IconName> = { none: "circle-outline", passed: "pass", failed: "error", skipped: "circle-slash", running: "loading" };
const stateIcon = (state: TestStateName) => { const i = shell.icon(STATE_ICON[state]); i.classList.add(`tsd-test-${state}`); if (state === "running") i.classList.add("tsd-spin"); return i; };
/** The Testing view: folder, file, describe, test, each with its mark; a row runs what is under it. */
const renderTesting = () => {
const counts = countTests(tests);
testingView.badge(counts.failed || null, "error");
const said = summary(counts, t);
testsItem.set(ready && said ? { icon: testsRunning ? "loading" : said.failed ? "error" : "beaker", busy: testsRunning, text: said.text, kind: said.failed ? "error" : undefined, title: testsSlow
? t("{n, plural, one {# test} other {# tests}}; running them all takes a while, so only the open file's run after a change. Click for the Testing view", { n: counts.total })
: t("{n, plural, one {# test} other {# tests}}. Click for the Testing view", { n: counts.total }), onClick: () => testingView.show() } : null);
if (tests.list.length === 0) {
testingList.replaceChildren(el("li", { className: "tsd-empty" }, t("The script has no tests yet. A test is a test(name, (sim) => { … }) imported from \"trigscript\", in any file or in one named *.test.ts: it runs here, in the simulator, after every change that compiles.")));
return;
}
const rows: HTMLElement[] = [];
const failing = (node: TestNode): boolean => (node.kind === "test" ? stateOf(tests, node.info) === "failed" : node.children.some(failing));
const walk = (nodes: TestNode[], depth: number) => {
for (const node of nodes) {
if (onlyFailing && !failing(node)) continue;
const ids = idsUnder(node);
const state: TestStateName = testsRunning ? "running" : node.kind === "test" || node.kind === "suite" ? stateOf(tests, node.info) : stateOfCounts(countTests(tests, (x) => ids.some((id) => x.id === id || x.id.startsWith(`${id} > `))));
const name = node.kind === "folder" || node.kind === "file" ? node.name : node.info.name;
const result = node.kind === "test" ? tests.results.get(node.info.id) : undefined;
const open = () => {
if (node.kind === "folder") return;
if (node.kind === "file") { openFile(node.path); return; }
if (node.kind === "test") { chosenTest = node.info.id; shell.showPanel("tests"); renderResults(); renderTesting(); }
goTo(node.info.file, node.info.line);
};
const row = el("li", { className: node.kind === "test" && node.info.id === chosenTest ? "tsd-row tsd-active" : "tsd-row", title: result?.message ?? name, onClick: open },
stateIcon(state),
el("span", { className: state === "failed" ? "tsd-name tsd-problem" : "tsd-name" }, name),
result && result.status !== "skipped" ? el("span", { className: "tsd-about" }, t("{ms} ms", { ms: result.ms })) : undefined,
el("span", { className: "tsd-row-actions" },
shell.iconButton({ icon: "play", title: node.kind === "test" ? t("Run this test") : t("Run these tests"), run: () => { void runTests(node.kind === "file" ? { files: [node.path] } : { ids }); } }).element,
),
);
row.style.paddingLeft = `${10 + depth * 12}px`;
rows.push(row);
walk(node.children, depth + 1);
}
};
walk(testTree(tests.list), 0);
if (rows.length === 0) rows.push(el("li", { className: "tsd-empty" }, t("No test fails.")));
testingList.replaceChildren(...rows);
};
/** Test Results: the chosen test's message, what it printed, and what happened by frame, each a link to its line. */
const renderResults = () => {
const failed = tests.list.filter((x) => x.kind === "test" && tests.results.get(x.id)?.status === "failed");
resultsView.badge(failed.length);
const info: TestInfo | undefined = tests.list.find((x) => x.id === chosenTest) ?? failed[0] ?? tests.list.find((x) => x.kind === "test" && tests.results.has(x.id));
const r = info ? tests.results.get(info.id) : undefined;
if (!info || !r) { resultsView.body.replaceChildren(el("div", { className: "tsd-empty" }, tests.list.length ? t("No test has run yet: they run after a change that compiles, or from the Testing view.") : t("The script has no tests."))); return; }
const list = el("ul", { className: "tsd-list" });
const at = r.at ?? { file: info.file, line: info.line };
list.append(el("li", { title: t("Go to the test"), onClick: () => goTo(info.file, info.line) },
stateIcon(r.status === "failed" ? "failed" : r.status === "passed" ? "passed" : "skipped"),
el("span", { className: "msg" }, testName(info)),
el("span", { className: "src" }, r.status === "skipped" ? t("skipped") : t("{frames, plural, one {# frame} other {# frames}} · {ms} ms", { frames: r.frames, ms: r.ms })),
el("span", { className: "where" }, where({ file: info.file, line: info.line }))));
if (r.status === "failed") {
list.append(el("li", { className: "tsd-fault", title: t("Go to where it failed"), onClick: () => goTo(at.file, at.line, at.column ?? 1) },
el("span", { className: "frame" }, t("failed")), el("span", { className: "msg" }, r.message ?? t("failed")), el("span", { className: "where" }, where({ file: at.file, line: at.line }))));
if (r.expected !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("expected")), el("span", { className: "msg" }, r.expected)));
if (r.actual !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("got")), el("span", { className: "msg" }, r.actual)));
}
// The same line several times over — to each of eight players, say — is one row that says how often.
const printed: { line: string; times: number }[] = [];
for (const line of r.printed) { const last = printed[printed.length - 1]; if (last?.line === line) last.times++; else printed.push({ line, times: 1 }); }
for (const { line, times } of printed) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("printed")), el("span", { className: "msg" }, line), times > 1 ? el("span", { className: "src" }, `×${times}`) : undefined));
const several = new Set(r.events.map((e) => e.player)).size > 1;
const events = foldPlayers(r.events);
for (const e of events.slice(0, SIMULATE_ROWS)) {
list.append(el("li", { onClick: () => { if (e.file && e.line) goTo(e.file, e.line); } },
el("span", { className: "frame" }, t("frame {n}", { n: e.frame })), el("span", { className: "msg note" }, `${several ? `${playersLabel(e.players)} · ` : ""}${e.text}`), el("span", { className: "where" }, e.file && e.line ? where({ file: e.file, line: e.line }) : "")));
}
if (events.length > SIMULATE_ROWS) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, "…"), el("span", { className: "msg" }, t("and {n} more", { n: events.length - SIMULATE_ROWS }))));
resultsView.body.replaceChildren(list);
};
/** Run tests now, from what is in the editor: all of them, a file's, or those with these ids. */
const runTests = async (filter: Pick<TestRunOptions, "files" | "ids"> = {}) => {
if (!editor || testsRunning) return;
testsRunning = true;
renderTesting();
try {
const a = await compileNow({ world: svc.world(), ...filter });
if (!a || cancelled) return;
if (!a.compiled.ok) { setStatus("error", t("Tests not run: {n, plural, one {# problem} other {# problems}} in the script.", { n: a.compiled.diagnostics.length }), NOTICE_MS); shell.showPanel("problems"); return; }
const ran = a.compiled.tests?.results.filter((x) => x.status !== "skipped") ?? [];
const failed = ran.filter((x) => x.status === "failed");
if (ran.length === 0) { setStatus("info", tests.list.length ? t("No test ran.") : t("The script has no tests.")); return; }
if (failed.length) { chosenTest = failed[0].id; shell.showPanel("tests"); }
setStatus(failed.length ? "error" : "ok", failed.length
? t("{failed} of {n, plural, one {# test} other {# tests}} failed: {name} — {message}", { failed: failed.length, n: ran.length, name: testName(failed[0]), message: failed[0].message ?? t("failed") })
: t("{n, plural, one {# test} other {# tests}} passed.", { n: ran.length }), NOTICE_MS);
} finally {
testsRunning = false;
render();
}
};
const runFailedTests = () => {
const ids = tests.list.filter((x) => x.kind === "test" && tests.results.get(x.id)?.status === "failed").map((x) => x.id);
if (ids.length === 0) { setStatus("info", t("No test has failed.")); return Promise.resolve(); }
return runTests({ ids });
};
/** The test the cursor is in: the last test or describe of the file that starts at or before its line. */
const runTestAtCursor = () => {
const at = editor?.cursor();
const here = at ? tests.list.filter((x) => x.file === normalizePath(at.file) && x.line <= at.line).sort((a, b) => b.line - a.line)[0] : undefined;
if (!here) { setStatus("info", t("There is no test at the cursor.")); return Promise.resolve(); }
return runTests({ ids: [here.id] });
};
const renderSimulation = () => {
if (!simulation) { simulateView.body.replaceChildren(el("div", { className: "tsd-empty" }, t("Simulate ({mod}+F5) runs the script's first {seconds} seconds in a built-in interpreter and lists what happened. A change to the script clears the list.", { mod: MOD, seconds: SIMULATE_FRAMES / 24 }))); return; }
const { sim, programs: ps, result: r } = simulation;
const list = el("ul", { className: "tsd-list" });
list.append(el("li", { className: "tsd-plain" }, el("span", { className: "msg note" }, t("{frames} frames ({seconds} s) for {players}, from the map's placed units. Units are made, given, moved, killed and counted, but nothing walks or fights; scores and the countdown read 0; wait takes no time.", { frames: SIMULATE_FRAMES, seconds: SIMULATE_FRAMES / 24, players: sim.game.players.slots.map((p) => `P${p + 1}`).join(", ") }))));
// What a program did that is always a mistake, first: the game says nothing of these (a read past an array's end is 0
// there, a store past it nothing), so this is where they are seen. One row a line, however often a loop came past it.
const faults = new Map<string, { first: NonNullable<typeof ps>["faults"][number]; times: number }>();
for (const f of ps?.faults ?? []) {
const key = `${f.at.file}:${f.at.line}:${f.at.column}:${f.message.replace(/\d+/g, "#")}`;
const seen = faults.get(key);
if (seen) seen.times++; else faults.set(key, { first: f, times: 1 });
}
for (const { first: f, times } of [...faults.values()].slice(0, SIMULATE_FAULTS)) {
list.append(el("li", { className: "tsd-fault", title: f.message, onClick: () => goTo(f.at.file, f.at.line, f.at.column) },
el("span", { className: "frame" }, t("frame {n}", { n: f.frame })), shell.icon("error"),
el("span", { className: "msg" }, times > 1 ? t("{message} (and {n, plural, one {# more time} other {# more times}} at this line)", { message: f.message, n: times - 1 }) : f.message),
el("span", { className: "where" }, where({ file: f.at.file, line: f.at.line }))));
}
if (faults.size > SIMULATE_FAULTS) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, "…"), el("span", { className: "msg" }, t("and {n} more lines with a fault", { n: faults.size - SIMULATE_FAULTS }))));
// Hand triggers' events (trigger interpreter) and the programs' (program interpreter), in time order.
// With several players in the game each line says whose it is, and what several of them did alike in a frame is one line.
const several = sim.game.players.slots.length > 1;
const flat = [
...sim.events.map((e, i) => { const at = r.sources[e.trigger]; return { frame: e.cycle, order: i, player: e.player, text: describeEvent(e), file: at?.file, line: at?.line, column: 1, title: t("Trigger #{n}", { n: e.trigger + 1 }) }; }),
...(ps?.events ?? []).map((e, i) => ({ frame: e.frame, order: sim.events.length + i, player: e.player, text: describeEvent(e), file: e.at.file as string | undefined, line: e.at.line as number | undefined, column: e.at.column, title: t("Program {n}", { n: e.program + 1 }) })),
].sort((x, y) => x.frame - y.frame || x.order - y.order);
const rows = foldPlayers(flat).map((e) => ({
line: () => el("li", { title: e.title, onClick: () => { if (e.file && e.line) goTo(e.file, e.line, e.column); } },
el("span", { className: "frame" }, t("frame {n}", { n: e.frame })), el("span", { className: "msg" }, `${several ? `${playersLabel(e.players)} · ` : ""}${e.text}`), el("span", { className: "where" }, e.file && e.line ? where({ file: e.file, line: e.line }) : "?")),
}));
if (rows.length === 0) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, "—"), el("span", { className: "msg" }, t("No actions ran in {frames} frames.", { frames: SIMULATE_FRAMES }))));
for (const row of rows.slice(0, SIMULATE_ROWS)) list.append(row.line());
if (rows.length > SIMULATE_ROWS) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, "…"), el("span", { className: "msg" }, t("and {n} more actions", { n: rows.length - SIMULATE_ROWS }))));
const shownVars = new Set<string>();
for (const v of r.variables) {
if (shownVars.has(v.name)) continue;
shownVars.add(v.name);
const say = (value: number | boolean | undefined) => (value === undefined ? "?" : typeof value === "boolean" ? (value ? "true" : "false") : String(value));
// A per-player program's variable is one for each player it ran for, unless it is shared.
const runs = ps?.runs.filter((x) => x.index === v.program) ?? [];
const shown = runs.length > 1 && !v.shared ? runs.map((x) => `P${x.player + 1} ${say(x.value(v.name))}`).join(" · ") : say(ps?.value(v.name, v.program));
list.append(el("li", { title: t("The variable's value when the run ended"), onClick: () => goTo(v.at.file, v.at.line, v.at.column) },
el("span", { className: "frame" }, t("after")), el("span", { className: "msg" }, `${v.name} = ${shown}`), el("span", { className: "where" }, typeOf(v))));
}
simulateView.body.replaceChildren(list);
};
/** The block was edited outside the script: what the next Apply does about it, and the other choice. Said once per state; the status bar brings it back. */
let staleShown = "";
const showStale = (again = false) => {
const state = svc.state();
if (!state?.stale) { staleShown = ""; shell.dismiss("stale"); return; }
const e = state.edited ?? null;
const signature = JSON.stringify([e, appendInstead]);
if (!again && signature === staleShown) return;
staleShown = signature;
if (!e) {
shell.notify({ key: "stale", kind: "warn", text: t("The script's triggers were edited or removed outside the script. They stay as hand-made triggers; the next Apply appends a fresh block. Saving the map does not apply the script until this is settled.") });
return;
}
const facts = t("The script's triggers were edited outside the script: {unchanged, plural, one {# trigger is} other {# triggers are}} still the script's, {changed, plural, one {# trigger was} other {# triggers were}} changed.", { unchanged: e.unchanged, changed: e.changed });
const plan = appendInstead
? t("The next Apply leaves them all as hand-made triggers and appends a fresh block.")
: t("The next Apply replaces the {unchanged} and keeps the {changed, plural, one {# edited one} other {# edited ones}} as hand-made triggers right after the new block.", { unchanged: e.unchanged, changed: e.changed });
shell.notify({ key: "stale", kind: "warn", text: `${facts} ${plan}`, actions: [
{ label: appendInstead ? t("Replace instead") : t("Append instead"), keep: true, run: () => { appendInstead = !appendInstead; render(); } },
{ label: t("Apply"), primary: true, run: () => { void build(); } },
] });
};
const renamedNow = () => renames.flatMap((r) => r.list.map((x) => `${r.object}.${x.from} → ${r.object}.${x.to}`));
let renamesShown = "";
const showRenames = (again = false) => {
const all = renamedNow();
if (all.length === 0) { renamesShown = ""; shell.dismiss("renames"); return; }
const signature = all.join("\n");
if (!again && signature === renamesShown) return;
renamesShown = signature;
shell.notify({ key: "renames", kind: "info", text: t("The map renamed {n, plural, one {something the script names} other {# things the script names}}: {list}.", { n: all.length, list: all.join(", ") }), actions: [
{ label: t("Leave"), run: () => { renames = []; render(); } },
{ label: t("Update references"), primary: true, run: () => { void applyRenames(); } },
] });
};
const renderCursor = () => {
const at = editor?.editor.getPosition();
cursorItem.set(at ? { text: t("Ln {line}, Col {column}", { line: at.lineNumber, column: at.column }), title: t("Go to line…"), onClick: () => { editor?.editor.focus(); editor?.editor.trigger("trigscript", "editor.action.gotoLine", null); } } : null);
};
const render = () => {
const errors = diagnostics.length;
testAction.set({ disabled: !ready || building || testing, busy: testing });
simulateAction.set({ disabled: !ready || building || errors > 0 });
applyAction.set({ disabled: !ready || building, busy: building && !testing });
pickAction.set({ disabled: !ready || picking, busy: picking });
// Read live, not cached: the block's state belongs to the map and other editors change it.
const state = svc.state();
const block = state?.block ?? null;
const stale = state?.stale ?? false;
const programs = outline ? outline.programs.length : state?.programs ?? 0;
problemsItem.set(ready ? { icon: errors ? "error" : "pass", text: String(errors), kind: errors ? "error" : undefined, title: errors ? t("{n, plural, one {# problem} other {# problems}}", { n: errors }) : result ? t("No problems") : t("Checking…"), onClick: () => shell.togglePanel("problems") } : null);
blockItem.set(stale ? null : block && !state?.unbuilt
? { icon: "check", text: t("{n, plural, one {# trigger} other {# triggers}} at #{start}", { n: block.count, start: block.start + 1 }), title: t("The script's triggers are in the map's trigger list, from #{start}. Click to apply the script again", { start: block.start + 1 }), onClick: () => { void build(); } }
: { icon: "circle-filled", text: block ? t("Changes not applied") : t("Not applied yet"), title: block
? t("The script changed since it was applied: saving or testing the map applies the script by itself. Click to apply it now ({mod}+Shift+B)", { mod: MOD })
: t("The script's triggers are not in the map yet: saving or testing the map applies the script by itself. Click to apply it now ({mod}+Shift+B)", { mod: MOD }), onClick: () => { void build(); } });
staleItem.set(stale ? { icon: "warning", kind: "warn", text: t("Triggers edited outside the script"), title: t("What the next Apply does about it"), onClick: () => showStale(true) } : null);
const renamed = renamedNow().length;
renamesItem.set(renamed ? { icon: "sync", kind: "warn", text: t("{n} renamed", { n: renamed }), title: t("The map renamed things the script names"), onClick: () => showRenames(true) } : null);
messageItem.set(status ? { text: status.text, busy: status.kind === "busy" } : !ready && !failed ? { text: t("Loading the editor…"), busy: true } : null);
// A build that went well says so only of the script it was made from.
const editedSince = buildState?.kind === "ok" && buildState.of !== files;
buildItem.set(buildState ? {
text: editedSince ? `${buildState.text} · ${t("edited since")}` : buildState.text, busy: buildState.kind === "busy",
icon: buildState.kind === "error" ? "error" : "package", kind: buildState.kind === "error" ? "error" : editedSince ? "warn" : undefined,
title: t("{what}. Click for its log", { what: editedSince ? t("The script changed since this build: the programs in the saved map are the older ones until the next Save") : buildState.title ?? t("The last build of the programs") }),
onClick: () => shell.showPanel("output"),
} : null);
// The library matters only to a script with programs: it is what builds them into the saved map.
const needsLibrary = programs > 0;
const libraryOk = !!library?.contribute && library.state() !== "failed";
libraryItem.set(!needsLibrary ? null : {
kind: libraryOk ? undefined : "warn",
icon: libraryOk ? undefined : "warning",
text: !library
? t("eudplib plugin not running")
: !library.contribute
? t("eudplib plugin older than 0.4")
: `eudplib ${library.versions.eudplib} · ${library.state() === "ready" ? t("runtime ready") : library.state() === "installing" ? t("runtime downloading…") : library.state() === "failed" ? t("runtime failed") : t("runtime downloads on the first save")}`,
title: !library
? t("The programs will not be built: install or turn on the eudplib plugin under Plugins ▸ Manage Plugins…")
: !library.contribute
? t("Update the eudplib plugin to build the programs")
: t("The eudplib plugin builds the programs into the map when it is saved or tested; its runtime is downloaded once, the first time"),
});
programsItem.set(programs ? { text: t("{n, plural, one {# program} other {# programs}} · Remastered", { n: programs }), title: t("Programs are built into the saved map, which then needs StarCraft: Remastered. Click for the programs and their variables"), onClick: () => { shell.toggleSidebar(true); programsSection.expand(); } } : null);
renderFiles();
renderPrograms();
renderProblems();
renderTesting();
renderResults();
renderSimulation();
showStale();
showRenames();
};
const applyResult = (r: CompileResult, asked?: TestRunOptions) => {
diagnostics = r.diagnostics;
result = r;
if (r.ok) outline = { programs: r.programs, variables: r.variables };
simulation = null;
// A script with problems keeps what its tests last said; one that compiled says what they say now.
if (r.ok) tests = mergeReport(tests, r.tests);
// All of them took long: from now on only the open file's run by themselves, and the rest wait for Run All.
if (r.ok && r.tests && asked && !asked.files && !asked.ids) testsSlow = r.tests.ms > TESTS_SLOW_MS;
if (chosenTest && !tests.results.has(chosenTest)) chosenTest = null;
if (editor && monaco) { setCompilerMarkers(monaco, files, diagnostics, warningsOf(tests, t)); editor.setTests(marksOf(tests, t), notesOf(tests, t)); editor.decorate(r.buildTime); refreshLineHints(); }
render();
};
/** What a compile is asked to run of the tests: every one, or, once that proved slow, the open file's. */
const automaticTests = (): TestRunOptions => ({ world: svc.world(), ...(testsSlow ? { files: [editor?.active() ?? ENTRY_FILE] } : {}) });
/** The locations the script can name, by key, for Ctrl+click and the hover. */
const mapRefs = () => {
if (!generated) return null;
const scn = api.document.scenario();
if (!scn) return null;
const byKey = new Map<string, LocationRef>();
for (const e of generated.names.locations.entries) {
const index = e.value - 1;
const l = scn.locations[index];
if (!l) continue;
const x0 = Math.min(l.left, l.right), x1 = Math.max(l.left, l.right), y0 = Math.min(l.top, l.bottom), y1 = Math.max(l.top, l.bottom);
byKey.set(e.keys[0], { index, name: e.keys[1] ?? e.keys[0], x: Math.floor(x0 / 32), y: Math.floor(y0 / 32), w: Math.max(1, Math.round((x1 - x0) / 32)), h: Math.max(1, Math.round((y1 - y0) / 32)) });
}
return { object: generated.names.locations.object, byKey, open: (ref: LocationRef) => { api.view.goTo({ kind: "location", index: ref.index }); api.view.flash({ locations: [ref.index], kind: "attention" }); } };
};
/** Type-check, run and lower in the background; markers land in the editor, the list below. */
const check = () => {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (cancelled || !generated) return;
const asked = automaticTests();
svc.prepare(files, generated, asked).then(
(a) => { if (!cancelled) applyResult(a.compiled, asked); },
(err: Error) => { if (!cancelled && !(err instanceof CompileSuperseded)) setStatus("error", t("Compiler: {message}", { message: err.message })); },
);
}, CHECK_DELAY_MS);
};
/**
* Compile what is in the editor right now, and hand back the artifact a build installs.
* A keystroke during the compile supersedes it with the newer text's check; the newer
* text is what the user wants built, so it is compiled again — a few times at most.
*/
const compileNow = async (asked: TestRunOptions = automaticTests()): Promise<ScriptArtifact | null> => {
if (timer !== null) { clearTimeout(timer); timer = null; }
for (let attempt = 0; attempt < 3; attempt++) {
if (cancelled || !generated) return null;
try {
const a = await svc.prepare(files, generated, asked);
applyResult(a.compiled, asked);
return a;
} catch (err) {
if (err instanceof CompileSuperseded) continue;
setStatus("error", t("Compiler: {message}", { message: (err as Error).message }));
return null;
}
}
return null;
};
const refusal = (why: BuildRefusal | undefined): string => {
switch (why) {