hoi4-agent-tools 3.10.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +115 -147
- package/dist/bin/http.js +10 -0
- package/dist/bin/setup.js +3 -2
- package/dist/bin/stdio.js +10 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
- package/dist/hoi4_agent_tools/core/canonical.js +18 -4
- package/dist/hoi4_agent_tools/core/configuration.js +7 -0
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
- package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
- package/dist/hoi4_agent_tools/core/engine.js +29 -2
- package/dist/hoi4_agent_tools/core/index.js +2 -2
- package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
- package/dist/hoi4_agent_tools/core/job-store.js +78 -5
- package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
- package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
- package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
- package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
- package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
- package/dist/hoi4_agent_tools/core/server-registry.js +22 -5
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
- package/dist/hoi4_agent_tools/core/source/cache.js +20 -10
- package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
- package/dist/hoi4_agent_tools/core/transactions.js +114 -23
- package/dist/hoi4_agent_tools/core/workspace.js +84 -8
- package/dist/hoi4_agent_tools/decision/ai.js +1 -1
- package/dist/hoi4_agent_tools/decision/service.js +28 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
- package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
- package/dist/hoi4_agent_tools/event/operations.js +14 -1
- package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
- package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
- package/dist/hoi4_agent_tools/gui/validators.js +8 -6
- package/dist/hoi4_agent_tools/map/bmp.js +1 -1
- package/dist/hoi4_agent_tools/map/derived.js +748 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
- package/dist/hoi4_agent_tools/map/model.js +103 -0
- package/dist/hoi4_agent_tools/map/operations.js +427 -58
- package/dist/hoi4_agent_tools/map/references.js +483 -0
- package/dist/hoi4_agent_tools/map/service.js +136 -61
- package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
- package/dist/hoi4_agent_tools/map/validation.js +3 -0
- package/dist/hoi4_agent_tools/map/world.js +1280 -0
- package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
- package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
- package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
- package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -29
- package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +31 -8
- package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
- package/dist/hoi4_agent_tools/probability/operations.js +175 -13
- package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
- package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
- package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
- package/dist/hoi4_agent_tools/reference/operations.js +88 -19
- package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
- package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
- package/dist/hoi4_agent_tools/reference/service.js +6 -0
- package/dist/hoi4_agent_tools/schemas/event.js +78 -19
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
- package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
- package/dist/hoi4_agent_tools/schemas/map.js +122 -3
- package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
- package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
- package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
- package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
- package/dist/hoi4_agent_tools/schemas/technology.js +79 -21
- package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
- package/dist/hoi4_agent_tools/technology/operations.js +14 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +7 -4
- package/docs/analysis.md +13 -0
- package/docs/configuration.md +281 -0
- package/docs/development.md +24 -2
- package/docs/events.md +27 -3
- package/docs/examples.md +6 -6
- package/docs/focus.md +125 -4
- package/docs/gui.md +98 -17
- package/docs/helper-expansion.md +8 -8
- package/docs/http.md +3 -1
- package/docs/jobs.md +19 -13
- package/docs/map.md +197 -6
- package/docs/mechanics.md +4 -0
- package/docs/probability.md +42 -15
- package/docs/reference.md +71 -4
- package/docs/setup.md +44 -11
- package/docs/technology.md +26 -1
- package/docs/third-party-notices.md +11 -0
- package/docs/tools.md +151 -42
- package/docs/troubleshooting.md +61 -0
- package/examples/clients/codex-config.toml +5 -2
- package/examples/clients/generic.json +2 -2
- package/examples/config/config.json +7 -0
- package/npm-shrinkwrap.json +151 -156
- package/package.json +13 -8
- package/schemas/configuration.schema.json +144 -128
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +55 -0
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +597 -126
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +51 -0
- package/schemas/operation-result.schema.json +2 -2
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +3 -5
- package/schemas/scenario-test-input.schema.json +3 -5
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import rulesFile from './known-mistakes.json' with { type: 'json' };
|
|
3
|
+
/**
|
|
4
|
+
* Known mistakes: a reviewed, source-cited list of script errors that the game accepts silently
|
|
5
|
+
* or reports with an unhelpful message. The list ships with the server as `known-mistakes.json`;
|
|
6
|
+
* it is changed only by reviewed edits to that file, never at run time. Each active rule has a
|
|
7
|
+
* detector below that flags the mistake in a checked file, and a rule that no longer applies
|
|
8
|
+
* stays in the file as `resolved` with the reason, so the history is kept and it is no longer
|
|
9
|
+
* applied.
|
|
10
|
+
*/
|
|
11
|
+
const sourceSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
kind: z.enum(['wiki', 'game_doc']),
|
|
14
|
+
page: z.string().min(1),
|
|
15
|
+
heading: z.string().min(1),
|
|
16
|
+
item: z.string().min(1),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
const ruleSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
id: z.string().regex(/^[a-z][a-z0-9_]{2,63}$/u),
|
|
22
|
+
status: z.enum(['active', 'resolved']),
|
|
23
|
+
severity: z.enum(['error', 'warning']),
|
|
24
|
+
surfaces: z
|
|
25
|
+
.array(z.enum([
|
|
26
|
+
'event',
|
|
27
|
+
'decision',
|
|
28
|
+
'idea',
|
|
29
|
+
'focus',
|
|
30
|
+
'technology',
|
|
31
|
+
'gui',
|
|
32
|
+
'map',
|
|
33
|
+
'localisation',
|
|
34
|
+
'ai',
|
|
35
|
+
]))
|
|
36
|
+
.min(1),
|
|
37
|
+
title: z.string().min(1).max(120),
|
|
38
|
+
mistake: z.string().min(1).max(300),
|
|
39
|
+
symptom: z.string().min(1).max(400),
|
|
40
|
+
fix: z.string().min(1).max(300),
|
|
41
|
+
sources: z.array(sourceSchema).min(1),
|
|
42
|
+
/** A regular expression for the game's own log message about the mistake. */
|
|
43
|
+
logPattern: z.string().min(1).max(200).optional(),
|
|
44
|
+
/** Required when `status` is `resolved`: why the rule no longer applies. */
|
|
45
|
+
resolution: z
|
|
46
|
+
.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u), note: z.string().min(1).max(300) })
|
|
47
|
+
.strict()
|
|
48
|
+
.optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict()
|
|
51
|
+
.superRefine((rule, context) => {
|
|
52
|
+
if ((rule.status === 'resolved') !== (rule.resolution !== undefined))
|
|
53
|
+
context.addIssue({
|
|
54
|
+
code: 'custom',
|
|
55
|
+
path: ['resolution'],
|
|
56
|
+
message: 'A resolved rule needs a resolution; an active rule must not have one',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
const rulesSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
version: z.literal(1),
|
|
62
|
+
reviewed: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u),
|
|
63
|
+
rules: z.array(ruleSchema).min(1),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
66
|
+
/** The reviewed rule list, validated once; a malformed file fails when the server loads. */
|
|
67
|
+
export const knownMistakeRules = rulesSchema.parse(rulesFile).rules;
|
|
68
|
+
/** Rules that are applied: resolved rules stay in the file as history only. */
|
|
69
|
+
export function activeKnownMistakes(surface, rules = knownMistakeRules) {
|
|
70
|
+
return rules.filter((rule) => rule.status === 'active' &&
|
|
71
|
+
(surface === undefined || surface === 'general' || rule.surfaces.some((s) => s === surface)));
|
|
72
|
+
}
|
|
73
|
+
/** The rule whose logged message `message` is, so a log entry can name its documented fix. */
|
|
74
|
+
export function knownMistakeForLogMessage(message, rules = knownMistakeRules) {
|
|
75
|
+
return activeKnownMistakes(undefined, rules).find((rule) => rule.logPattern !== undefined && new RegExp(rule.logPattern, 'iu').test(message));
|
|
76
|
+
}
|
|
77
|
+
function assignmentsOf(block) {
|
|
78
|
+
return block.entries.filter((entry) => entry.type === 'assignment');
|
|
79
|
+
}
|
|
80
|
+
function field(block, key) {
|
|
81
|
+
return assignmentsOf(block).find((entry) => entry.key.value === key);
|
|
82
|
+
}
|
|
83
|
+
function scalarField(block, key) {
|
|
84
|
+
const entry = field(block, key);
|
|
85
|
+
return entry?.value.type === 'scalar' ? entry.value.value : undefined;
|
|
86
|
+
}
|
|
87
|
+
const eventKeys = new Set([
|
|
88
|
+
'country_event',
|
|
89
|
+
'news_event',
|
|
90
|
+
'state_event',
|
|
91
|
+
'unit_leader_event',
|
|
92
|
+
'operative_leader_event',
|
|
93
|
+
]);
|
|
94
|
+
/** The event definitions of a file: the assignment, its block and its id. */
|
|
95
|
+
function eventsOf(document) {
|
|
96
|
+
const events = [];
|
|
97
|
+
for (const node of assignmentsOf(document.root))
|
|
98
|
+
if (eventKeys.has(node.key.value) && node.value.type === 'block')
|
|
99
|
+
events.push({ node, body: node.value, id: scalarField(node.value, 'id') });
|
|
100
|
+
return events;
|
|
101
|
+
}
|
|
102
|
+
/** `days` of a `mean_time_to_happen` block when it is a plain number. */
|
|
103
|
+
function baseMeanDays(timer) {
|
|
104
|
+
if (timer.value.type !== 'block')
|
|
105
|
+
return undefined;
|
|
106
|
+
const days = scalarField(timer.value, 'days');
|
|
107
|
+
const months = scalarField(timer.value, 'months');
|
|
108
|
+
const years = scalarField(timer.value, 'years');
|
|
109
|
+
const unit = (value, factor) => value === undefined ? 0 : /^-?\d+(?:\.\d+)?$/u.test(value) ? Number(value) * factor : undefined;
|
|
110
|
+
const parts = [unit(days, 1), unit(months, 30), unit(years, 365)];
|
|
111
|
+
let total = 0;
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
if (part === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
total += part;
|
|
116
|
+
}
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
/** Days from a `Y.M.D` or `Y.M.D.H` date, ignoring leap days as the game does. */
|
|
120
|
+
function dayNumber(date) {
|
|
121
|
+
const match = /^(\d{1,4})\.(\d{1,2})\.(\d{1,2})(?:\.\d{1,2})?$/u.exec(date);
|
|
122
|
+
if (match === null)
|
|
123
|
+
return undefined;
|
|
124
|
+
const monthDays = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
|
125
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
126
|
+
if (month < 1 || month > 12)
|
|
127
|
+
return undefined;
|
|
128
|
+
return year * 365 + monthDays[month - 1] + day;
|
|
129
|
+
}
|
|
130
|
+
/** Date bounds directly inside a trigger block, with `OR`/`NOT` branches left out. */
|
|
131
|
+
function dateBounds(trigger) {
|
|
132
|
+
const bounds = {};
|
|
133
|
+
for (const entry of assignmentsOf(trigger)) {
|
|
134
|
+
if (entry.key.value !== 'date' || entry.value.type !== 'scalar')
|
|
135
|
+
continue;
|
|
136
|
+
const day = dayNumber(entry.value.value);
|
|
137
|
+
if (day === undefined)
|
|
138
|
+
continue;
|
|
139
|
+
if (entry.operator.text === '>' || entry.operator.text === '>=')
|
|
140
|
+
bounds.lower = Math.max(bounds.lower ?? day, day);
|
|
141
|
+
else if (entry.operator.text === '<' || entry.operator.text === '<=')
|
|
142
|
+
bounds.upper = Math.min(bounds.upper ?? day, day);
|
|
143
|
+
}
|
|
144
|
+
return bounds;
|
|
145
|
+
}
|
|
146
|
+
const TRIGGER_CHECK_DAYS = 20;
|
|
147
|
+
const detectors = {
|
|
148
|
+
event_triggered_only_with_mtth: (document) => eventsOf(document).flatMap(({ body }) => {
|
|
149
|
+
const timer = field(body, 'mean_time_to_happen');
|
|
150
|
+
return timer !== undefined && scalarField(body, 'is_triggered_only') === 'yes'
|
|
151
|
+
? [{ node: timer }]
|
|
152
|
+
: [];
|
|
153
|
+
}),
|
|
154
|
+
event_fires_every_day: (document) => eventsOf(document).flatMap(({ node, body }) => {
|
|
155
|
+
if (scalarField(body, 'is_triggered_only') === 'yes')
|
|
156
|
+
return [];
|
|
157
|
+
if (scalarField(body, 'fire_only_once') === 'yes')
|
|
158
|
+
return [];
|
|
159
|
+
const timer = field(body, 'mean_time_to_happen');
|
|
160
|
+
if (timer === undefined)
|
|
161
|
+
return [{ node }];
|
|
162
|
+
const days = baseMeanDays(timer);
|
|
163
|
+
return days !== undefined && days <= 1 ? [{ node: timer }] : [];
|
|
164
|
+
}),
|
|
165
|
+
news_event_not_major: (document) => eventsOf(document).flatMap(({ node, body }) => {
|
|
166
|
+
if (node.key.value !== 'news_event')
|
|
167
|
+
return [];
|
|
168
|
+
if (scalarField(body, 'major') !== 'yes')
|
|
169
|
+
return [{ node }];
|
|
170
|
+
const once = field(body, 'fire_only_once');
|
|
171
|
+
return once !== undefined && scalarField(body, 'fire_only_once') === 'yes'
|
|
172
|
+
? [{ node: once }]
|
|
173
|
+
: [];
|
|
174
|
+
}),
|
|
175
|
+
event_date_window_too_tight: (document) => eventsOf(document).flatMap(({ body }) => {
|
|
176
|
+
if (scalarField(body, 'is_triggered_only') === 'yes')
|
|
177
|
+
return [];
|
|
178
|
+
const trigger = field(body, 'trigger');
|
|
179
|
+
if (trigger?.value.type !== 'block')
|
|
180
|
+
return [];
|
|
181
|
+
const { lower, upper } = dateBounds(trigger.value);
|
|
182
|
+
return lower !== undefined && upper !== undefined && upper - lower < TRIGGER_CHECK_DAYS
|
|
183
|
+
? [{ node: trigger }]
|
|
184
|
+
: [];
|
|
185
|
+
}),
|
|
186
|
+
event_namespace_missing: (document, _family, context) => {
|
|
187
|
+
const declared = new Set(context.eventNamespaces ?? []);
|
|
188
|
+
for (const node of assignmentsOf(document.root))
|
|
189
|
+
if (node.key.value === 'add_namespace' && node.value.type === 'scalar')
|
|
190
|
+
declared.add(node.value.value);
|
|
191
|
+
return eventsOf(document).flatMap(({ body, id }) => {
|
|
192
|
+
const idNode = field(body, 'id');
|
|
193
|
+
const dot = id?.lastIndexOf('.') ?? -1;
|
|
194
|
+
if (idNode === undefined || id === undefined || dot < 1)
|
|
195
|
+
return [];
|
|
196
|
+
const namespace = id.slice(0, dot);
|
|
197
|
+
return declared.has(namespace)
|
|
198
|
+
? []
|
|
199
|
+
: [
|
|
200
|
+
{
|
|
201
|
+
node: idNode,
|
|
202
|
+
message: `${id} uses namespace ${namespace}, which no events file declares with add_namespace`,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
event_numeric_id_invalid: (document) => eventsOf(document).flatMap(({ body, id }) => {
|
|
208
|
+
const idNode = field(body, 'id');
|
|
209
|
+
const dot = id?.lastIndexOf('.') ?? -1;
|
|
210
|
+
if (idNode === undefined || id === undefined || dot < 1)
|
|
211
|
+
return [];
|
|
212
|
+
const number = id.slice(dot + 1);
|
|
213
|
+
if (/^\d+$/u.test(number) && Number(number) < 100_000)
|
|
214
|
+
return [];
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
node: idNode,
|
|
218
|
+
message: `${id} ends in "${number}", which is not a whole number from 0 to 99999`,
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
}),
|
|
222
|
+
temp_variable_scope_prefix: (document) => {
|
|
223
|
+
const temporary = new Set();
|
|
224
|
+
const regular = new Set();
|
|
225
|
+
const names = [];
|
|
226
|
+
const setters = /^(?:set|add_to|subtract_from|multiply|divide|modulo|round|clamp|set_to_random|clear)_(temp_)?variable$/u;
|
|
227
|
+
const visit = (block) => {
|
|
228
|
+
for (const entry of block.entries) {
|
|
229
|
+
if (entry.type === 'block') {
|
|
230
|
+
visit(entry);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (entry.type !== 'assignment')
|
|
234
|
+
continue;
|
|
235
|
+
const setter = setters.exec(entry.key.value);
|
|
236
|
+
if (setter !== null && entry.value.type === 'block')
|
|
237
|
+
for (const child of assignmentsOf(entry.value)) {
|
|
238
|
+
if (child.key.value === 'var' && child.value.type === 'scalar')
|
|
239
|
+
(setter[1] === undefined ? regular : temporary).add(child.value.value);
|
|
240
|
+
else if (child.key.value !== 'value' &&
|
|
241
|
+
child.key.value !== 'tooltip' &&
|
|
242
|
+
child.key.value !== 'min' &&
|
|
243
|
+
child.key.value !== 'max' &&
|
|
244
|
+
child.key.value !== 'integer')
|
|
245
|
+
(setter[1] === undefined ? regular : temporary).add(child.key.value);
|
|
246
|
+
}
|
|
247
|
+
else if (setter !== null && entry.value.type === 'scalar')
|
|
248
|
+
(setter[1] === undefined ? regular : temporary).add(entry.value.value);
|
|
249
|
+
if (entry.value.type === 'scalar' && entry.value.value.includes('.'))
|
|
250
|
+
names.push({ node: entry, token: entry.value.value });
|
|
251
|
+
if (entry.value.type === 'block')
|
|
252
|
+
visit(entry.value);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
visit(document.root);
|
|
256
|
+
const findings = [];
|
|
257
|
+
for (const { node, token } of names) {
|
|
258
|
+
const name = token.slice(token.lastIndexOf('.') + 1);
|
|
259
|
+
if (!temporary.has(name) || regular.has(name))
|
|
260
|
+
continue;
|
|
261
|
+
if (!/^(?:[A-Za-z_][\w]*|[a-z_]+:[\w]+)(?:\.[A-Za-z_][\w]*)+$/u.test(token))
|
|
262
|
+
continue;
|
|
263
|
+
findings.push({
|
|
264
|
+
node,
|
|
265
|
+
message: `${token} reads the temporary variable ${name} through a scope; temporary variables are unscoped, so read ${name} directly`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return findings;
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
/** The rule ids that have a detector. */
|
|
272
|
+
export const knownMistakeDetectorIds = Object.keys(detectors);
|
|
273
|
+
/** The families each rule's detector runs in; others do not hold the construct. */
|
|
274
|
+
const ruleFamilies = {
|
|
275
|
+
event_triggered_only_with_mtth: ['events'],
|
|
276
|
+
event_fires_every_day: ['events'],
|
|
277
|
+
news_event_not_major: ['events'],
|
|
278
|
+
event_date_window_too_tight: ['events'],
|
|
279
|
+
event_namespace_missing: ['events'],
|
|
280
|
+
event_numeric_id_invalid: ['events'],
|
|
281
|
+
temp_variable_scope_prefix: 'any',
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Flags the known mistakes in one checked file. Resolved rules are not applied. Each finding
|
|
285
|
+
* names the rule, the symptom and the documented fix, in the finding's own message.
|
|
286
|
+
*/
|
|
287
|
+
export function knownMistakeFindings(family, document, context = {}, rules = knownMistakeRules) {
|
|
288
|
+
const findings = [];
|
|
289
|
+
for (const rule of activeKnownMistakes(undefined, rules)) {
|
|
290
|
+
const detector = detectors[rule.id];
|
|
291
|
+
const families = ruleFamilies[rule.id];
|
|
292
|
+
if (detector === undefined || families === undefined)
|
|
293
|
+
continue;
|
|
294
|
+
if (families !== 'any' && !families.includes(family))
|
|
295
|
+
continue;
|
|
296
|
+
for (const hit of detector(document, family, context))
|
|
297
|
+
findings.push({
|
|
298
|
+
code: `KNOWN_MISTAKE_${rule.id.toUpperCase()}`,
|
|
299
|
+
command: hit.node.key.value,
|
|
300
|
+
offset: hit.node.start,
|
|
301
|
+
end: hit.node.end,
|
|
302
|
+
message: `${hit.message ?? rule.title}. ${rule.symptom} Fix: ${rule.fix}`.slice(0, 500),
|
|
303
|
+
rule,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return findings;
|
|
307
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"reviewed": "2026-10-10",
|
|
4
|
+
"rules": [
|
|
5
|
+
{
|
|
6
|
+
"id": "event_triggered_only_with_mtth",
|
|
7
|
+
"status": "active",
|
|
8
|
+
"severity": "warning",
|
|
9
|
+
"surfaces": ["event"],
|
|
10
|
+
"title": "mean_time_to_happen on a triggered-only event",
|
|
11
|
+
"mistake": "The event sets is_triggered_only = yes and also mean_time_to_happen.",
|
|
12
|
+
"symptom": "The game logs \"Event is triggered only, but does not have a 1 base-factor\", and the timer never runs.",
|
|
13
|
+
"fix": "Remove mean_time_to_happen, or remove is_triggered_only if the event is meant to fire by itself.",
|
|
14
|
+
"sources": [
|
|
15
|
+
{
|
|
16
|
+
"kind": "wiki",
|
|
17
|
+
"page": "Event modding",
|
|
18
|
+
"heading": "Common mistakes > Unintuitive logged errors",
|
|
19
|
+
"item": "Event is triggered only, but does not have a 1 base-factor."
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"logPattern": "Event is triggered only, but does not have a 1 base-factor"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "event_fires_every_day",
|
|
26
|
+
"status": "active",
|
|
27
|
+
"severity": "warning",
|
|
28
|
+
"surfaces": ["event"],
|
|
29
|
+
"title": "Automatic event with a one-day timer",
|
|
30
|
+
"mistake": "The event can fire by itself, can repeat, and has no mean_time_to_happen or one of 1 day or less.",
|
|
31
|
+
"symptom": "The game logs \"Event is set to trigger every day\"; once its trigger is true the event can fire daily.",
|
|
32
|
+
"fix": "Add is_triggered_only = yes and fire the event from an effect or on action, add fire_only_once = yes, or give it a longer mean_time_to_happen.",
|
|
33
|
+
"sources": [
|
|
34
|
+
{
|
|
35
|
+
"kind": "wiki",
|
|
36
|
+
"page": "Event modding",
|
|
37
|
+
"heading": "Common mistakes > Unintuitive logged errors",
|
|
38
|
+
"item": "Event is set to trigger every day."
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"logPattern": "Event is set to trigger every day"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "news_event_not_major",
|
|
45
|
+
"status": "active",
|
|
46
|
+
"severity": "warning",
|
|
47
|
+
"surfaces": ["event"],
|
|
48
|
+
"title": "News event that reaches one country",
|
|
49
|
+
"mistake": "A news_event lacks major = yes, or combines major = yes with fire_only_once = yes.",
|
|
50
|
+
"symptom": "Only the first country receives the news event; fire_only_once cancels the effect of major.",
|
|
51
|
+
"fix": "Set major = yes and remove fire_only_once. Use show_major to limit which countries see it.",
|
|
52
|
+
"sources": [
|
|
53
|
+
{
|
|
54
|
+
"kind": "wiki",
|
|
55
|
+
"page": "Event modding",
|
|
56
|
+
"heading": "Common mistakes > Unlogged errors",
|
|
57
|
+
"item": "Setting a news event to fire only once or not setting one as major"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "event_date_window_too_tight",
|
|
63
|
+
"status": "active",
|
|
64
|
+
"severity": "warning",
|
|
65
|
+
"surfaces": ["event"],
|
|
66
|
+
"title": "Date window shorter than the 20-day trigger check",
|
|
67
|
+
"mistake": "An event that fires by itself bounds date on both sides less than 20 days apart in its trigger.",
|
|
68
|
+
"symptom": "The trigger is checked about every 20 days, so the window can fall between two checks and the event never fires.",
|
|
69
|
+
"fix": "Make the event is_triggered_only = yes and fire it with a delay from on_startup or another effect, or widen the window to at least 20 days.",
|
|
70
|
+
"sources": [
|
|
71
|
+
{
|
|
72
|
+
"kind": "wiki",
|
|
73
|
+
"page": "Event modding",
|
|
74
|
+
"heading": "Common mistakes > Unlogged errors",
|
|
75
|
+
"item": "Tight bounds on date triggers"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "event_namespace_missing",
|
|
81
|
+
"status": "active",
|
|
82
|
+
"severity": "error",
|
|
83
|
+
"surfaces": ["event"],
|
|
84
|
+
"title": "Event id without add_namespace",
|
|
85
|
+
"mistake": "An event id uses a namespace that no events file declares with add_namespace.",
|
|
86
|
+
"symptom": "The game logs \"Malformed token\" at the id line and the event does not work.",
|
|
87
|
+
"fix": "Add add_namespace = <namespace> outside any event, before the first event that uses it.",
|
|
88
|
+
"sources": [
|
|
89
|
+
{
|
|
90
|
+
"kind": "wiki",
|
|
91
|
+
"page": "Event modding",
|
|
92
|
+
"heading": "ID rules",
|
|
93
|
+
"item": "A namespace must be first created before defining any events that use it."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"kind": "wiki",
|
|
97
|
+
"page": "Event modding",
|
|
98
|
+
"heading": "Common mistakes > Unintuitive logged errors",
|
|
99
|
+
"item": "Malformed token: event_id.123, near line"
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"logPattern": "Malformed token: [\\w.]+\\.\\d+"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "event_numeric_id_invalid",
|
|
106
|
+
"status": "active",
|
|
107
|
+
"severity": "error",
|
|
108
|
+
"surfaces": ["event"],
|
|
109
|
+
"title": "Event number that is not a whole number below 100000",
|
|
110
|
+
"mistake": "The part of an event id after the last dot is not an integer, or is 100000 or more.",
|
|
111
|
+
"symptom": "Every non-integer id becomes number 0, and numbers from 100000 reach the next namespace, so events share one internal id and one fires the other (\"Failed to create id ... Already exists in game\").",
|
|
112
|
+
"fix": "End the id with a whole number from 0 to 99999, unique within its namespace.",
|
|
113
|
+
"sources": [
|
|
114
|
+
{
|
|
115
|
+
"kind": "wiki",
|
|
116
|
+
"page": "Event modding",
|
|
117
|
+
"heading": "ID rules",
|
|
118
|
+
"item": "the ID of the event after the namespace must be an integer"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"kind": "wiki",
|
|
122
|
+
"page": "Event modding",
|
|
123
|
+
"heading": "Common mistakes > Unintuitive logged errors",
|
|
124
|
+
"item": "Failed to create id 12300000 50. Already exists in game."
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
"logPattern": "Failed to create id \\d+ 50\\. Already exists in game"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"id": "temp_variable_scope_prefix",
|
|
131
|
+
"status": "active",
|
|
132
|
+
"severity": "warning",
|
|
133
|
+
"surfaces": ["event", "decision", "focus"],
|
|
134
|
+
"title": "Scope prefix on a temporary variable",
|
|
135
|
+
"mistake": "A temporary variable is read through a scope, such as ROOT.my_temp, in a file that only ever sets it as a temporary variable.",
|
|
136
|
+
"symptom": "Temporary variables have no scope, so the prefix does nothing useful and the read does not find the variable the author meant.",
|
|
137
|
+
"fix": "Read the temporary variable by its plain name, or store the value in a regular variable on the scope that needs it.",
|
|
138
|
+
"sources": [
|
|
139
|
+
{
|
|
140
|
+
"kind": "wiki",
|
|
141
|
+
"page": "Data structures",
|
|
142
|
+
"heading": "Variables > Variable types",
|
|
143
|
+
"item": "Temporary variables are unscoped"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"kind": "wiki",
|
|
147
|
+
"page": "Data structures",
|
|
148
|
+
"heading": "Variables > Scoping",
|
|
149
|
+
"item": "Scoping only exists for regular variables."
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
import { readModDescriptor } from '../core/workspace.js';
|
|
3
|
+
const MAX_NAMESPACES = 200;
|
|
4
|
+
const MAX_FOLDERS = 60;
|
|
5
|
+
/** Event namespaces with their numbered range, from `namespace.N` event IDs. */
|
|
6
|
+
function namespaces(symbols) {
|
|
7
|
+
const byNamespace = new Map();
|
|
8
|
+
for (const symbol of symbols) {
|
|
9
|
+
if (symbol.kind !== 'event')
|
|
10
|
+
continue;
|
|
11
|
+
const match = /^(.+)\.(\d{1,9})$/u.exec(symbol.id);
|
|
12
|
+
if (match === null)
|
|
13
|
+
continue;
|
|
14
|
+
const entry = byNamespace.get(match[1]) ?? { numbers: [], files: new Set() };
|
|
15
|
+
entry.numbers.push(Number(match[2]));
|
|
16
|
+
entry.files.add(symbol.path);
|
|
17
|
+
byNamespace.set(match[1], entry);
|
|
18
|
+
}
|
|
19
|
+
return [...byNamespace]
|
|
20
|
+
.map(([namespace, { numbers, files }]) => ({
|
|
21
|
+
namespace,
|
|
22
|
+
events: new Set(numbers).size,
|
|
23
|
+
lowest: Math.min(...numbers),
|
|
24
|
+
highest: Math.max(...numbers),
|
|
25
|
+
files: files.size,
|
|
26
|
+
}))
|
|
27
|
+
.sort((left, right) => left.namespace.localeCompare(right.namespace, 'en'));
|
|
28
|
+
}
|
|
29
|
+
export async function modIndex(engine, workspaceId, input, principal, signal) {
|
|
30
|
+
if (input.mode === 'next_id') {
|
|
31
|
+
const prefix = input.prefix ??
|
|
32
|
+
(input.namespace === undefined ? undefined : `${input.namespace.replace(/\.$/u, '')}.`);
|
|
33
|
+
if (prefix === undefined)
|
|
34
|
+
throw new ServiceError('MOD_INDEX_PREFIX_REQUIRED', 'next_id needs prefix or namespace');
|
|
35
|
+
// Every layer: a new identifier must not collide with game or dependency definitions.
|
|
36
|
+
const snapshot = await engine.scan(workspaceId, {}, principal, signal);
|
|
37
|
+
const used = new Set();
|
|
38
|
+
for (const symbol of snapshot.index.symbols) {
|
|
39
|
+
if (symbol.kind !== input.kind || !symbol.id.startsWith(prefix))
|
|
40
|
+
continue;
|
|
41
|
+
const suffix = symbol.id.slice(prefix.length);
|
|
42
|
+
if (/^\d{1,9}$/u.test(suffix))
|
|
43
|
+
used.add(Number(suffix));
|
|
44
|
+
}
|
|
45
|
+
const highest = used.size === 0 ? undefined : Math.max(...used);
|
|
46
|
+
let firstGap;
|
|
47
|
+
for (let number = 1; highest !== undefined && number < highest; number += 1)
|
|
48
|
+
if (!used.has(number)) {
|
|
49
|
+
firstGap = number;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
const next = (highest ?? 0) + 1;
|
|
53
|
+
return {
|
|
54
|
+
mode: 'next_id',
|
|
55
|
+
complete: snapshot.complete,
|
|
56
|
+
next: {
|
|
57
|
+
kind: input.kind,
|
|
58
|
+
prefix,
|
|
59
|
+
used: used.size,
|
|
60
|
+
...(highest === undefined ? {} : { highest }),
|
|
61
|
+
nextId: `${prefix}${next}`,
|
|
62
|
+
...(firstGap === undefined ? {} : { firstGapId: `${prefix}${firstGap}` }),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const snapshot = await engine.scan(workspaceId, input.layer === 'mod' ? { rootKinds: ['mod'] } : {}, principal, signal);
|
|
67
|
+
const symbols = snapshot.index.symbols.filter(({ overridden }) => !overridden);
|
|
68
|
+
const kinds = new Map();
|
|
69
|
+
for (const symbol of symbols)
|
|
70
|
+
kinds.set(symbol.kind, (kinds.get(symbol.kind) ?? 0) + 1);
|
|
71
|
+
const folders = new Map();
|
|
72
|
+
for (const file of snapshot.files) {
|
|
73
|
+
const parts = file.relativePath.split('/');
|
|
74
|
+
const folder = parts.length > 2 && parts[0] === 'common' ? parts.slice(0, 2).join('/') : parts[0];
|
|
75
|
+
const totals = folders.get(folder) ?? { files: 0, bytes: 0 };
|
|
76
|
+
totals.files += 1;
|
|
77
|
+
totals.bytes += file.size;
|
|
78
|
+
folders.set(folder, totals);
|
|
79
|
+
}
|
|
80
|
+
const allNamespaces = namespaces(symbols);
|
|
81
|
+
const modRoot = engine.resolver
|
|
82
|
+
.get(workspaceId, principal)
|
|
83
|
+
.roots.find(({ kind }) => kind === 'mod')?.path;
|
|
84
|
+
const descriptor = modRoot === undefined ? undefined : await readModDescriptor(modRoot);
|
|
85
|
+
return {
|
|
86
|
+
mode: 'overview',
|
|
87
|
+
layer: input.layer,
|
|
88
|
+
complete: snapshot.complete,
|
|
89
|
+
...(descriptor === undefined ? {} : { descriptor }),
|
|
90
|
+
files: snapshot.files.length,
|
|
91
|
+
kinds: [...kinds]
|
|
92
|
+
.map(([kind, count]) => ({ kind, count }))
|
|
93
|
+
.sort((left, right) => right.count - left.count || left.kind.localeCompare(right.kind)),
|
|
94
|
+
folders: [...folders]
|
|
95
|
+
.map(([folder, totals]) => ({ folder, ...totals }))
|
|
96
|
+
.sort((left, right) => right.files - left.files || left.folder.localeCompare(right.folder))
|
|
97
|
+
.slice(0, MAX_FOLDERS),
|
|
98
|
+
namespaces: allNamespaces.slice(0, MAX_NAMESPACES),
|
|
99
|
+
omittedNamespaces: Math.max(0, allNamespaces.length - MAX_NAMESPACES),
|
|
100
|
+
};
|
|
101
|
+
}
|