intelligent-system-design-language 0.3.22 → 0.3.24

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 (106) hide show
  1. package/.claude/agents/langium-language-designer.md +38 -38
  2. package/.claude/agents/typescript-vscode-expert.md +29 -29
  3. package/.claude/agents/ui-ux-designer.md +36 -36
  4. package/.claude/settings.local.json +33 -33
  5. package/.idea/inspectionProfiles/Project_Default.xml +6 -6
  6. package/.idea/isdl.iml +13 -13
  7. package/.idea/modules.xml +8 -8
  8. package/.idea/vcs.xml +6 -6
  9. package/.idea/watcherTasks.xml +3 -3
  10. package/.vscodeignore +18 -18
  11. package/LICENSE +673 -673
  12. package/README.md +86 -86
  13. package/bin/cli.js +4 -4
  14. package/bin/lsp.js +8 -8
  15. package/out/_backgrounds.scss +91 -91
  16. package/out/_handlebars.scss +497 -497
  17. package/out/_isdlStyles.scss +1444 -1444
  18. package/out/_vuetifyOverrides.scss +425 -425
  19. package/out/_vuetifyStyles.scss +31957 -31957
  20. package/out/cli/components/_backgrounds.scss +91 -91
  21. package/out/cli/components/_handlebars.scss +497 -497
  22. package/out/cli/components/_isdlStyles.scss +1444 -1444
  23. package/out/cli/components/_vuetifyOverrides.scss +425 -425
  24. package/out/cli/components/_vuetifyStyles.scss +31957 -31957
  25. package/out/cli/components/active-effect-sheet-generator.js +453 -453
  26. package/out/cli/components/chat-card-generator.js +654 -654
  27. package/out/cli/components/css-generator.js +4 -4
  28. package/out/cli/components/damage-roll-generator.js +160 -160
  29. package/out/cli/components/datamodel-generator.js +257 -257
  30. package/out/cli/components/derived-data-generator.js +923 -923
  31. package/out/cli/components/hotbar-drop-hook-generator.js +82 -82
  32. package/out/cli/components/init-hook-generator.js +495 -495
  33. package/out/cli/components/language-generator.js +1 -1
  34. package/out/cli/components/language-generator.js.map +1 -1
  35. package/out/cli/components/measured-template-preview.js +221 -221
  36. package/out/cli/components/method-generator.js +601 -562
  37. package/out/cli/components/method-generator.js.map +1 -1
  38. package/out/cli/components/ready-hook-generator.js +404 -404
  39. package/out/cli/components/token-generator.js +116 -116
  40. package/out/cli/components/vue/base-components/vue-attribute.js +138 -138
  41. package/out/cli/components/vue/base-components/vue-boolean.js +64 -64
  42. package/out/cli/components/vue/base-components/vue-calculator.js +93 -93
  43. package/out/cli/components/vue/base-components/vue-damage-application.js +356 -356
  44. package/out/cli/components/vue/base-components/vue-damage-bonuses.js +165 -165
  45. package/out/cli/components/vue/base-components/vue-damage-resistances.js +196 -196
  46. package/out/cli/components/vue/base-components/vue-damage-track.js +121 -121
  47. package/out/cli/components/vue/base-components/vue-date-time.js +42 -42
  48. package/out/cli/components/vue/base-components/vue-dice.js +98 -98
  49. package/out/cli/components/vue/base-components/vue-die.js +73 -73
  50. package/out/cli/components/vue/base-components/vue-document-choice.js +149 -149
  51. package/out/cli/components/vue/base-components/vue-document-choices.js +179 -179
  52. package/out/cli/components/vue/base-components/vue-document-link.js +60 -60
  53. package/out/cli/components/vue/base-components/vue-extended-choice.js +88 -88
  54. package/out/cli/components/vue/base-components/vue-inventory.js +519 -519
  55. package/out/cli/components/vue/base-components/vue-macro-choice.js +138 -138
  56. package/out/cli/components/vue/base-components/vue-measured-template.js +530 -530
  57. package/out/cli/components/vue/base-components/vue-money.js +483 -483
  58. package/out/cli/components/vue/base-components/vue-number.js +174 -174
  59. package/out/cli/components/vue/base-components/vue-paperdoll.js +43 -43
  60. package/out/cli/components/vue/base-components/vue-parent-property-reference.js +76 -76
  61. package/out/cli/components/vue/base-components/vue-prosemirror.js +18 -18
  62. package/out/cli/components/vue/base-components/vue-resource.js +136 -136
  63. package/out/cli/components/vue/base-components/vue-roll-visualizer.js +285 -285
  64. package/out/cli/components/vue/base-components/vue-self-property-reference.js +62 -62
  65. package/out/cli/components/vue/base-components/vue-string-choice.js +98 -98
  66. package/out/cli/components/vue/base-components/vue-string-choices.js +203 -203
  67. package/out/cli/components/vue/base-components/vue-string.js +60 -60
  68. package/out/cli/components/vue/base-components/vue-text-field.js +53 -53
  69. package/out/cli/components/vue/base-components/vue-tracker.js +431 -431
  70. package/out/cli/components/vue/vue-action-component-generator.js +64 -64
  71. package/out/cli/components/vue/vue-active-effect-sheet-generator.js +856 -856
  72. package/out/cli/components/vue/vue-datatable-sheet-class-generator.js +292 -292
  73. package/out/cli/components/vue/vue-datatable2-component-generator.js +824 -824
  74. package/out/cli/components/vue/vue-document-creation-app.js +121 -121
  75. package/out/cli/components/vue/vue-document-creation-sheet.js +94 -94
  76. package/out/cli/components/vue/vue-generator.js +40 -40
  77. package/out/cli/components/vue/vue-mixin.js +296 -296
  78. package/out/cli/components/vue/vue-pinned-datatable-component-generator.js +260 -260
  79. package/out/cli/components/vue/vue-prompt-generator.js +80 -80
  80. package/out/cli/components/vue/vue-prompt-sheet-class-generator.js +317 -317
  81. package/out/cli/components/vue/vue-sheet-application-generator.js +1171 -1171
  82. package/out/cli/components/vue/vue-sheet-class-generator.js +510 -510
  83. package/out/cli/generator.js +508 -438
  84. package/out/cli/generator.js.map +1 -1
  85. package/out/extension/github/githubManager.js +35 -35
  86. package/out/extension/github/githubQuickActions.js +120 -120
  87. package/out/extension/github/system-workflow.yml +47 -47
  88. package/out/extension/main.cjs +881 -311
  89. package/out/extension/main.cjs.map +3 -3
  90. package/out/extension/package.json +419 -419
  91. package/out/language/generated/ast.js +98 -2
  92. package/out/language/generated/ast.js.map +1 -1
  93. package/out/language/generated/grammar.js +14688 -14240
  94. package/out/language/generated/grammar.js.map +1 -1
  95. package/out/language/intelligent-system-design-language-validator.js +44 -2
  96. package/out/language/intelligent-system-design-language-validator.js.map +1 -1
  97. package/out/language/isdl-scope-provider.js +7 -1
  98. package/out/language/isdl-scope-provider.js.map +1 -1
  99. package/out/language/main.cjs +881 -311
  100. package/out/language/main.cjs.map +3 -3
  101. package/out/package.json +419 -419
  102. package/out/progressbar.min.js +6 -6
  103. package/out/styles.scss +828 -762
  104. package/out/test/validating/diagnostics.test.js +55 -0
  105. package/out/test/validating/diagnostics.test.js.map +1 -1
  106. package/package.json +419 -419
@@ -7,291 +7,291 @@ export default function generateRollVisualizerComponent(destination) {
7
7
  if (!fs.existsSync(generatedFileDir)) {
8
8
  fs.mkdirSync(generatedFileDir, { recursive: true });
9
9
  }
10
- const fileNode = expandToNode `
11
- <script>
12
- // Module-scoped cache (shared across all roll-visualizer instances for the page
13
- // session, and surviving sheet re-renders/re-opens). Keyed by the RESOLVED formula
14
- // (after @refs are substituted), so the same dice math -- especially the expensive
15
- // simulation path -- is only computed once. Cleared on page reload.
16
- const __rollVisualizerCache = new Map();
17
- </script>
18
- <script setup>
19
- import { ref, watch, computed, inject } from "vue";
20
-
21
- const props = defineProps({
22
- label: String,
23
- icon: String,
24
- color: String,
25
- systemPath: String,
26
- context: Object,
27
- // The Foundry roll formula (may contain @refs), compiled from the field's value: expression.
28
- formula: { type: String, default: "" },
29
- // Data object resolving the @refs, bound from the live (reactive) document/prompt data.
30
- rollData: { type: Object, default: () => ({}) }
31
- });
32
-
33
- // ----- Distribution engine ---------------------------------------------------
34
- // For purely additive dice formulas (dice + constants, +/- only, no dice
35
- // modifiers) we compute the EXACT probability distribution by convolution.
36
- // Anything more exotic (keep-highest/lowest, exploding, rerolls, multiplied
37
- // dice, fate dice, pools, functions) falls back to Monte Carlo simulation.
38
-
39
- // A distribution is a Map<outcomeValue, probability>.
40
- const dieDist = (faces) => {
41
- const m = new Map();
42
- for (let f = 1; f <= faces; f++) m.set(f, 1 / faces);
43
- return m;
44
- };
45
- const convolve = (a, b) => {
46
- const out = new Map();
47
- for (const [va, pa] of a) {
48
- for (const [vb, pb] of b) {
49
- const v = va + vb;
50
- out.set(v, (out.get(v) || 0) + pa * pb);
51
- }
52
- }
53
- return out;
54
- };
55
- const negate = (a) => {
56
- const m = new Map();
57
- for (const [v, p] of a) m.set(-v, p);
58
- return m;
59
- };
60
- const shift = (a, k) => {
61
- const m = new Map();
62
- for (const [v, p] of a) m.set(v + k, p);
63
- return m;
64
- };
65
-
66
- // Classify a Foundry roll term. Foundry does not minify these class names in
67
- // either v12 or v13, so constructor.name is a reliable signal.
68
- const classify = (term) => {
69
- const n = term?.constructor?.name;
70
- if (n === "Die") return "die";
71
- if (n === "NumericTerm") return "num";
72
- if (n === "OperatorTerm") return "op";
73
- return "other";
74
- };
75
-
76
- // Decide whether a parsed roll can be solved exactly. Guard the convolution
77
- // against pathological sizes (huge dice pools) by falling back to simulation.
78
- const MAX_BUCKETS = 5000;
79
- const canConvolve = (terms) => {
80
- let minTotal = 0, maxTotal = 0;
81
- for (const term of terms) {
82
- const kind = classify(term);
83
- if (kind === "op") {
84
- if (term.operator !== "+" && term.operator !== "-") return false;
85
- continue;
86
- }
87
- if (kind === "die") {
88
- if (Array.isArray(term.modifiers) && term.modifiers.length > 0) return false;
89
- const number = Number(term.number);
90
- const faces = Number(term.faces);
91
- if (!Number.isInteger(number) || !Number.isInteger(faces) || faces < 1 || number < 0) return false;
92
- minTotal += number;
93
- maxTotal += number * faces;
94
- continue;
95
- }
96
- if (kind === "num") {
97
- if (!Number.isFinite(Number(term.number))) return false;
98
- continue;
99
- }
100
- return false; // unknown term type
101
- }
102
- return (maxTotal - minTotal) <= MAX_BUCKETS;
103
- };
104
-
105
- // Build the exact PMF for an additive term list.
106
- const convolveTerms = (terms) => {
107
- let acc = new Map([[0, 1]]);
108
- let sign = 1;
109
- for (const term of terms) {
110
- const kind = classify(term);
111
- if (kind === "op") {
112
- sign = term.operator === "-" ? -1 : 1;
113
- continue;
114
- }
115
- if (kind === "die") {
116
- let dist = new Map([[0, 1]]);
117
- for (let i = 0; i < Number(term.number); i++) dist = convolve(dist, dieDist(Number(term.faces)));
118
- if (sign < 0) dist = negate(dist);
119
- acc = convolve(acc, dist);
120
- }
121
- else if (kind === "num") {
122
- acc = shift(acc, sign * Number(term.number));
123
- }
124
- sign = 1;
125
- }
126
- return acc;
127
- };
128
-
129
- // Derive the display payload (average, min, max, chart series) from a PMF.
130
- const summarize = (dist, approximate) => {
131
- const entries = [...dist.entries()].sort((a, b) => a[0] - b[0]);
132
- let average = 0, min = Infinity, max = -Infinity;
133
- for (const [v, p] of entries) {
134
- average += v * p;
135
- if (v < min) min = v;
136
- if (v > max) max = v;
137
- }
138
- const outcomeValues = entries.map(([v]) => v);
139
- const values = entries.map(([, p]) => +(p * 100).toFixed(2));
140
- // Thin the x-axis labels so they don't cluster/overlap: pick a "nice" step
141
- // (1, 2, 5, 10, ...) targeting ~8 labels, and only label outcomes that land on
142
- // a multiple of it -- so the axis reads in round numbers (5, 10, 15, 20 ...).
143
- const lo = outcomeValues.length ? outcomeValues[0] : 0;
144
- const hi = outcomeValues.length ? outcomeValues[outcomeValues.length - 1] : 0;
145
- const span = Math.max(1, hi - lo);
146
- const rawStep = span / 8;
147
- const pow = Math.pow(10, Math.floor(Math.log10(rawStep)));
148
- const norm = rawStep / pow;
149
- const niceStep = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * pow;
150
- const step = Math.max(1, Math.round(niceStep));
151
- return {
152
- average: +average.toFixed(2),
153
- min: entries.length ? min : 0,
154
- max: entries.length ? max : 0,
155
- labels: outcomeValues,
156
- values,
157
- step,
158
- approximate,
159
- hasData: entries.length > 0
160
- };
161
- };
162
-
163
- // ----- Reactive computation --------------------------------------------------
164
-
165
- const result = ref({ average: 0, min: 0, max: 0, labels: [], values: [], step: 1, approximate: false, hasData: false });
166
- const iterations = ref(0);
167
- let runToken = 0;
168
-
169
- // Monte Carlo fallback. Roll.simulate builds a full Roll per sample (~0.2ms each),
170
- // so a large batch blocks the main thread for seconds and freezes the sheet on
171
- // render. Instead, sample a modest target in small chunks and yield to the event
172
- // loop between each, refining the curve in place. The sheet stays responsive and
173
- // the chart fills in progressively.
174
- const SIM_TARGET = 2000;
175
- const SIM_CHUNK = 200;
176
- const runSimulation = async (formula, token, cacheKey) => {
177
- const counts = new Map();
178
- let done = 0;
179
- while (done < SIM_TARGET) {
180
- if (token !== runToken) return; // a newer run superseded us
181
- const n = Math.min(SIM_CHUNK, SIM_TARGET - done);
182
- const batch = await Roll.simulate(formula, n);
183
- if (token !== runToken) return;
184
- for (const v of batch) counts.set(v, (counts.get(v) || 0) + 1);
185
- done += n;
186
- const dist = new Map();
187
- for (const [v, c] of counts) dist.set(v, c / done);
188
- result.value = summarize(dist, true);
189
- iterations.value = done;
190
- // Yield so the browser can paint and handle input between chunks.
191
- await new Promise(r => setTimeout(r, 0));
192
- }
193
- // Cache the completed estimate so this formula isn't re-simulated later.
194
- __rollVisualizerCache.set(cacheKey, result.value);
195
- };
196
-
197
- const recompute = () => {
198
- const token = ++runToken;
199
- const raw = (props.formula || "").trim();
200
- if (!raw) {
201
- result.value = { average: 0, min: 0, max: 0, labels: [], values: [], step: 1, approximate: false, hasData: false };
202
- return;
203
- }
204
- let roll;
205
- try {
206
- roll = new Roll(raw, props.rollData || {});
207
- }
208
- catch (e) {
209
- result.value = { average: 0, min: 0, max: 0, labels: [], values: [], step: 1, approximate: false, hasData: false };
210
- return;
211
- }
212
- // Cache hit on the resolved formula -> reuse the result, no recompute.
213
- const cacheKey = roll.formula;
214
- const cached = __rollVisualizerCache.get(cacheKey);
215
- if (cached) {
216
- result.value = cached;
217
- iterations.value = cached.approximate ? SIM_TARGET : 0;
218
- return;
219
- }
220
- // After construction Foundry has substituted @refs into the terms.
221
- const terms = roll.terms || [];
222
- if (canConvolve(terms)) {
223
- iterations.value = 0;
224
- const summary = summarize(convolveTerms(terms), false);
225
- __rollVisualizerCache.set(cacheKey, summary);
226
- result.value = summary;
227
- }
228
- else {
229
- // Simulate against the resolved formula (no @refs remain in roll.formula).
230
- runSimulation(roll.formula, token, cacheKey);
231
- }
232
- };
233
-
234
- // Debounce so rapid field edits don't kick off redundant simulations.
235
- let debounceHandle = null;
236
- watch(
237
- () => [props.formula, JSON.stringify(props.rollData || {})],
238
- () => {
239
- if (debounceHandle) clearTimeout(debounceHandle);
240
- debounceHandle = setTimeout(recompute, 200);
241
- },
242
- { immediate: true }
243
- );
244
-
245
- const getLabel = computed(() => {
246
- const localized = game.i18n.localize(props.label);
247
- if (props.icon) return \`<i class="fa-solid \${props.icon}"></i> \${localized}\`;
248
- return localized;
249
- });
250
- const accentColor = computed(() => props.color || "#92aed9");
251
- const averageText = computed(() => (result.value.approximate ? "≈ " : "") + result.value.average);
252
- </script>
253
-
254
- <template>
255
- <v-card class="isdl-roll-visualizer" :name="systemPath" variant="tonal" density="compact">
256
- <div class="isdl-roll-visualizer__header">
257
- <span class="isdl-roll-visualizer__label" v-html="getLabel"></span>
258
- <span class="isdl-roll-visualizer__avg" :style="{ color: accentColor }">{{ averageText }}</span>
259
- </div>
260
- <v-sparkline
261
- v-if="result.hasData"
262
- :labels="result.labels"
263
- :model-value="result.values"
264
- :color="accentColor"
265
- line-width="2"
266
- padding="8"
267
- smooth="6"
268
- :label-size="10"
269
- auto-draw
270
- preserveAspectRatio="none"
271
- >
272
- <!-- Render a label only on "nice" round outcomes; blank elsewhere so the
273
- axis doesn't cluster and never falls back to showing the raw value. -->
274
- <template #label="item">
275
- {{ Number(item.value) % result.step === 0 ? item.value : "" }}
276
- </template>
277
- </v-sparkline>
278
- <div v-else class="isdl-roll-visualizer__empty text-caption">
279
- {{ game.i18n.localize("ROLLVISUALIZER.NoFormula") }}
280
- </div>
281
- <div class="isdl-roll-visualizer__footer text-caption">
282
- <span v-if="result.hasData">
283
- {{ game.i18n.localize("ROLLVISUALIZER.Min") }}: {{ result.min }}
284
- &middot;
285
- {{ game.i18n.localize("ROLLVISUALIZER.Max") }}: {{ result.max }}
286
- &middot;
287
- {{ game.i18n.localize("ROLLVISUALIZER.Average") }}: {{ averageText }}
288
- </span>
289
- <span v-if="result.approximate && iterations > 0" class="isdl-roll-visualizer__approx">
290
- ({{ iterations }} {{ game.i18n.localize("ROLLVISUALIZER.Simulations") }})
291
- </span>
292
- </div>
293
- </v-card>
294
- </template>
10
+ const fileNode = expandToNode `
11
+ <script>
12
+ // Module-scoped cache (shared across all roll-visualizer instances for the page
13
+ // session, and surviving sheet re-renders/re-opens). Keyed by the RESOLVED formula
14
+ // (after @refs are substituted), so the same dice math -- especially the expensive
15
+ // simulation path -- is only computed once. Cleared on page reload.
16
+ const __rollVisualizerCache = new Map();
17
+ </script>
18
+ <script setup>
19
+ import { ref, watch, computed, inject } from "vue";
20
+
21
+ const props = defineProps({
22
+ label: String,
23
+ icon: String,
24
+ color: String,
25
+ systemPath: String,
26
+ context: Object,
27
+ // The Foundry roll formula (may contain @refs), compiled from the field's value: expression.
28
+ formula: { type: String, default: "" },
29
+ // Data object resolving the @refs, bound from the live (reactive) document/prompt data.
30
+ rollData: { type: Object, default: () => ({}) }
31
+ });
32
+
33
+ // ----- Distribution engine ---------------------------------------------------
34
+ // For purely additive dice formulas (dice + constants, +/- only, no dice
35
+ // modifiers) we compute the EXACT probability distribution by convolution.
36
+ // Anything more exotic (keep-highest/lowest, exploding, rerolls, multiplied
37
+ // dice, fate dice, pools, functions) falls back to Monte Carlo simulation.
38
+
39
+ // A distribution is a Map<outcomeValue, probability>.
40
+ const dieDist = (faces) => {
41
+ const m = new Map();
42
+ for (let f = 1; f <= faces; f++) m.set(f, 1 / faces);
43
+ return m;
44
+ };
45
+ const convolve = (a, b) => {
46
+ const out = new Map();
47
+ for (const [va, pa] of a) {
48
+ for (const [vb, pb] of b) {
49
+ const v = va + vb;
50
+ out.set(v, (out.get(v) || 0) + pa * pb);
51
+ }
52
+ }
53
+ return out;
54
+ };
55
+ const negate = (a) => {
56
+ const m = new Map();
57
+ for (const [v, p] of a) m.set(-v, p);
58
+ return m;
59
+ };
60
+ const shift = (a, k) => {
61
+ const m = new Map();
62
+ for (const [v, p] of a) m.set(v + k, p);
63
+ return m;
64
+ };
65
+
66
+ // Classify a Foundry roll term. Foundry does not minify these class names in
67
+ // either v12 or v13, so constructor.name is a reliable signal.
68
+ const classify = (term) => {
69
+ const n = term?.constructor?.name;
70
+ if (n === "Die") return "die";
71
+ if (n === "NumericTerm") return "num";
72
+ if (n === "OperatorTerm") return "op";
73
+ return "other";
74
+ };
75
+
76
+ // Decide whether a parsed roll can be solved exactly. Guard the convolution
77
+ // against pathological sizes (huge dice pools) by falling back to simulation.
78
+ const MAX_BUCKETS = 5000;
79
+ const canConvolve = (terms) => {
80
+ let minTotal = 0, maxTotal = 0;
81
+ for (const term of terms) {
82
+ const kind = classify(term);
83
+ if (kind === "op") {
84
+ if (term.operator !== "+" && term.operator !== "-") return false;
85
+ continue;
86
+ }
87
+ if (kind === "die") {
88
+ if (Array.isArray(term.modifiers) && term.modifiers.length > 0) return false;
89
+ const number = Number(term.number);
90
+ const faces = Number(term.faces);
91
+ if (!Number.isInteger(number) || !Number.isInteger(faces) || faces < 1 || number < 0) return false;
92
+ minTotal += number;
93
+ maxTotal += number * faces;
94
+ continue;
95
+ }
96
+ if (kind === "num") {
97
+ if (!Number.isFinite(Number(term.number))) return false;
98
+ continue;
99
+ }
100
+ return false; // unknown term type
101
+ }
102
+ return (maxTotal - minTotal) <= MAX_BUCKETS;
103
+ };
104
+
105
+ // Build the exact PMF for an additive term list.
106
+ const convolveTerms = (terms) => {
107
+ let acc = new Map([[0, 1]]);
108
+ let sign = 1;
109
+ for (const term of terms) {
110
+ const kind = classify(term);
111
+ if (kind === "op") {
112
+ sign = term.operator === "-" ? -1 : 1;
113
+ continue;
114
+ }
115
+ if (kind === "die") {
116
+ let dist = new Map([[0, 1]]);
117
+ for (let i = 0; i < Number(term.number); i++) dist = convolve(dist, dieDist(Number(term.faces)));
118
+ if (sign < 0) dist = negate(dist);
119
+ acc = convolve(acc, dist);
120
+ }
121
+ else if (kind === "num") {
122
+ acc = shift(acc, sign * Number(term.number));
123
+ }
124
+ sign = 1;
125
+ }
126
+ return acc;
127
+ };
128
+
129
+ // Derive the display payload (average, min, max, chart series) from a PMF.
130
+ const summarize = (dist, approximate) => {
131
+ const entries = [...dist.entries()].sort((a, b) => a[0] - b[0]);
132
+ let average = 0, min = Infinity, max = -Infinity;
133
+ for (const [v, p] of entries) {
134
+ average += v * p;
135
+ if (v < min) min = v;
136
+ if (v > max) max = v;
137
+ }
138
+ const outcomeValues = entries.map(([v]) => v);
139
+ const values = entries.map(([, p]) => +(p * 100).toFixed(2));
140
+ // Thin the x-axis labels so they don't cluster/overlap: pick a "nice" step
141
+ // (1, 2, 5, 10, ...) targeting ~8 labels, and only label outcomes that land on
142
+ // a multiple of it -- so the axis reads in round numbers (5, 10, 15, 20 ...).
143
+ const lo = outcomeValues.length ? outcomeValues[0] : 0;
144
+ const hi = outcomeValues.length ? outcomeValues[outcomeValues.length - 1] : 0;
145
+ const span = Math.max(1, hi - lo);
146
+ const rawStep = span / 8;
147
+ const pow = Math.pow(10, Math.floor(Math.log10(rawStep)));
148
+ const norm = rawStep / pow;
149
+ const niceStep = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * pow;
150
+ const step = Math.max(1, Math.round(niceStep));
151
+ return {
152
+ average: +average.toFixed(2),
153
+ min: entries.length ? min : 0,
154
+ max: entries.length ? max : 0,
155
+ labels: outcomeValues,
156
+ values,
157
+ step,
158
+ approximate,
159
+ hasData: entries.length > 0
160
+ };
161
+ };
162
+
163
+ // ----- Reactive computation --------------------------------------------------
164
+
165
+ const result = ref({ average: 0, min: 0, max: 0, labels: [], values: [], step: 1, approximate: false, hasData: false });
166
+ const iterations = ref(0);
167
+ let runToken = 0;
168
+
169
+ // Monte Carlo fallback. Roll.simulate builds a full Roll per sample (~0.2ms each),
170
+ // so a large batch blocks the main thread for seconds and freezes the sheet on
171
+ // render. Instead, sample a modest target in small chunks and yield to the event
172
+ // loop between each, refining the curve in place. The sheet stays responsive and
173
+ // the chart fills in progressively.
174
+ const SIM_TARGET = 2000;
175
+ const SIM_CHUNK = 200;
176
+ const runSimulation = async (formula, token, cacheKey) => {
177
+ const counts = new Map();
178
+ let done = 0;
179
+ while (done < SIM_TARGET) {
180
+ if (token !== runToken) return; // a newer run superseded us
181
+ const n = Math.min(SIM_CHUNK, SIM_TARGET - done);
182
+ const batch = await Roll.simulate(formula, n);
183
+ if (token !== runToken) return;
184
+ for (const v of batch) counts.set(v, (counts.get(v) || 0) + 1);
185
+ done += n;
186
+ const dist = new Map();
187
+ for (const [v, c] of counts) dist.set(v, c / done);
188
+ result.value = summarize(dist, true);
189
+ iterations.value = done;
190
+ // Yield so the browser can paint and handle input between chunks.
191
+ await new Promise(r => setTimeout(r, 0));
192
+ }
193
+ // Cache the completed estimate so this formula isn't re-simulated later.
194
+ __rollVisualizerCache.set(cacheKey, result.value);
195
+ };
196
+
197
+ const recompute = () => {
198
+ const token = ++runToken;
199
+ const raw = (props.formula || "").trim();
200
+ if (!raw) {
201
+ result.value = { average: 0, min: 0, max: 0, labels: [], values: [], step: 1, approximate: false, hasData: false };
202
+ return;
203
+ }
204
+ let roll;
205
+ try {
206
+ roll = new Roll(raw, props.rollData || {});
207
+ }
208
+ catch (e) {
209
+ result.value = { average: 0, min: 0, max: 0, labels: [], values: [], step: 1, approximate: false, hasData: false };
210
+ return;
211
+ }
212
+ // Cache hit on the resolved formula -> reuse the result, no recompute.
213
+ const cacheKey = roll.formula;
214
+ const cached = __rollVisualizerCache.get(cacheKey);
215
+ if (cached) {
216
+ result.value = cached;
217
+ iterations.value = cached.approximate ? SIM_TARGET : 0;
218
+ return;
219
+ }
220
+ // After construction Foundry has substituted @refs into the terms.
221
+ const terms = roll.terms || [];
222
+ if (canConvolve(terms)) {
223
+ iterations.value = 0;
224
+ const summary = summarize(convolveTerms(terms), false);
225
+ __rollVisualizerCache.set(cacheKey, summary);
226
+ result.value = summary;
227
+ }
228
+ else {
229
+ // Simulate against the resolved formula (no @refs remain in roll.formula).
230
+ runSimulation(roll.formula, token, cacheKey);
231
+ }
232
+ };
233
+
234
+ // Debounce so rapid field edits don't kick off redundant simulations.
235
+ let debounceHandle = null;
236
+ watch(
237
+ () => [props.formula, JSON.stringify(props.rollData || {})],
238
+ () => {
239
+ if (debounceHandle) clearTimeout(debounceHandle);
240
+ debounceHandle = setTimeout(recompute, 200);
241
+ },
242
+ { immediate: true }
243
+ );
244
+
245
+ const getLabel = computed(() => {
246
+ const localized = game.i18n.localize(props.label);
247
+ if (props.icon) return \`<i class="fa-solid \${props.icon}"></i> \${localized}\`;
248
+ return localized;
249
+ });
250
+ const accentColor = computed(() => props.color || "#92aed9");
251
+ const averageText = computed(() => (result.value.approximate ? "≈ " : "") + result.value.average);
252
+ </script>
253
+
254
+ <template>
255
+ <v-card class="isdl-roll-visualizer" :name="systemPath" variant="tonal" density="compact">
256
+ <div class="isdl-roll-visualizer__header">
257
+ <span class="isdl-roll-visualizer__label" v-html="getLabel"></span>
258
+ <span class="isdl-roll-visualizer__avg" :style="{ color: accentColor }">{{ averageText }}</span>
259
+ </div>
260
+ <v-sparkline
261
+ v-if="result.hasData"
262
+ :labels="result.labels"
263
+ :model-value="result.values"
264
+ :color="accentColor"
265
+ line-width="2"
266
+ padding="8"
267
+ smooth="6"
268
+ :label-size="10"
269
+ auto-draw
270
+ preserveAspectRatio="none"
271
+ >
272
+ <!-- Render a label only on "nice" round outcomes; blank elsewhere so the
273
+ axis doesn't cluster and never falls back to showing the raw value. -->
274
+ <template #label="item">
275
+ {{ Number(item.value) % result.step === 0 ? item.value : "" }}
276
+ </template>
277
+ </v-sparkline>
278
+ <div v-else class="isdl-roll-visualizer__empty text-caption">
279
+ {{ game.i18n.localize("ROLLVISUALIZER.NoFormula") }}
280
+ </div>
281
+ <div class="isdl-roll-visualizer__footer text-caption">
282
+ <span v-if="result.hasData">
283
+ {{ game.i18n.localize("ROLLVISUALIZER.Min") }}: {{ result.min }}
284
+ &middot;
285
+ {{ game.i18n.localize("ROLLVISUALIZER.Max") }}: {{ result.max }}
286
+ &middot;
287
+ {{ game.i18n.localize("ROLLVISUALIZER.Average") }}: {{ averageText }}
288
+ </span>
289
+ <span v-if="result.approximate && iterations > 0" class="isdl-roll-visualizer__approx">
290
+ ({{ iterations }} {{ game.i18n.localize("ROLLVISUALIZER.Simulations") }})
291
+ </span>
292
+ </div>
293
+ </v-card>
294
+ </template>
295
295
  `.appendNewLine();
296
296
  fs.writeFileSync(generatedFilePath, toString(fileNode));
297
297
  }