redscript-mc 1.2.10 → 1.2.12

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.
@@ -753,32 +753,33 @@ describe('MC Integration - New Features', () => {
753
753
  expect(count).toBeLessThanOrEqual(3)
754
754
  })
755
755
 
756
- test('is-check-test.mcrs: foreach is-narrowing only matches zombie entities', async () => {
756
+ test('is-check-test.mcrs: foreach is-narrowing correctly matches entity types', async () => {
757
757
  if (!serverOnline) return
758
758
 
759
759
  await mc.fullReset({ clearArea: false, killEntities: true, resetScoreboards: false })
760
- await mc.command('/scoreboard players set #is_check players 0')
761
- await mc.command('/scoreboard players set #is_check zombies 0')
760
+ await mc.command('/forceload add 0 0').catch(() => {}) // Ensure chunk is loaded
761
+ await mc.command('/scoreboard objectives add armor_stands dummy').catch(() => {})
762
+ await mc.command('/scoreboard objectives add items dummy').catch(() => {})
763
+ await mc.command('/scoreboard players set #is_check armor_stands 0')
764
+ await mc.command('/scoreboard players set #is_check items 0')
762
765
  await mc.command('/function is_check_test:__load').catch(() => {})
763
- await mc.command('/summon minecraft:zombie 0 65 0')
764
- await mc.command('/tag @e[type=minecraft:zombie,sort=nearest,limit=1] add is_check_target')
765
- await mc.command('/summon minecraft:armor_stand 2 65 0')
766
- await mc.command('/tag @e[type=minecraft:armor_stand,sort=nearest,limit=1] add is_check_target')
766
+
767
+ // Spawn 2 armor_stands and 1 item (all persist without players)
768
+ await mc.command('/summon minecraft:armor_stand 0 65 0 {Tags:["is_check_target"],NoGravity:1b}')
769
+ await mc.command('/summon minecraft:armor_stand 2 65 0 {Tags:["is_check_target"],NoGravity:1b}')
770
+ await mc.command('/summon minecraft:item 4 65 0 {Tags:["is_check_target"],Item:{id:"minecraft:stone",count:1},Age:-32768}')
771
+ await mc.ticks(5)
767
772
 
768
773
  await mc.command('/function is_check_test:check_types')
769
774
  await mc.ticks(5)
770
775
 
771
- const zombies = await mc.scoreboard('#is_check', 'zombies')
772
- const players = await mc.scoreboard('#is_check', 'players')
773
- const zombieEntities = await mc.entities('@e[type=minecraft:zombie,tag=is_check_target]')
774
- const standEntities = await mc.entities('@e[type=minecraft:armor_stand,tag=is_check_target]')
776
+ const armorStands = await mc.scoreboard('#is_check', 'armor_stands')
777
+ const items = await mc.scoreboard('#is_check', 'items')
775
778
 
776
- expect(zombies).toBe(1)
777
- expect(players).toBe(0)
778
- expect(zombieEntities).toHaveLength(0)
779
- expect(standEntities).toHaveLength(1)
779
+ expect(armorStands).toBe(2) // 2 armor_stands matched
780
+ expect(items).toBe(1) // 1 item matched
780
781
 
781
- await mc.command('/kill @e[tag=is_check_target]').catch(() => {})
782
+ await mc.command('/function is_check_test:cleanup').catch(() => {})
782
783
  })
783
784
 
784
785
  test('event-test.mcrs: @on(PlayerDeath) compiles and loads', async () => {
@@ -797,3 +798,100 @@ describe('MC Integration - New Features', () => {
797
798
  expect(tickResult.ok).toBe(true)
798
799
  })
799
800
  })
801
+
802
+ describe('MC Integration - Extended Coverage', () => {
803
+ test('struct-test.mcrs: struct instantiation and field access', async () => {
804
+ if (!serverOnline) return
805
+
806
+ writeFixtureFile('struct-test.mcrs', 'struct_test')
807
+ await mc.reload()
808
+ await mc.command('/function struct_test:__load').catch(() => {})
809
+ await mc.command('/function struct_test:test_struct')
810
+ await mc.ticks(5)
811
+
812
+ expect(await mc.scoreboard('#struct_x', 'rs')).toBe(10)
813
+ expect(await mc.scoreboard('#struct_y', 'rs')).toBe(64)
814
+ expect(await mc.scoreboard('#struct_z', 'rs')).toBe(-5)
815
+ expect(await mc.scoreboard('#struct_x2', 'rs')).toBe(15) // 10+5
816
+ expect(await mc.scoreboard('#struct_z2', 'rs')).toBe(-10) // -5*2
817
+ expect(await mc.scoreboard('#struct_alive', 'rs')).toBe(1)
818
+ expect(await mc.scoreboard('#struct_score', 'rs')).toBe(100)
819
+ })
820
+
821
+ test('enum-test.mcrs: enum values and match', async () => {
822
+ if (!serverOnline) return
823
+
824
+ writeFixtureFile('enum-test.mcrs', 'enum_test')
825
+ await mc.reload()
826
+ await mc.command('/function enum_test:__load').catch(() => {})
827
+ await mc.command('/function enum_test:test_enum')
828
+ await mc.ticks(5)
829
+
830
+ expect(await mc.scoreboard('#enum_phase', 'rs')).toBe(2) // Playing=2
831
+ expect(await mc.scoreboard('#enum_match', 'rs')).toBe(2) // matched Playing
832
+ expect(await mc.scoreboard('#enum_rank', 'rs')).toBe(10) // Diamond=10
833
+ expect(await mc.scoreboard('#enum_high', 'rs')).toBe(1) // Diamond > Gold
834
+ })
835
+
836
+ test('array-test.mcrs: array operations', async () => {
837
+ if (!serverOnline) return
838
+
839
+ writeFixtureFile('array-test.mcrs', 'array_test')
840
+ await mc.reload()
841
+ await mc.command('/function array_test:__load').catch(() => {})
842
+ await mc.command('/function array_test:test_array')
843
+ await mc.ticks(5)
844
+
845
+ expect(await mc.scoreboard('#arr_0', 'rs')).toBe(10)
846
+ expect(await mc.scoreboard('#arr_2', 'rs')).toBe(30)
847
+ expect(await mc.scoreboard('#arr_4', 'rs')).toBe(50)
848
+ expect(await mc.scoreboard('#arr_len', 'rs')).toBe(5)
849
+ expect(await mc.scoreboard('#arr_sum', 'rs')).toBe(150) // 10+20+30+40+50
850
+ expect(await mc.scoreboard('#arr_push', 'rs')).toBe(4) // [1,2,3,4].len
851
+ expect(await mc.scoreboard('#arr_pop', 'rs')).toBe(4) // popped value
852
+ })
853
+
854
+ test('break-continue-test.mcrs: break and continue statements', async () => {
855
+ if (!serverOnline) return
856
+
857
+ writeFixtureFile('break-continue-test.mcrs', 'break_continue_test')
858
+ await mc.reload()
859
+ await mc.command('/function break_continue_test:__load').catch(() => {})
860
+ await mc.command('/function break_continue_test:test_break_continue')
861
+ await mc.ticks(10)
862
+
863
+ expect(await mc.scoreboard('#break_at', 'rs')).toBe(5)
864
+ expect(await mc.scoreboard('#sum_evens', 'rs')).toBe(20) // 0+2+4+6+8
865
+ expect(await mc.scoreboard('#while_break', 'rs')).toBe(7)
866
+ expect(await mc.scoreboard('#nested_break', 'rs')).toBe(3) // outer completes 3 times
867
+ })
868
+
869
+ test('match-range-test.mcrs: match with range patterns', async () => {
870
+ if (!serverOnline) return
871
+
872
+ writeFixtureFile('match-range-test.mcrs', 'match_range_test')
873
+ await mc.reload()
874
+ await mc.command('/function match_range_test:__load').catch(() => {})
875
+ await mc.command('/function match_range_test:test_match_range')
876
+ await mc.ticks(5)
877
+
878
+ expect(await mc.scoreboard('#grade', 'rs')).toBe(4) // score=85 → B
879
+ expect(await mc.scoreboard('#boundary_59', 'rs')).toBe(1) // 59 matches 0..59
880
+ expect(await mc.scoreboard('#boundary_60', 'rs')).toBe(2) // 60 matches 60..100
881
+ expect(await mc.scoreboard('#neg_range', 'rs')).toBe(1) // -5 matches ..0
882
+ })
883
+
884
+ test('foreach-at-test.mcrs: foreach with at @s context', async () => {
885
+ if (!serverOnline) return
886
+
887
+ writeFixtureFile('foreach-at-test.mcrs', 'foreach_at_test')
888
+ await mc.reload()
889
+ await mc.fullReset({ clearArea: false, killEntities: true, resetScoreboards: false })
890
+ await mc.command('/function foreach_at_test:setup').catch(() => {})
891
+ await mc.command('/function foreach_at_test:test_foreach_at')
892
+ await mc.ticks(10)
893
+
894
+ expect(await mc.scoreboard('#foreach_count', 'rs')).toBe(3)
895
+ expect(await mc.scoreboard('#foreach_at_count', 'rs')).toBe(3)
896
+ })
897
+ })
package/src/ast/types.ts CHANGED
@@ -179,11 +179,32 @@ export type LiteralExpr =
179
179
  // ---------------------------------------------------------------------------
180
180
 
181
181
  export type ExecuteSubcommand =
182
+ // Context modifiers
182
183
  | { kind: 'as'; selector: EntitySelector }
183
184
  | { kind: 'at'; selector: EntitySelector }
185
+ | { kind: 'positioned'; x: string; y: string; z: string }
186
+ | { kind: 'positioned_as'; selector: EntitySelector }
187
+ | { kind: 'rotated'; yaw: string; pitch: string }
188
+ | { kind: 'rotated_as'; selector: EntitySelector }
189
+ | { kind: 'facing'; x: string; y: string; z: string }
190
+ | { kind: 'facing_entity'; selector: EntitySelector; anchor: 'eyes' | 'feet' }
191
+ | { kind: 'anchored'; anchor: 'eyes' | 'feet' }
192
+ | { kind: 'align'; axes: string }
193
+ | { kind: 'in'; dimension: string }
194
+ | { kind: 'on'; relation: string }
195
+ | { kind: 'summon'; entity: string }
196
+ // Conditions
184
197
  | { kind: 'if_entity'; selector?: EntitySelector; varName?: string; filters?: SelectorFilter }
185
198
  | { kind: 'unless_entity'; selector?: EntitySelector; varName?: string; filters?: SelectorFilter }
186
- | { kind: 'in'; dimension: string }
199
+ | { kind: 'if_block'; pos: [string, string, string]; block: string }
200
+ | { kind: 'unless_block'; pos: [string, string, string]; block: string }
201
+ | { kind: 'if_score'; target: string; targetObj: string; op: string; source: string; sourceObj: string }
202
+ | { kind: 'unless_score'; target: string; targetObj: string; op: string; source: string; sourceObj: string }
203
+ | { kind: 'if_score_range'; target: string; targetObj: string; range: string }
204
+ | { kind: 'unless_score_range'; target: string; targetObj: string; range: string }
205
+ // Store
206
+ | { kind: 'store_result'; target: string; targetObj: string }
207
+ | { kind: 'store_success'; target: string; targetObj: string }
187
208
 
188
209
  export type Stmt =
189
210
  | { kind: 'let'; name: string; type?: TypeNode; init: Expr; span?: Span }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  // eslint-disable-next-line @typescript-eslint/no-var-requires
8
- export const version = '1.2.10'
8
+ export const version = '1.2.11'
9
9
 
10
10
  import { Lexer } from './lexer'
11
11
  import { Parser } from './parser'
@@ -118,20 +118,20 @@ const NAMESPACED_ENTITY_TYPE_RE = /^[a-z0-9_.-]+:[a-z0-9_./-]+$/
118
118
  const BARE_ENTITY_TYPE_RE = /^[a-z0-9_./-]+$/
119
119
 
120
120
  const ENTITY_TO_MC_TYPE: Partial<Record<EntityTypeName, string>> = {
121
- Player: 'player',
122
- Zombie: 'zombie',
123
- Skeleton: 'skeleton',
124
- Creeper: 'creeper',
125
- Spider: 'spider',
126
- Enderman: 'enderman',
127
- Pig: 'pig',
128
- Cow: 'cow',
129
- Sheep: 'sheep',
130
- Chicken: 'chicken',
131
- Villager: 'villager',
132
- ArmorStand: 'armor_stand',
133
- Item: 'item',
134
- Arrow: 'arrow',
121
+ Player: 'minecraft:player',
122
+ Zombie: 'minecraft:zombie',
123
+ Skeleton: 'minecraft:skeleton',
124
+ Creeper: 'minecraft:creeper',
125
+ Spider: 'minecraft:spider',
126
+ Enderman: 'minecraft:enderman',
127
+ Pig: 'minecraft:pig',
128
+ Cow: 'minecraft:cow',
129
+ Sheep: 'minecraft:sheep',
130
+ Chicken: 'minecraft:chicken',
131
+ Villager: 'minecraft:villager',
132
+ ArmorStand: 'minecraft:armor_stand',
133
+ Item: 'minecraft:item',
134
+ Arrow: 'minecraft:arrow',
135
135
  }
136
136
 
137
137
  function normalizeSelector(selector: string, warnings: Warning[]): string {
@@ -213,6 +213,8 @@ export class Lowering {
213
213
 
214
214
  // Struct definitions: name → { fieldName: TypeNode }
215
215
  private structDefs: Map<string, Map<string, TypeNode>> = new Map()
216
+ // Full struct declarations for field iteration
217
+ private structDecls: Map<string, StructDecl> = new Map()
216
218
  private enumDefs: Map<string, Map<string, number>> = new Map()
217
219
  private functionDefaults: Map<string, Array<Expr | undefined>> = new Map()
218
220
  private constValues: Map<string, ConstDecl['value']> = new Map()
@@ -243,6 +245,7 @@ export class Lowering {
243
245
  fields.set(field.name, field.type)
244
246
  }
245
247
  this.structDefs.set(struct.name, fields)
248
+ this.structDecls.set(struct.name, struct)
246
249
  }
247
250
 
248
251
  for (const enumDecl of program.enums ?? []) {
@@ -630,6 +633,23 @@ export class Lowering {
630
633
  return
631
634
  }
632
635
 
636
+ // Handle struct initialization from function call (copy from __ret_struct)
637
+ if ((stmt.init.kind === 'call' || stmt.init.kind === 'static_call') && stmt.type?.kind === 'struct') {
638
+ // First, execute the function call
639
+ this.lowerExpr(stmt.init)
640
+ // Then copy all fields from __ret_struct to the variable's storage
641
+ const structDecl = this.structDecls.get(stmt.type.name)
642
+ if (structDecl) {
643
+ const structName = stmt.type.name.toLowerCase()
644
+ for (const field of structDecl.fields) {
645
+ const srcPath = `rs:heap __ret_struct.${field.name}`
646
+ const dstPath = `rs:heap ${structName}_${stmt.name}.${field.name}`
647
+ this.builder.emitRaw(`data modify storage ${dstPath} set from storage ${srcPath}`)
648
+ }
649
+ }
650
+ return
651
+ }
652
+
633
653
  // Handle array literal initialization
634
654
  if (stmt.init.kind === 'array_lit') {
635
655
  // Initialize empty NBT array
@@ -684,6 +704,20 @@ export class Lowering {
684
704
 
685
705
  private lowerReturnStmt(stmt: Extract<Stmt, { kind: 'return' }>): void {
686
706
  if (stmt.value) {
707
+ // Handle struct literal return: store fields to __ret_struct storage
708
+ if (stmt.value.kind === 'struct_lit') {
709
+ for (const field of stmt.value.fields) {
710
+ const path = `rs:heap __ret_struct.${field.name}`
711
+ const fieldValue = this.lowerExpr(field.value)
712
+ if (fieldValue.kind === 'const') {
713
+ this.builder.emitRaw(`data modify storage ${path} set value ${fieldValue.value}`)
714
+ } else if (fieldValue.kind === 'var') {
715
+ this.builder.emitRaw(`execute store result storage ${path} int 1 run scoreboard players get ${fieldValue.name} rs`)
716
+ }
717
+ }
718
+ this.builder.emitReturn({ kind: 'const', value: 0 })
719
+ return
720
+ }
687
721
  const value = this.lowerExpr(stmt.value)
688
722
  this.builder.emitReturn(value)
689
723
  } else {
@@ -1200,17 +1234,51 @@ export class Lowering {
1200
1234
  const parts: string[] = ['execute']
1201
1235
  for (const sub of stmt.subcommands) {
1202
1236
  switch (sub.kind) {
1237
+ // Context modifiers
1203
1238
  case 'as':
1204
1239
  parts.push(`as ${this.selectorToString(sub.selector)}`)
1205
1240
  break
1206
1241
  case 'at':
1207
1242
  parts.push(`at ${this.selectorToString(sub.selector)}`)
1208
1243
  break
1244
+ case 'positioned':
1245
+ parts.push(`positioned ${sub.x} ${sub.y} ${sub.z}`)
1246
+ break
1247
+ case 'positioned_as':
1248
+ parts.push(`positioned as ${this.selectorToString(sub.selector)}`)
1249
+ break
1250
+ case 'rotated':
1251
+ parts.push(`rotated ${sub.yaw} ${sub.pitch}`)
1252
+ break
1253
+ case 'rotated_as':
1254
+ parts.push(`rotated as ${this.selectorToString(sub.selector)}`)
1255
+ break
1256
+ case 'facing':
1257
+ parts.push(`facing ${sub.x} ${sub.y} ${sub.z}`)
1258
+ break
1259
+ case 'facing_entity':
1260
+ parts.push(`facing entity ${this.selectorToString(sub.selector)} ${sub.anchor}`)
1261
+ break
1262
+ case 'anchored':
1263
+ parts.push(`anchored ${sub.anchor}`)
1264
+ break
1265
+ case 'align':
1266
+ parts.push(`align ${sub.axes}`)
1267
+ break
1268
+ case 'in':
1269
+ parts.push(`in ${sub.dimension}`)
1270
+ break
1271
+ case 'on':
1272
+ parts.push(`on ${sub.relation}`)
1273
+ break
1274
+ case 'summon':
1275
+ parts.push(`summon ${sub.entity}`)
1276
+ break
1277
+ // Conditions
1209
1278
  case 'if_entity':
1210
1279
  if (sub.selector) {
1211
1280
  parts.push(`if entity ${this.selectorToString(sub.selector)}`)
1212
1281
  } else if (sub.varName) {
1213
- // Variable with filters - substitute with @s and apply filters
1214
1282
  const sel: EntitySelector = { kind: '@s', filters: sub.filters }
1215
1283
  parts.push(`if entity ${this.selectorToString(sel)}`)
1216
1284
  }
@@ -1219,13 +1287,34 @@ export class Lowering {
1219
1287
  if (sub.selector) {
1220
1288
  parts.push(`unless entity ${this.selectorToString(sub.selector)}`)
1221
1289
  } else if (sub.varName) {
1222
- // Variable with filters - substitute with @s and apply filters
1223
1290
  const sel: EntitySelector = { kind: '@s', filters: sub.filters }
1224
1291
  parts.push(`unless entity ${this.selectorToString(sel)}`)
1225
1292
  }
1226
1293
  break
1227
- case 'in':
1228
- parts.push(`in ${sub.dimension}`)
1294
+ case 'if_block':
1295
+ parts.push(`if block ${sub.pos[0]} ${sub.pos[1]} ${sub.pos[2]} ${sub.block}`)
1296
+ break
1297
+ case 'unless_block':
1298
+ parts.push(`unless block ${sub.pos[0]} ${sub.pos[1]} ${sub.pos[2]} ${sub.block}`)
1299
+ break
1300
+ case 'if_score':
1301
+ parts.push(`if score ${sub.target} ${sub.targetObj} ${sub.op} ${sub.source} ${sub.sourceObj}`)
1302
+ break
1303
+ case 'unless_score':
1304
+ parts.push(`unless score ${sub.target} ${sub.targetObj} ${sub.op} ${sub.source} ${sub.sourceObj}`)
1305
+ break
1306
+ case 'if_score_range':
1307
+ parts.push(`if score ${sub.target} ${sub.targetObj} matches ${sub.range}`)
1308
+ break
1309
+ case 'unless_score_range':
1310
+ parts.push(`unless score ${sub.target} ${sub.targetObj} matches ${sub.range}`)
1311
+ break
1312
+ // Store
1313
+ case 'store_result':
1314
+ parts.push(`store result score ${sub.target} ${sub.targetObj}`)
1315
+ break
1316
+ case 'store_success':
1317
+ parts.push(`store success score ${sub.target} ${sub.targetObj}`)
1229
1318
  break
1230
1319
  }
1231
1320
  }
@@ -1706,6 +1795,22 @@ export class Lowering {
1706
1795
 
1707
1796
  const implMethod = this.resolveInstanceMethod(expr)
1708
1797
  if (implMethod) {
1798
+ // Copy struct fields from instance to 'self' storage before calling
1799
+ const receiver = expr.args[0]
1800
+ if (receiver?.kind === 'ident') {
1801
+ const receiverType = this.inferExprType(receiver)
1802
+ if (receiverType?.kind === 'struct') {
1803
+ const structDecl = this.structDecls.get(receiverType.name)
1804
+ const structName = receiverType.name.toLowerCase()
1805
+ if (structDecl) {
1806
+ for (const field of structDecl.fields) {
1807
+ const srcPath = `rs:heap ${structName}_${receiver.name}.${field.name}`
1808
+ const dstPath = `rs:heap ${structName}_self.${field.name}`
1809
+ this.builder.emitRaw(`data modify storage ${dstPath} set from storage ${srcPath}`)
1810
+ }
1811
+ }
1812
+ }
1813
+ }
1709
1814
  return this.emitMethodCall(implMethod.loweredName, implMethod.fn, expr.args)
1710
1815
  }
1711
1816
 
@@ -133,10 +133,13 @@ export class DeadCodeEliminator {
133
133
  const entries = new Set<string>()
134
134
 
135
135
  for (const fn of program.declarations) {
136
- if (fn.name === 'main') {
136
+ // All top-level functions are entry points (callable via /function)
137
+ // Exception: functions starting with _ are considered private/internal
138
+ if (!fn.name.startsWith('_')) {
137
139
  entries.add(fn.name)
138
140
  }
139
141
 
142
+ // Decorated functions are always entry points (even if prefixed with _)
140
143
  if (fn.decorators.some(decorator => [
141
144
  'tick',
142
145
  'load',
@@ -147,7 +150,7 @@ export class DeadCodeEliminator {
147
150
  'on_death',
148
151
  'on_login',
149
152
  'on_join_team',
150
- 'keep', // Prevent DCE from removing this function
153
+ 'keep',
151
154
  ].includes(decorator.name))) {
152
155
  entries.add(fn.name)
153
156
  }
@@ -660,11 +660,11 @@ export class Parser {
660
660
  const iterable = this.parseExpr()
661
661
  this.expect(')')
662
662
 
663
- // Parse optional execute context modifiers (at, positioned, rotated, facing, etc.)
663
+ // Parse optional execute context modifiers (as, at, positioned, rotated, facing, etc.)
664
664
  let executeContext: string | undefined
665
- // Check for 'at' keyword or identifiers like 'positioned', 'rotated', 'facing', 'anchored', 'align'
666
- const execIdentKeywords = ['positioned', 'rotated', 'facing', 'anchored', 'align']
667
- if (this.check('at') || this.check('in') || (this.check('ident') && execIdentKeywords.includes(this.peek().value))) {
665
+ // Check for execute subcommand keywords
666
+ const execIdentKeywords = ['positioned', 'rotated', 'facing', 'anchored', 'align', 'on', 'summon']
667
+ if (this.check('as') || this.check('at') || this.check('in') || (this.check('ident') && execIdentKeywords.includes(this.peek().value))) {
668
668
  // Collect everything until we hit '{'
669
669
  let context = ''
670
670
  while (!this.check('{') && !this.check('eof')) {
@@ -738,25 +738,84 @@ export class Parser {
738
738
  } else if (this.match('at')) {
739
739
  const selector = this.parseSelector()
740
740
  subcommands.push({ kind: 'at', selector })
741
- } else if (this.match('if')) {
742
- // Expect 'entity' keyword (as ident) or just parse selector directly
743
- if (this.peek().kind === 'ident' && this.peek().value === 'entity') {
744
- this.advance() // consume 'entity'
741
+ } else if (this.checkIdent('positioned')) {
742
+ this.advance()
743
+ if (this.match('as')) {
744
+ const selector = this.parseSelector()
745
+ subcommands.push({ kind: 'positioned_as', selector })
746
+ } else {
747
+ const x = this.parseCoordToken()
748
+ const y = this.parseCoordToken()
749
+ const z = this.parseCoordToken()
750
+ subcommands.push({ kind: 'positioned', x, y, z })
745
751
  }
746
- const selectorOrVar = this.parseSelectorOrVarSelector()
747
- subcommands.push({ kind: 'if_entity', ...selectorOrVar })
748
- } else if (this.match('unless')) {
749
- // Expect 'entity' keyword (as ident) or just parse selector directly
750
- if (this.peek().kind === 'ident' && this.peek().value === 'entity') {
751
- this.advance() // consume 'entity'
752
+ } else if (this.checkIdent('rotated')) {
753
+ this.advance()
754
+ if (this.match('as')) {
755
+ const selector = this.parseSelector()
756
+ subcommands.push({ kind: 'rotated_as', selector })
757
+ } else {
758
+ const yaw = this.parseCoordToken()
759
+ const pitch = this.parseCoordToken()
760
+ subcommands.push({ kind: 'rotated', yaw, pitch })
752
761
  }
753
- const selectorOrVar = this.parseSelectorOrVarSelector()
754
- subcommands.push({ kind: 'unless_entity', ...selectorOrVar })
762
+ } else if (this.checkIdent('facing')) {
763
+ this.advance()
764
+ if (this.checkIdent('entity')) {
765
+ this.advance()
766
+ const selector = this.parseSelector()
767
+ const anchor = this.checkIdent('eyes') || this.checkIdent('feet') ? this.advance().value as 'eyes' | 'feet' : 'feet'
768
+ subcommands.push({ kind: 'facing_entity', selector, anchor })
769
+ } else {
770
+ const x = this.parseCoordToken()
771
+ const y = this.parseCoordToken()
772
+ const z = this.parseCoordToken()
773
+ subcommands.push({ kind: 'facing', x, y, z })
774
+ }
775
+ } else if (this.checkIdent('anchored')) {
776
+ this.advance()
777
+ const anchor = this.advance().value as 'eyes' | 'feet'
778
+ subcommands.push({ kind: 'anchored', anchor })
779
+ } else if (this.checkIdent('align')) {
780
+ this.advance()
781
+ const axes = this.advance().value
782
+ subcommands.push({ kind: 'align', axes })
783
+ } else if (this.checkIdent('on')) {
784
+ this.advance()
785
+ const relation = this.advance().value
786
+ subcommands.push({ kind: 'on', relation })
787
+ } else if (this.checkIdent('summon')) {
788
+ this.advance()
789
+ const entity = this.advance().value
790
+ subcommands.push({ kind: 'summon', entity })
791
+ } else if (this.checkIdent('store')) {
792
+ this.advance()
793
+ const storeType = this.advance().value // 'result' or 'success'
794
+ if (this.checkIdent('score')) {
795
+ this.advance()
796
+ const target = this.advance().value
797
+ const targetObj = this.advance().value
798
+ if (storeType === 'result') {
799
+ subcommands.push({ kind: 'store_result', target, targetObj })
800
+ } else {
801
+ subcommands.push({ kind: 'store_success', target, targetObj })
802
+ }
803
+ } else {
804
+ this.error('store currently only supports score target')
805
+ }
806
+ } else if (this.match('if')) {
807
+ this.parseExecuteCondition(subcommands, 'if')
808
+ } else if (this.match('unless')) {
809
+ this.parseExecuteCondition(subcommands, 'unless')
755
810
  } else if (this.match('in')) {
756
- const dim = this.expect('ident').value
811
+ // Dimension can be namespaced: minecraft:the_nether
812
+ let dim = this.advance().value
813
+ if (this.match(':')) {
814
+ dim += ':' + this.advance().value
815
+ }
757
816
  subcommands.push({ kind: 'in', dimension: dim })
758
817
  } else {
759
- this.error(`Unexpected token in execute statement: ${this.peek().kind}`)
818
+ this.error(`Unexpected token in execute statement: ${this.peek().kind} (${this.peek().value})`)
760
819
  }
761
820
  }
762
821
 
@@ -766,6 +825,74 @@ export class Parser {
766
825
  return this.withLoc({ kind: 'execute', subcommands, body }, executeToken)
767
826
  }
768
827
 
828
+ private parseExecuteCondition(subcommands: ExecuteSubcommand[], type: 'if' | 'unless'): void {
829
+ if (this.checkIdent('entity') || this.check('selector')) {
830
+ if (this.checkIdent('entity')) this.advance()
831
+ const selectorOrVar = this.parseSelectorOrVarSelector()
832
+ subcommands.push({ kind: type === 'if' ? 'if_entity' : 'unless_entity', ...selectorOrVar })
833
+ } else if (this.checkIdent('block')) {
834
+ this.advance()
835
+ const x = this.parseCoordToken()
836
+ const y = this.parseCoordToken()
837
+ const z = this.parseCoordToken()
838
+ const block = this.parseBlockId()
839
+ subcommands.push({ kind: type === 'if' ? 'if_block' : 'unless_block', pos: [x, y, z], block })
840
+ } else if (this.checkIdent('score')) {
841
+ this.advance()
842
+ const target = this.advance().value
843
+ const targetObj = this.advance().value
844
+ // Check for range or comparison
845
+ if (this.checkIdent('matches')) {
846
+ this.advance()
847
+ const range = this.advance().value
848
+ subcommands.push({ kind: type === 'if' ? 'if_score_range' : 'unless_score_range', target, targetObj, range })
849
+ } else {
850
+ const op = this.advance().value // <, <=, =, >=, >
851
+ const source = this.advance().value
852
+ const sourceObj = this.advance().value
853
+ subcommands.push({
854
+ kind: type === 'if' ? 'if_score' : 'unless_score',
855
+ target, targetObj, op, source, sourceObj
856
+ })
857
+ }
858
+ } else {
859
+ this.error(`Unknown condition type after ${type}`)
860
+ }
861
+ }
862
+
863
+ private parseCoordToken(): string {
864
+ // Handle ~, ^, numbers, relative coords like ~5, ^-3
865
+ const token = this.peek()
866
+ if (token.kind === 'rel_coord' || token.kind === 'local_coord' ||
867
+ token.kind === 'int_lit' || token.kind === 'float_lit' ||
868
+ token.kind === '-' || token.kind === 'ident') {
869
+ return this.advance().value
870
+ }
871
+ this.error(`Expected coordinate, got ${token.kind}`)
872
+ return '~'
873
+ }
874
+
875
+ private parseBlockId(): string {
876
+ // Parse block ID like minecraft:stone or stone
877
+ let id = this.advance().value
878
+ if (this.match(':')) {
879
+ id += ':' + this.advance().value
880
+ }
881
+ // Handle block states [facing=north]
882
+ if (this.check('[')) {
883
+ id += this.advance().value // [
884
+ while (!this.check(']') && !this.check('eof')) {
885
+ id += this.advance().value
886
+ }
887
+ id += this.advance().value // ]
888
+ }
889
+ return id
890
+ }
891
+
892
+ private checkIdent(value: string): boolean {
893
+ return this.check('ident') && this.peek().value === value
894
+ }
895
+
769
896
  private parseExprStmt(): Stmt {
770
897
  const expr = this.parseExpr()
771
898
  this.expect(';')