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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redscript-mc",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "A high-level programming language that compiles to Minecraft datapacks",
5
5
  "main": "dist/src/index.js",
6
6
  "bin": {
@@ -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 = ['counter', 'arena', 'shop', 'quiz', 'turret']
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 = path.resolve(path.dirname(filePath), match[1])
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
- let importedSource: string
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
@@ -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) {
@@ -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
- }
@@ -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
- }