redscript-mc 2.1.0 → 2.1.1
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/README.md +36 -0
- package/dist/src/__tests__/lsp.test.js +76 -0
- package/dist/src/__tests__/mc-syntax.test.js +1 -6
- package/dist/src/__tests__/stdlib-include.test.d.ts +1 -0
- package/dist/src/__tests__/stdlib-include.test.js +86 -0
- package/dist/src/cli.js +10 -3
- package/dist/src/compile.d.ts +1 -0
- package/dist/src/compile.js +33 -10
- package/dist/src/emit/compile.d.ts +2 -0
- package/dist/src/emit/compile.js +2 -2
- package/dist/src/lsp/server.js +51 -0
- package/editors/vscode/package-lock.json +3 -3
- package/editors/vscode/package.json +1 -1
- package/editors/vscode/syntaxes/redscript.tmLanguage.json +34 -0
- package/examples/coroutine-demo.mcrs +50 -0
- package/examples/enum-demo.mcrs +95 -0
- package/examples/scheduler-demo.mcrs +59 -0
- package/package.json +1 -1
- package/src/__tests__/lsp.test.ts +89 -0
- package/src/__tests__/mc-syntax.test.ts +1 -7
- package/src/__tests__/stdlib-include.test.ts +61 -0
- package/src/cli.ts +9 -1
- package/src/compile.ts +44 -15
- package/src/emit/compile.ts +4 -2
- package/src/lsp/server.ts +55 -0
- package/examples/spiral.mcrs +0 -43
- package/src/examples/arena.mcrs +0 -44
- package/src/examples/counter.mcrs +0 -12
- package/src/examples/new_features_demo.mcrs +0 -193
- package/src/examples/rpg.mcrs +0 -13
- package/src/examples/stdlib_demo.mcrs +0 -181
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// enum-demo.mcrs — NPC AI state machine using enums and match
|
|
2
|
+
//
|
|
3
|
+
// An NPC cycles through Idle → Moving → Attacking states.
|
|
4
|
+
// Each state has its own behaviour, driven by @tick.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// /function demo:npc_start activate the NPC AI
|
|
8
|
+
// /function demo:npc_stop deactivate the NPC AI
|
|
9
|
+
|
|
10
|
+
enum Phase {
|
|
11
|
+
Idle, // 0 — waiting for a player nearby
|
|
12
|
+
Moving, // 1 — closing the distance
|
|
13
|
+
Attacking, // 2 — striking the nearest player
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
struct NpcState {
|
|
17
|
+
phase: int, // current Phase value
|
|
18
|
+
ticks: int, // ticks in the current phase
|
|
19
|
+
active: bool
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let npc: NpcState = {
|
|
23
|
+
phase: 0,
|
|
24
|
+
ticks: 0,
|
|
25
|
+
active: false
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
@load
|
|
29
|
+
fn npc_load() {
|
|
30
|
+
npc.phase = Phase.Idle;
|
|
31
|
+
npc.ticks = 0;
|
|
32
|
+
npc.active = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn npc_start() {
|
|
36
|
+
npc.active = true;
|
|
37
|
+
npc.phase = Phase.Idle;
|
|
38
|
+
npc.ticks = 0;
|
|
39
|
+
actionbar(@a, "[NPC] AI activated");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn npc_stop() {
|
|
43
|
+
npc.active = false;
|
|
44
|
+
actionbar(@a, "[NPC] AI deactivated");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Phase handlers ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
fn phase_idle() {
|
|
50
|
+
actionbar(@a, "[NPC] Idle — scanning for targets...");
|
|
51
|
+
// After 40 ticks (2 seconds) transition to Moving
|
|
52
|
+
if (npc.ticks >= 40) {
|
|
53
|
+
npc.phase = Phase.Moving;
|
|
54
|
+
npc.ticks = 0;
|
|
55
|
+
title(@a, "NPC begins moving");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn phase_moving() {
|
|
60
|
+
actionbar(@a, "[NPC] Moving — closing distance");
|
|
61
|
+
// Simulate movement toward nearest player
|
|
62
|
+
raw("execute as @e[type=minecraft:zombie,tag=npc_ai] at @s run tp @s @p[limit=1] 0 0 0");
|
|
63
|
+
if (npc.ticks >= 60) {
|
|
64
|
+
npc.phase = Phase.Attacking;
|
|
65
|
+
npc.ticks = 0;
|
|
66
|
+
title(@a, "NPC attacks!");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn phase_attacking() {
|
|
71
|
+
actionbar(@a, "[NPC] Attacking!");
|
|
72
|
+
raw("execute as @e[type=minecraft:zombie,tag=npc_ai] at @s run effect give @p[limit=1,distance=..3] minecraft:slowness 1 1 true");
|
|
73
|
+
if (npc.ticks >= 30) {
|
|
74
|
+
npc.phase = Phase.Idle;
|
|
75
|
+
npc.ticks = 0;
|
|
76
|
+
title(@a, "NPC backs off");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Main tick ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
@tick
|
|
83
|
+
fn npc_tick() {
|
|
84
|
+
if (!npc.active) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
npc.ticks = npc.ticks + 1;
|
|
89
|
+
|
|
90
|
+
match (npc.phase) {
|
|
91
|
+
Phase.Idle => { phase_idle(); }
|
|
92
|
+
Phase.Moving => { phase_moving(); }
|
|
93
|
+
Phase.Attacking => { phase_attacking(); }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// scheduler-demo.mcrs — Delayed event triggering with @schedule
|
|
2
|
+
//
|
|
3
|
+
// @schedule(ticks=N) generates a _schedule_xxx wrapper that emits
|
|
4
|
+
// `schedule function ns:xxx Nt`, deferring execution by N ticks.
|
|
5
|
+
//
|
|
6
|
+
// 20 ticks = 1 second in Minecraft.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// /function demo:begin_countdown trigger the 1-second delayed reward
|
|
10
|
+
// /function demo:announce_morning schedule a sunrise announcement
|
|
11
|
+
|
|
12
|
+
// ── 1-second delayed reward ───────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
fn begin_countdown() {
|
|
15
|
+
title(@a, "Get ready...");
|
|
16
|
+
raw("function demo:_schedule_reward_players");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Called automatically 1 second (20t) after _schedule_reward_players fires
|
|
20
|
+
@schedule(ticks=20)
|
|
21
|
+
fn reward_players(): void {
|
|
22
|
+
title(@a, "Go!");
|
|
23
|
+
raw("effect give @a minecraft:speed 5 1 true");
|
|
24
|
+
raw("effect give @a minecraft:jump_boost 5 1 true");
|
|
25
|
+
tell(@a, "Speed and Jump Boost applied for 5 seconds.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── 5-second delayed announcement ────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
fn announce_morning() {
|
|
31
|
+
tell(@a, "Sunrise in 5 seconds...");
|
|
32
|
+
raw("function demo:_schedule_sunrise_event");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@schedule(ticks=100)
|
|
36
|
+
fn sunrise_event(): void {
|
|
37
|
+
raw("time set day");
|
|
38
|
+
raw("weather clear");
|
|
39
|
+
title(@a, "Good morning!");
|
|
40
|
+
subtitle(@a, "A new day begins");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Chain: schedule a follow-up from inside a scheduled function ──────────
|
|
44
|
+
|
|
45
|
+
fn start_chain() {
|
|
46
|
+
tell(@a, "Chain started — phase 1 runs in 1s, phase 2 in 3s total.");
|
|
47
|
+
raw("function demo:_schedule_chain_phase1");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@schedule(ticks=20)
|
|
51
|
+
fn chain_phase1(): void {
|
|
52
|
+
actionbar(@a, "Phase 1 complete");
|
|
53
|
+
raw("function demo:_schedule_chain_phase2");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@schedule(ticks=40)
|
|
57
|
+
fn chain_phase2(): void {
|
|
58
|
+
actionbar(@a, "Phase 2 complete — chain done!");
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { Parser } from '../parser'
|
|
|
10
10
|
import { TypeChecker } from '../typechecker'
|
|
11
11
|
import { DiagnosticError } from '../diagnostics'
|
|
12
12
|
import type { Program, FnDecl, TypeNode } from '../ast/types'
|
|
13
|
+
import { BUILTIN_METADATA } from '../builtins/metadata'
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Helpers mirrored from lsp/server.ts (tested independently)
|
|
@@ -255,6 +256,94 @@ fn anotherFn(x: int): int { return x; }
|
|
|
255
256
|
})
|
|
256
257
|
})
|
|
257
258
|
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Hover — builtin functions
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
const DECORATOR_DOCS: Record<string, string> = {
|
|
264
|
+
tick: 'Runs every MC game tick (~20 Hz). No arguments.',
|
|
265
|
+
load: 'Runs on `/reload`. Use for initialization logic.',
|
|
266
|
+
coroutine: 'Splits loops into tick-spread continuations. Arg: `batch=N` (steps per tick, default 1).',
|
|
267
|
+
schedule: 'Schedules the function to run after N ticks. Arg: `ticks=N`.',
|
|
268
|
+
on_trigger: 'Runs when a trigger scoreboard objective is set by a player. Arg: trigger name.',
|
|
269
|
+
keep: 'Prevents the compiler from dead-code-eliminating this function.',
|
|
270
|
+
on: 'Generic event handler decorator.',
|
|
271
|
+
on_advancement: 'Runs when a player earns an advancement. Arg: advancement id.',
|
|
272
|
+
on_craft: 'Runs when a player crafts an item. Arg: item id.',
|
|
273
|
+
on_death: 'Runs when a player dies.',
|
|
274
|
+
on_join_team: 'Runs when a player joins a team. Arg: team name.',
|
|
275
|
+
on_login: 'Runs when a player logs in.',
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
describe('LSP hover — builtin functions', () => {
|
|
279
|
+
it('has metadata for say', () => {
|
|
280
|
+
const b = BUILTIN_METADATA['say']
|
|
281
|
+
expect(b).toBeDefined()
|
|
282
|
+
expect(b.params.length).toBeGreaterThan(0)
|
|
283
|
+
expect(b.returns).toBe('void')
|
|
284
|
+
expect(b.doc).toBeTruthy()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('has metadata for kill', () => {
|
|
288
|
+
const b = BUILTIN_METADATA['kill']
|
|
289
|
+
expect(b).toBeDefined()
|
|
290
|
+
expect(b.returns).toBe('void')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('has metadata for tellraw', () => {
|
|
294
|
+
const b = BUILTIN_METADATA['tellraw']
|
|
295
|
+
expect(b).toBeDefined()
|
|
296
|
+
const paramStr = b.params.map(p => `${p.name}: ${p.type}${p.required ? '' : '?'}`).join(', ')
|
|
297
|
+
const sig = `fn ${b.name}(${paramStr}): ${b.returns}`
|
|
298
|
+
expect(sig).toMatch(/^fn tellraw/)
|
|
299
|
+
expect(sig).toContain('target')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('formats builtin hover markdown', () => {
|
|
303
|
+
const b = BUILTIN_METADATA['particle']
|
|
304
|
+
expect(b).toBeDefined()
|
|
305
|
+
const paramStr = b.params.map(p => `${p.name}: ${p.type}${p.required ? '' : '?'}`).join(', ')
|
|
306
|
+
const sig = `fn ${b.name}(${paramStr}): ${b.returns}`
|
|
307
|
+
const markdown = `\`\`\`redscript\n${sig}\n\`\`\`\n${b.doc}`
|
|
308
|
+
expect(markdown).toContain('```redscript')
|
|
309
|
+
expect(markdown).toContain('fn particle')
|
|
310
|
+
expect(markdown).toContain(b.doc)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Hover — decorators
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe('LSP hover — decorators', () => {
|
|
319
|
+
it('has docs for @tick', () => {
|
|
320
|
+
expect(DECORATOR_DOCS['tick']).toContain('tick')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('has docs for @load', () => {
|
|
324
|
+
expect(DECORATOR_DOCS['load']).toContain('reload')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('has docs for @coroutine', () => {
|
|
328
|
+
expect(DECORATOR_DOCS['coroutine']).toContain('batch')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('has docs for @schedule', () => {
|
|
332
|
+
expect(DECORATOR_DOCS['schedule']).toContain('ticks')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('has docs for @on_trigger', () => {
|
|
336
|
+
expect(DECORATOR_DOCS['on_trigger']).toContain('trigger')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('formats decorator hover markdown', () => {
|
|
340
|
+
const name = 'tick'
|
|
341
|
+
const doc = DECORATOR_DOCS[name]
|
|
342
|
+
const markdown = `**@${name}** — ${doc}`
|
|
343
|
+
expect(markdown).toBe(`**@tick** — ${DECORATOR_DOCS['tick']}`)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
258
347
|
// ---------------------------------------------------------------------------
|
|
259
348
|
// Server module import (smoke test — does not start stdio)
|
|
260
349
|
// ---------------------------------------------------------------------------
|
|
@@ -5,7 +5,7 @@ import { compile } from '../compile'
|
|
|
5
5
|
import { MCCommandValidator } from '../mc-validator'
|
|
6
6
|
|
|
7
7
|
const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'mc-commands-1.21.4.json')
|
|
8
|
-
const EXAMPLES = ['
|
|
8
|
+
const EXAMPLES = ['shop', 'quiz', 'turret']
|
|
9
9
|
|
|
10
10
|
function getCommands(source: string, namespace = 'test'): string[] {
|
|
11
11
|
const result = compile(source, { namespace })
|
|
@@ -32,12 +32,6 @@ function validateSource(
|
|
|
32
32
|
describe('MC Command Syntax Validation', () => {
|
|
33
33
|
const validator = new MCCommandValidator(FIXTURE_PATH)
|
|
34
34
|
|
|
35
|
-
test('counter example generates valid MC commands', () => {
|
|
36
|
-
const src = fs.readFileSync(path.join(__dirname, '..', 'examples', 'counter.mcrs'), 'utf-8')
|
|
37
|
-
const errors = validateSource(validator, src, 'counter')
|
|
38
|
-
expect(errors).toHaveLength(0)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
35
|
EXAMPLES.forEach(name => {
|
|
42
36
|
test(`${name}.mcrs generates valid MC commands`, () => {
|
|
43
37
|
const src = fs.readFileSync(path.join(__dirname, '..', 'examples', `${name}.mcrs`), 'utf-8')
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as path from 'path'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
import * as os from 'os'
|
|
4
|
+
import { compile } from '../emit/compile'
|
|
5
|
+
|
|
6
|
+
describe('stdlib include path', () => {
|
|
7
|
+
it('import "stdlib/math" resolves to the stdlib math module', () => {
|
|
8
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
|
|
9
|
+
const mainPath = path.join(tempDir, 'main.mcrs')
|
|
10
|
+
fs.writeFileSync(mainPath, 'import "stdlib/math";\nfn main() { let x: int = abs(-5); }\n')
|
|
11
|
+
const source = fs.readFileSync(mainPath, 'utf-8')
|
|
12
|
+
|
|
13
|
+
const result = compile(source, { namespace: 'test', filePath: mainPath })
|
|
14
|
+
expect(result.files.some(f => f.path.includes('abs'))).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('import "stdlib/math.mcrs" also resolves (explicit extension)', () => {
|
|
18
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
|
|
19
|
+
const mainPath = path.join(tempDir, 'main.mcrs')
|
|
20
|
+
fs.writeFileSync(mainPath, 'import "stdlib/math.mcrs";\nfn main() { let x: int = abs(-5); }\n')
|
|
21
|
+
const source = fs.readFileSync(mainPath, 'utf-8')
|
|
22
|
+
|
|
23
|
+
const result = compile(source, { namespace: 'test', filePath: mainPath })
|
|
24
|
+
expect(result.files.some(f => f.path.includes('abs'))).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('import "stdlib/vec" resolves to the stdlib vec module', () => {
|
|
28
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
|
|
29
|
+
const mainPath = path.join(tempDir, 'main.mcrs')
|
|
30
|
+
fs.writeFileSync(mainPath, 'import "stdlib/vec";\nfn main() { let d: int = dot2d(1, 2, 3, 4); }\n')
|
|
31
|
+
const source = fs.readFileSync(mainPath, 'utf-8')
|
|
32
|
+
|
|
33
|
+
const result = compile(source, { namespace: 'test', filePath: mainPath })
|
|
34
|
+
expect(result.files.some(f => f.path.includes('dot2d'))).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('non-existent stdlib module gives a clear error', () => {
|
|
38
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
|
|
39
|
+
const mainPath = path.join(tempDir, 'main.mcrs')
|
|
40
|
+
fs.writeFileSync(mainPath, 'import "stdlib/nonexistent";\nfn main() {}\n')
|
|
41
|
+
const source = fs.readFileSync(mainPath, 'utf-8')
|
|
42
|
+
|
|
43
|
+
expect(() => compile(source, { namespace: 'test', filePath: mainPath }))
|
|
44
|
+
.toThrow(/Cannot import/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('--include flag allows importing from custom directory', () => {
|
|
48
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-include-'))
|
|
49
|
+
const libDir = path.join(tempDir, 'mylibs')
|
|
50
|
+
fs.mkdirSync(libDir)
|
|
51
|
+
const mainPath = path.join(tempDir, 'main.mcrs')
|
|
52
|
+
const libPath = path.join(libDir, 'helpers.mcrs')
|
|
53
|
+
|
|
54
|
+
fs.writeFileSync(libPath, 'fn triple(x: int) -> int { return x + x + x; }\n')
|
|
55
|
+
fs.writeFileSync(mainPath, 'import "helpers";\nfn main() { let x: int = triple(3); }\n')
|
|
56
|
+
const source = fs.readFileSync(mainPath, 'utf-8')
|
|
57
|
+
|
|
58
|
+
const result = compile(source, { namespace: 'test', filePath: mainPath, includeDirs: [libDir] })
|
|
59
|
+
expect(result.files.some(f => f.path.includes('triple'))).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
})
|
package/src/cli.ts
CHANGED
|
@@ -57,6 +57,7 @@ Options:
|
|
|
57
57
|
--mc-version <ver> Target Minecraft version (default: 1.21). Affects codegen features.
|
|
58
58
|
e.g. --mc-version 1.20.2, --mc-version 1.19
|
|
59
59
|
--lenient Treat type errors as warnings instead of blocking compilation
|
|
60
|
+
--include <dir> Add a directory to the import search path (repeatable)
|
|
60
61
|
-h, --help Show this help message
|
|
61
62
|
`)
|
|
62
63
|
}
|
|
@@ -161,6 +162,7 @@ function parseArgs(args: string[]): {
|
|
|
161
162
|
sourceMap?: boolean
|
|
162
163
|
mcVersionStr?: string
|
|
163
164
|
lenient?: boolean
|
|
165
|
+
includeDirs?: string[]
|
|
164
166
|
} {
|
|
165
167
|
const result: ReturnType<typeof parseArgs> = {}
|
|
166
168
|
let i = 0
|
|
@@ -189,6 +191,10 @@ function parseArgs(args: string[]): {
|
|
|
189
191
|
} else if (arg === '--lenient') {
|
|
190
192
|
result.lenient = true
|
|
191
193
|
i++
|
|
194
|
+
} else if (arg === '--include') {
|
|
195
|
+
if (!result.includeDirs) result.includeDirs = []
|
|
196
|
+
result.includeDirs.push(args[++i])
|
|
197
|
+
i++
|
|
192
198
|
} else if (!result.command) {
|
|
193
199
|
result.command = arg
|
|
194
200
|
i++
|
|
@@ -216,6 +222,7 @@ function compileCommand(
|
|
|
216
222
|
sourceMap = false,
|
|
217
223
|
mcVersionStr?: string,
|
|
218
224
|
lenient = false,
|
|
225
|
+
includeDirs?: string[],
|
|
219
226
|
): void {
|
|
220
227
|
// Read source file
|
|
221
228
|
if (!fs.existsSync(file)) {
|
|
@@ -236,7 +243,7 @@ function compileCommand(
|
|
|
236
243
|
const source = fs.readFileSync(file, 'utf-8')
|
|
237
244
|
|
|
238
245
|
try {
|
|
239
|
-
const result = compile(source, { namespace, filePath: file, generateSourceMap: sourceMap, mcVersion, lenient })
|
|
246
|
+
const result = compile(source, { namespace, filePath: file, generateSourceMap: sourceMap, mcVersion, lenient, includeDirs })
|
|
240
247
|
|
|
241
248
|
for (const w of result.warnings) {
|
|
242
249
|
console.error(`Warning: ${w}`)
|
|
@@ -435,6 +442,7 @@ async function main(): Promise<void> {
|
|
|
435
442
|
parsed.sourceMap,
|
|
436
443
|
parsed.mcVersionStr,
|
|
437
444
|
parsed.lenient,
|
|
445
|
+
parsed.includeDirs,
|
|
438
446
|
)
|
|
439
447
|
}
|
|
440
448
|
break
|
package/src/compile.ts
CHANGED
|
@@ -69,6 +69,37 @@ function isLibrarySource(source: string): boolean {
|
|
|
69
69
|
interface PreprocessOptions {
|
|
70
70
|
filePath?: string
|
|
71
71
|
seen?: Set<string>
|
|
72
|
+
includeDirs?: string[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve an import specifier to an absolute file path, trying multiple locations. */
|
|
76
|
+
function resolveImportPath(
|
|
77
|
+
spec: string,
|
|
78
|
+
fromFile: string,
|
|
79
|
+
includeDirs: string[]
|
|
80
|
+
): string | null {
|
|
81
|
+
const candidates = spec.endsWith('.mcrs') ? [spec] : [spec, spec + '.mcrs']
|
|
82
|
+
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
// 1. Relative to the importing file
|
|
85
|
+
const rel = path.resolve(path.dirname(fromFile), candidate)
|
|
86
|
+
if (fs.existsSync(rel)) return rel
|
|
87
|
+
|
|
88
|
+
// 2. stdlib directory (package root / src / stdlib)
|
|
89
|
+
// Strip leading 'stdlib/' prefix so `import "stdlib/math"` resolves to
|
|
90
|
+
// <stdlibDir>/math.mcrs rather than <stdlibDir>/stdlib/math.mcrs.
|
|
91
|
+
const stdlibDir = path.resolve(__dirname, '..', 'src', 'stdlib')
|
|
92
|
+
const stdlibCandidate = candidate.replace(/^stdlib\//, '')
|
|
93
|
+
const stdlib = path.resolve(stdlibDir, stdlibCandidate)
|
|
94
|
+
if (fs.existsSync(stdlib)) return stdlib
|
|
95
|
+
|
|
96
|
+
// 3. Extra include dirs
|
|
97
|
+
for (const dir of includeDirs) {
|
|
98
|
+
const extra = path.resolve(dir, candidate)
|
|
99
|
+
if (fs.existsSync(extra)) return extra
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
72
103
|
}
|
|
73
104
|
|
|
74
105
|
function countLines(source: string): number {
|
|
@@ -86,6 +117,7 @@ function offsetRanges(ranges: SourceRange[], lineOffset: number): SourceRange[]
|
|
|
86
117
|
export function preprocessSourceWithMetadata(source: string, options: PreprocessOptions = {}): PreprocessedSource {
|
|
87
118
|
const { filePath } = options
|
|
88
119
|
const seen = options.seen ?? new Set<string>()
|
|
120
|
+
const includeDirs = options.includeDirs ?? []
|
|
89
121
|
|
|
90
122
|
if (filePath) {
|
|
91
123
|
seen.add(path.resolve(filePath))
|
|
@@ -113,31 +145,28 @@ export function preprocessSourceWithMetadata(source: string, options: Preprocess
|
|
|
113
145
|
)
|
|
114
146
|
}
|
|
115
147
|
|
|
116
|
-
const importPath =
|
|
148
|
+
const importPath = resolveImportPath(match[1], filePath, includeDirs)
|
|
149
|
+
if (!importPath) {
|
|
150
|
+
throw new DiagnosticError(
|
|
151
|
+
'ParseError',
|
|
152
|
+
`Cannot import '${match[1]}'`,
|
|
153
|
+
{ file: filePath, line: i + 1, col: 1 },
|
|
154
|
+
lines
|
|
155
|
+
)
|
|
156
|
+
}
|
|
117
157
|
if (!seen.has(importPath)) {
|
|
118
158
|
seen.add(importPath)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
importedSource = fs.readFileSync(importPath, 'utf-8')
|
|
123
|
-
} catch {
|
|
124
|
-
throw new DiagnosticError(
|
|
125
|
-
'ParseError',
|
|
126
|
-
`Cannot import '${match[1]}'`,
|
|
127
|
-
{ file: filePath, line: i + 1, col: 1 },
|
|
128
|
-
lines
|
|
129
|
-
)
|
|
130
|
-
}
|
|
159
|
+
const importedSource = fs.readFileSync(importPath, 'utf-8')
|
|
131
160
|
|
|
132
161
|
if (isLibrarySource(importedSource)) {
|
|
133
162
|
// Library file: parse separately so its functions are DCE-eligible.
|
|
134
163
|
// Also collect any transitive library imports inside it.
|
|
135
|
-
const nested = preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen })
|
|
164
|
+
const nested = preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen, includeDirs })
|
|
136
165
|
libraryImports.push({ source: importedSource, filePath: importPath })
|
|
137
166
|
// Propagate transitive library imports (e.g. math.mcrs imports vec.mcrs)
|
|
138
167
|
if (nested.libraryImports) libraryImports.push(...nested.libraryImports)
|
|
139
168
|
} else {
|
|
140
|
-
imports.push(preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen }))
|
|
169
|
+
imports.push(preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen, includeDirs }))
|
|
141
170
|
}
|
|
142
171
|
}
|
|
143
172
|
continue
|
package/src/emit/compile.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface CompileOptions {
|
|
|
34
34
|
* Use for gradual migration or testing with existing codebases that have type errors.
|
|
35
35
|
*/
|
|
36
36
|
lenient?: boolean
|
|
37
|
+
/** Extra directories to search when resolving imports (in addition to relative and stdlib). */
|
|
38
|
+
includeDirs?: string[]
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface CompileResult {
|
|
@@ -44,11 +46,11 @@ export interface CompileResult {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export function compile(source: string, options: CompileOptions = {}): CompileResult {
|
|
47
|
-
const { namespace = 'redscript', filePath, generateSourceMap = false, mcVersion = DEFAULT_MC_VERSION, lenient = false } = options
|
|
49
|
+
const { namespace = 'redscript', filePath, generateSourceMap = false, mcVersion = DEFAULT_MC_VERSION, lenient = false, includeDirs } = options
|
|
48
50
|
const warnings: string[] = []
|
|
49
51
|
|
|
50
52
|
// Preprocess: resolve import directives, merge imported sources
|
|
51
|
-
const preprocessed = preprocessSourceWithMetadata(source, { filePath })
|
|
53
|
+
const preprocessed = preprocessSourceWithMetadata(source, { filePath, includeDirs })
|
|
52
54
|
const processedSource = preprocessed.source
|
|
53
55
|
|
|
54
56
|
// Stage 1: Lex + Parse → AST
|
package/src/lsp/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { Parser } from '../parser'
|
|
|
34
34
|
import { TypeChecker } from '../typechecker'
|
|
35
35
|
import { DiagnosticError } from '../diagnostics'
|
|
36
36
|
import type { Program, FnDecl, Span, TypeNode } from '../ast/types'
|
|
37
|
+
import { BUILTIN_METADATA } from '../builtins/metadata'
|
|
37
38
|
|
|
38
39
|
// ---------------------------------------------------------------------------
|
|
39
40
|
// Connection and document manager
|
|
@@ -123,6 +124,25 @@ function toDiagnostic(err: DiagnosticError): Diagnostic {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Decorator hover docs
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
const DECORATOR_DOCS: Record<string, string> = {
|
|
132
|
+
tick: 'Runs every MC game tick (~20 Hz). No arguments.',
|
|
133
|
+
load: 'Runs on `/reload`. Use for initialization logic.',
|
|
134
|
+
coroutine: 'Splits loops into tick-spread continuations. Arg: `batch=N` (steps per tick, default 1).',
|
|
135
|
+
schedule: 'Schedules the function to run after N ticks. Arg: `ticks=N`.',
|
|
136
|
+
on_trigger: 'Runs when a trigger scoreboard objective is set by a player. Arg: trigger name.',
|
|
137
|
+
keep: 'Prevents the compiler from dead-code-eliminating this function.',
|
|
138
|
+
on: 'Generic event handler decorator.',
|
|
139
|
+
on_advancement: 'Runs when a player earns an advancement. Arg: advancement id.',
|
|
140
|
+
on_craft: 'Runs when a player crafts an item. Arg: item id.',
|
|
141
|
+
on_death: 'Runs when a player dies.',
|
|
142
|
+
on_join_team: 'Runs when a player joins a team. Arg: team name.',
|
|
143
|
+
on_login: 'Runs when a player logs in.',
|
|
144
|
+
}
|
|
145
|
+
|
|
126
146
|
// ---------------------------------------------------------------------------
|
|
127
147
|
// Hover helpers
|
|
128
148
|
// ---------------------------------------------------------------------------
|
|
@@ -290,9 +310,44 @@ connection.onHover((params: TextDocumentPositionParams): Hover | null => {
|
|
|
290
310
|
const program = cached?.program ?? null
|
|
291
311
|
if (!program) return null
|
|
292
312
|
|
|
313
|
+
// Check if cursor is on a decorator (@tick, @load, etc.)
|
|
314
|
+
const lines = source.split('\n')
|
|
315
|
+
const lineText = lines[params.position.line] ?? ''
|
|
316
|
+
const decoratorMatch = lineText.match(/@([a-zA-Z_][a-zA-Z0-9_]*)/)
|
|
317
|
+
if (decoratorMatch) {
|
|
318
|
+
const ch = params.position.character
|
|
319
|
+
const atIdx = lineText.indexOf('@')
|
|
320
|
+
const decoratorEnd = atIdx + 1 + decoratorMatch[1].length
|
|
321
|
+
if (ch >= atIdx && ch <= decoratorEnd) {
|
|
322
|
+
const decoratorName = decoratorMatch[1]
|
|
323
|
+
const decoratorDoc = DECORATOR_DOCS[decoratorName]
|
|
324
|
+
if (decoratorDoc) {
|
|
325
|
+
const content: MarkupContent = {
|
|
326
|
+
kind: MarkupKind.Markdown,
|
|
327
|
+
value: `**@${decoratorName}** — ${decoratorDoc}`,
|
|
328
|
+
}
|
|
329
|
+
return { contents: content }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
293
334
|
const word = wordAt(source, params.position)
|
|
294
335
|
if (!word) return null
|
|
295
336
|
|
|
337
|
+
// Check builtins
|
|
338
|
+
const builtin = BUILTIN_METADATA[word]
|
|
339
|
+
if (builtin) {
|
|
340
|
+
const paramStr = builtin.params
|
|
341
|
+
.map(p => `${p.name}: ${p.type}${p.required ? '' : '?'}`)
|
|
342
|
+
.join(', ')
|
|
343
|
+
const sig = `fn ${builtin.name}(${paramStr}): ${builtin.returns}`
|
|
344
|
+
const content: MarkupContent = {
|
|
345
|
+
kind: MarkupKind.Markdown,
|
|
346
|
+
value: `\`\`\`redscript\n${sig}\n\`\`\`\n${builtin.doc}`,
|
|
347
|
+
}
|
|
348
|
+
return { contents: content }
|
|
349
|
+
}
|
|
350
|
+
|
|
296
351
|
// Check if it's a known function
|
|
297
352
|
const fn = findFunction(program, word)
|
|
298
353
|
if (fn) {
|
package/examples/spiral.mcrs
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// ===== Simple Particle Demo =====
|
|
2
|
-
// 展示: @tick, 状态管理, f-strings, 控制命令
|
|
3
|
-
|
|
4
|
-
// 状态
|
|
5
|
-
let counter: int = 0;
|
|
6
|
-
let running: bool = false;
|
|
7
|
-
|
|
8
|
-
// ===== 主循环 =====
|
|
9
|
-
@tick fn demo_tick() {
|
|
10
|
-
if (!running) { return; }
|
|
11
|
-
|
|
12
|
-
// 每 tick 增加计数器
|
|
13
|
-
counter = counter + 1;
|
|
14
|
-
|
|
15
|
-
// 在每个玩家位置生成粒子
|
|
16
|
-
foreach (p in @a) at @s {
|
|
17
|
-
particle("minecraft:end_rod", ~0, ~1, ~0, 0.5, 0.5, 0.5, 0.1, 5);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// 每 20 ticks (1秒) 报告一次
|
|
21
|
-
if (counter % 20 == 0) {
|
|
22
|
-
say(f"Running for {counter} ticks");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ===== 控制命令 =====
|
|
27
|
-
// @keep 防止 DCE 删除
|
|
28
|
-
@keep fn start() {
|
|
29
|
-
running = true;
|
|
30
|
-
counter = 0;
|
|
31
|
-
say(f"Demo started!");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
@keep fn stop() {
|
|
35
|
-
running = false;
|
|
36
|
-
say(f"Demo stopped at {counter} ticks.");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
@keep fn reset() {
|
|
40
|
-
running = false;
|
|
41
|
-
counter = 0;
|
|
42
|
-
say(f"Demo reset.");
|
|
43
|
-
}
|
package/src/examples/arena.mcrs
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// PvP arena scoreboard tracker.
|
|
2
|
-
// Reads the vanilla kills objective, announces the top score every 200 ticks,
|
|
3
|
-
// and tells the current leader(s) directly.
|
|
4
|
-
|
|
5
|
-
@tick
|
|
6
|
-
fn arena_tick() {
|
|
7
|
-
let ticks: int = scoreboard_get("arena", #ticks);
|
|
8
|
-
ticks = ticks + 1;
|
|
9
|
-
scoreboard_set("arena", #ticks, ticks);
|
|
10
|
-
|
|
11
|
-
if (ticks % 200 == 0) {
|
|
12
|
-
announce_leaders();
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
fn announce_leaders() {
|
|
17
|
-
let top_kills: int = 0;
|
|
18
|
-
|
|
19
|
-
foreach (player in @a) {
|
|
20
|
-
let kills: int = scoreboard_get(player, #kills);
|
|
21
|
-
if (kills > top_kills) {
|
|
22
|
-
top_kills = kills;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (top_kills > 0) {
|
|
27
|
-
announce("Arena update: leader check complete.");
|
|
28
|
-
title_times(@a, 10, 40, 10);
|
|
29
|
-
actionbar(@a, "Top kills updated");
|
|
30
|
-
|
|
31
|
-
foreach (player in @a) {
|
|
32
|
-
let kills: int = scoreboard_get(player, #kills);
|
|
33
|
-
if (kills == top_kills) {
|
|
34
|
-
tell(player, "You are leading the arena right now.");
|
|
35
|
-
title(player, "Arena Leader");
|
|
36
|
-
subtitle(player, "Hold the top score");
|
|
37
|
-
actionbar(player, "Stay alive to keep the lead");
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
} else {
|
|
41
|
-
announce("Arena update: no PvP kills yet.");
|
|
42
|
-
actionbar(@a, "No arena leader yet");
|
|
43
|
-
}
|
|
44
|
-
}
|