From 2a7077575dc77687c52e117d928c3087ee507e06 Mon Sep 17 00:00:00 2001 From: yuzhe <53376445+bkmashiro@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:35:03 +0100 Subject: [PATCH] burn(bug): Fix ne/inequality operator in cmpToMC() for if-score contexts [9GH3DD] --- src/__tests__/emit/index.test.ts | 129 ++++++++++++++++++++++++++++++- src/emit/index.ts | 33 +++++--- 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/src/__tests__/emit/index.test.ts b/src/__tests__/emit/index.test.ts index 3ddc12e..fe56591 100644 --- a/src/__tests__/emit/index.test.ts +++ b/src/__tests__/emit/index.test.ts @@ -135,7 +135,8 @@ describe('emit: direct LIR emission', () => { expect(main).toContain('execute if score $a __emit matches 1..5 run function emitns:matches') expect(main).toContain('execute unless score $a __emit matches 0 run function emitns:unless_matches') expect(main).toContain('execute if score $a __emit = $b __emit run function emitns:if_score') - expect(main).toContain('execute unless score $a __emit = $b __emit run function emitns:unless_score') + // call_unless_score with ne: unless(a != b) == if(a == b), so sense flips + expect(main).toContain('execute if score $a __emit = $b __emit run function emitns:unless_score') expect(main).toContain('execute as @a at @e[type=marker] at @s positioned ~1 ~2 ~3 rotated ~ ~10 in minecraft:the_nether anchored eyes if score $a __emit < $b __emit unless score $a __emit >= $b __emit if score $a __emit matches 1.. unless score $b __emit matches ..0 run function emitns:ctx') expect(main).toContain('scoreboard players operation $ret __emit = $a __emit') expect(main).toContain('$tp @s $(x) $(y) $(z)') @@ -246,4 +247,130 @@ describe('emit: direct LIR emission', () => { expect(neOut).not.toBe(eqOut) }) }) + + test('call_if_score with ne emits unless score, not if score =', () => { + const module: LIRModule = { + namespace: 'ns', + objective: '__obj', + functions: [ + { + name: 'Test', + isMacro: false, + macroParams: [], + instructions: [ + { + kind: 'call_if_score', + fn: 'ns:ne_branch', + a: { player: '$x', obj: '__obj' }, + op: 'ne', + b: { player: '$y', obj: '__obj' }, + }, + ], + }, + ], + } + + const files = emit(module, { namespace: 'ns', mcVersion: McVersion.v1_21 }) + const content = getFile(files, 'data/ns/function/test.mcfunction') + + expect(content).toContain('execute unless score $x __obj = $y __obj run function ns:ne_branch') + expect(content).not.toContain('if score $x __obj = $y __obj') + }) + + test('call_unless_score with ne emits if score (double negation cancels out)', () => { + const module: LIRModule = { + namespace: 'ns', + objective: '__obj', + functions: [ + { + name: 'Test', + isMacro: false, + macroParams: [], + instructions: [ + { + kind: 'call_unless_score', + fn: 'ns:ne_unless_branch', + a: { player: '$x', obj: '__obj' }, + op: 'ne', + b: { player: '$y', obj: '__obj' }, + }, + ], + }, + ], + } + + const files = emit(module, { namespace: 'ns', mcVersion: McVersion.v1_21 }) + const content = getFile(files, 'data/ns/function/test.mcfunction') + + // unless (a != b) == if (a == b) + expect(content).toContain('execute if score $x __obj = $y __obj run function ns:ne_unless_branch') + expect(content).not.toContain('unless score $x __obj = $y __obj run function ns:ne_unless_branch') + }) + + test('call_if_score with non-ne operators emits if score without flipping', () => { + const ops = [ + { op: 'eq', mc: '=' }, + { op: 'lt', mc: '<' }, + { op: 'le', mc: '<=' }, + { op: 'gt', mc: '>' }, + { op: 'ge', mc: '>=' }, + ] as const + + for (const { op, mc } of ops) { + const module: LIRModule = { + namespace: 'ns', + objective: '__obj', + functions: [ + { + name: 'Test', + isMacro: false, + macroParams: [], + instructions: [ + { + kind: 'call_if_score', + fn: `ns:fn_${op}`, + a: { player: '$x', obj: '__obj' }, + op, + b: { player: '$y', obj: '__obj' }, + }, + ], + }, + ], + } + + const files = emit(module, { namespace: 'ns', mcVersion: McVersion.v1_21 }) + const content = getFile(files, 'data/ns/function/test.mcfunction') + + expect(content).toContain(`execute if score $x __obj ${mc} $y __obj run function ns:fn_${op}`) + } + }) + + test('if_score subcmd with ne emits unless score in execute chain', () => { + const module: LIRModule = { + namespace: 'ns', + objective: '__obj', + functions: [ + { + name: 'Test', + isMacro: false, + macroParams: [], + instructions: [ + { + kind: 'call_context', + fn: 'ns:ctx_ne', + subcommands: [ + { kind: 'if_score', a: '$x __obj', op: 'ne', b: '$y __obj' }, + ], + }, + ], + }, + ], + } + + const files = emit(module, { namespace: 'ns', mcVersion: McVersion.v1_21 }) + const content = getFile(files, 'data/ns/function/test.mcfunction') + + expect(content).toContain('execute unless score $x __obj = $y __obj run function ns:ctx_ne') + expect(content).not.toContain('if score $x __obj = $y __obj run function ns:ctx_ne') + }) }) diff --git a/src/emit/index.ts b/src/emit/index.ts index f886640..a028e74 100644 --- a/src/emit/index.ts +++ b/src/emit/index.ts @@ -841,14 +841,10 @@ function emitInstr(instr: LIRInstr, ns: string, obj: string, mcVersion: McVersio return `execute unless score ${slot(instr.slot)} matches ${instr.range} run function ${instr.fn}` case 'call_if_score': - // 'ne' has no direct MC operator; invert to `unless ... =` for correct semantics. - if (instr.op === 'ne') { - return `execute unless score ${slot(instr.a)} = ${slot(instr.b)} run function ${instr.fn}` - } - return `execute if score ${slot(instr.a)} ${cmpToMC(instr.op)} ${slot(instr.b)} run function ${instr.fn}` + return `execute ${scoreCondition('if', instr.op, slot(instr.a), slot(instr.b))} run function ${instr.fn}` case 'call_unless_score': - return `execute unless score ${slot(instr.a)} ${cmpToMC(instr.op)} ${slot(instr.b)} run function ${instr.fn}` + return `execute ${scoreCondition('unless', instr.op, slot(instr.a), slot(instr.b))} run function ${instr.fn}` case 'call_context': { const subcmds = instr.subcommands.map(emitSubcmd).join(' ') @@ -900,7 +896,7 @@ function slot(s: Slot): string { function cmpToMC(op: CmpOp): string { switch (op) { case 'eq': return '=' - case 'ne': return '=' // 'ne' maps to '=' — callers must wrap in `unless` (MC has no != operator) + case 'ne': return '=' // ne is expressed via "unless score ... =" rather than a distinct operator case 'lt': return '<' case 'le': return '<=' case 'gt': return '>' @@ -908,6 +904,25 @@ function cmpToMC(op: CmpOp): string { } } +/** + * Emit a score condition fragment, e.g. "if score $a obj = $b obj". + * + * MC has no "!=" operator; not-equal is expressed by flipping the if/unless + * keyword and using "=" as the operator: + * a != b → unless score a = b (not: if score a = b) + * a == b → if score a = b + * + * The `sense` parameter is the caller's intended polarity ('if' or 'unless'). + * For 'ne', both the sense and operator are adjusted automatically. + */ +function scoreCondition(sense: 'if' | 'unless', op: CmpOp, a: string, b: string): string { + if (op === 'ne') { + const flipped = sense === 'if' ? 'unless' : 'if' + return `${flipped} score ${a} = ${b}` + } + return `${sense} score ${a} ${cmpToMC(op)} ${b}` +} + /** * Pre-1.20.2 compat: emit a macro template as a plain command. * $(param) placeholders are replaced with `storage rs:macro_args ` data-get @@ -995,9 +1010,9 @@ function emitSubcmd(sub: ExecuteSubcmd): string { case 'anchored': return `anchored ${sub.anchor}` case 'if_score': - return `if score ${sub.a} ${cmpToMC(sub.op)} ${sub.b}` + return scoreCondition('if', sub.op, sub.a, sub.b) case 'unless_score': - return `unless score ${sub.a} ${cmpToMC(sub.op)} ${sub.b}` + return scoreCondition('unless', sub.op, sub.a, sub.b) case 'if_matches': return `if score ${sub.score} matches ${sub.range}` case 'unless_matches':