Skip to content

Addon: [1.9.12] - Unified expression parser for complex requirements#786

Merged
Xian55 merged 1 commit intodevfrom
feature/complex-requirements
Feb 27, 2026
Merged

Addon: [1.9.12] - Unified expression parser for complex requirements#786
Xian55 merged 1 commit intodevfrom
feature/complex-requirements

Conversation

@Xian55
Copy link
Copy Markdown
Owner

@Xian55 Xian55 commented Feb 27, 2026

Summary

Why Replace the Old Expression System?

The previous system had two disconnected layers:

  1. InfixToPostfix (Shunting Yard) — only handled &&, ||, () as a string-level converter, producing postfix tokens
  2. CreateArithmetic — handled simple Variable OP Value flat comparisons

Shunting Yard limitations:

  • Parentheses were ambiguous between the two layers — (Health% + 10) > 50 && InCombat would break because InfixToPostfix treats ( as logical grouping, not arithmetic
  • No support for arithmetic expressions (+, -, *, /) — users were limited to flat Variable OP Value comparisons
  • The % modulo operator was hardcoded to check == 0 only — no way to write Deaths % 2 == 1
  • Adding new operators required modifications in multiple places across both layers
  • Negation (not, !) was handled via character scanning with SearchValues, separate from the expression evaluation

The new unified Pratt parser handles all operators (arithmetic, comparison, logical) with proper precedence in a single pass, eliminating all of these limitations.


Breaking Change: not keyword removed

The not keyword prefix has been removed from the expression system. Use the ! operator instead.

Migration regex (for user JSON profiles):

  • Find: \bnot\s+
  • Replace: !

Examples:

Before After
"not InCombat" "!InCombat"
"not Stealth && Health% > 50" "!Stealth && Health% > 50"
"not Swimming" "!Swimming"

All shipped class profiles have already been updated.


New Unified Expression Parser (Pratt Parser)

Replace the two-layer expression system (InfixToPostfix Shunting Yard + CreateArithmetic flat comparisons) with a unified Pratt parser that handles all operators in a single pass with proper precedence.

New files:

  • Core/RPN/ExpressionToken.csTokenKind enum and ExpressionToken readonly struct
  • Core/RPN/ExpressionTokenizer.csref struct tokenizer operating on ReadOnlySpan<char> for zero-allocation scanning. Greedy variable matching (sorted by length desc) resolves ambiguity between Health% (variable) and % (modulo operator)
  • Core/RPN/ExpressionParser.cs — Pratt parser with type-checked ExprValue struct (Func<int>? / Func<bool>? / Func<string>?). Delegates are composed at parse-time, not runtime — same performance model as the previous system

Deleted: Core/RPN/InfixToPostfix.cs — fully replaced by the new parser

Operator precedence table:

Precedence Operators Operand Types - Result
1 (lowest) || bool, bool - bool
2 && bool, bool - bool
3 ==, != int, int - bool
4 >, <, >=, <= int, int - bool
5 +, - int, int - int
6 *, /, % int, int - int
7 (prefix) !, unary - bool/int - bool/int

New operators: +, -, *, / (arithmetic), != (comparison). % now returns int (previously hardcoded to == 0).

Backward compatibility: If a top-level expression (or logical operand) evaluates to int rather than bool, the parser implicitly wraps it as expr == 0 to preserve the existing Deaths % 2 behavior.

Example new expressions:

"Requirement": "Deaths % 2 == 1"
"Requirement": "Energy - Cost_Sinister_Strike >= 0"
"Requirement": "(Health% + Mana%) / 2 > 50"
"Requirement": "Kills % 5 == 0 && SessionMinutes > 10"
"Requirement": "MainHandSwing > -SpellQueueWindow"

All existing expressions continue to work unchanged (including Health%>70, Stealth&&InMeleeRange without spaces). The tokenizer checks multi-char operators before variable names, and variable matching verifies the next character is a delimiter, so no-space expressions parse correctly.

Log message format preserved:

  • Health% > 50 -> "Health% 75 > 50"
  • Deaths % 2 == 1 -> "Deaths 3 % 2 == 1"
  • InCombat && Health% > 50 -> "InCombat and Health% 75 > 50" (uses existing Requirement.And/Requirement.Or join strings)

RequirementFactory Simplification

  • Process() method reduced from a 30-line InfixToPostfix + stack evaluation loop to a single expressionParser.Parse(requirement) call
  • Removed 6 arithmetic/comparison handler entries from requirementMap (>=, <=, >, <, ==, %) — the Pratt parser handles these natively as operators
  • Removed methods: CreateRequirement, CreateGreaterThen, CreateLesserThen, CreateGreaterOrEquals, CreateLesserOrEquals, CreateEquals, CreateModulo, CreateArithmetic
  • Removed negateKeywordsSpan and comparison constant strings
  • Removed from Requirement.cs: RequirementExt class (And(), Or(), Negate() extensions) — only used by the old Process() method
  • Parameterized requirements (npcID:, Form:, SpellInRange:, etc.) remain handled by existing factory methods — the parser delegates to them via the requirementMap

Array IntVariables for Aura Prefixes

  • IntVariables type changed from Dictionary<string, int> to Dictionary<string, int[]> with custom IntOrIntArrayDictionaryConverter (scalars are normalized to single-element arrays for uniform handling)
  • Array values for aura prefixes (Buff_, Debuff_, TDebuff_, TBuff_, FBuff_) register a single variable returning the max remaining time across all icon IDs
  • Enables grouping multiple buff/debuff IDs under one variable name

Example JSON config:

"IntVariables": {
    "Buff_Shield": [1234, 5678]
}
"Requirement": "Buff_Shield > 0"

This checks if any of the listed buff IDs is active (max remaining time > 0).


SinceDamageTakenMs Keyword

  • New PlayerReader property tracking milliseconds since last damage taken (using Stopwatch.GetTimestamp() for high-resolution timing)
  • Returns int.MaxValue if no damage has been recorded yet
  • Detects damage by comparing HealthPercent() between frames — when current health < previous health, records a new damage timestamp
  • Useful for combat timeout conditions

Example:

"Requirement": "SinceDamageTakenMs > 5000"

Checks if the player hasn't taken damage for 5 seconds.


Testing Infrastructure

  • NullAddonDataProvider (Core/AddonDataProvider/NullAddonDataProvider.cs) — implements IAddonDataProvider with a zeroed Data array for headless/benchmark use without WoW running
  • NullScreenImageProvider (Core/NullScreenImageProvider.cs) — implements IScreenImageProvider returning empty DirectBitmap
  • DependencyInjection.AddCoreLoadOnly() — registers a minimal DI container sufficient for parsing and loading class profiles without a live game connection. Registers null providers, database singletons, and addon components

HeadlessServer Improvements

  • HeadlessServer.RunLoadOnly() and BotController.LoadClassProfile() now return bool success/failure (previously void)
  • Program.cs exits with code 1 on load failure (previously always exited 0)
  • loadall.bat rewritten: iterates all JSON profiles, reports [PASS]/[FAIL] per file, prints summary with total passed/failed counts, and exits with non-zero code if any failed

Benchmark Rewrite

  • LoadAllProfiles benchmark now uses DI directly (AddCoreLoadOnly()) instead of spawning external HeadlessServer processes
  • SetWorkingDirectory() walks up to solution root reliably from any build output path
  • Iterates all profiles (previously only loaded the first one)
  • [Params] for UnitRace/UnitClass to inject correct race/class into addon data

Addon Fix: DataToColor:PS() — Highest Spell Rank

  • PS() now places the highest spell rank instead of the lowest — spells in the spell book are ordered by rank, so the function now tracks the last matching index (best) and breaks when matches end, ensuring the highest rank is placed on the action bar
  • Addon version bumped 1.9.11 -> 1.9.12

ClassConfiguration Fix

  • Merge() method: base action requirements now joined with && instead of bare space concatenation, preventing malformed combined expressions (e.g., "InCombat Health% > 50" is now correctly "InCombat && Health% > 50")

Class Profile Updates

Updated profiles to use new expression syntax:

  • Paladin (6, 8, 10, 16, 20, 30): Consolidated aura/seal conditions using Buff_ arrays and arithmetic expressions
  • Warrior (12, 20): Updated requirement expressions
  • Mage (6, 16, 60): Updated requirement expressions
  • Shaman (15, 22, 44): Updated requirement expressions
  • Rogue (20): Updated requirement expressions with energy cost calculations
  • Druid (24): Updated requirement expressions

README Updates

  • Documented new arithmetic operators (+, -, *, /), != comparison, and full expression syntax
  • Added examples for complex expressions, array IntVariables, and SinceDamageTakenMs
  • Updated operator precedence documentation

Test plan

  • dotnet build MasterOfPuppets.sln compiles without errors
  • dotnet test passes all existing tests
  • HeadlessServer loadall.bat loads all class profiles successfully with [PASS] status
  • dotnet run --project Benchmarks -c Release -- --filter *LoadAllProfiles* — all JSON configs parse without error
  • Verify expression parsing handles operator precedence correctly (e.g., 2 + 3 * 4 == 14)
  • Verify no-space expressions still work (Health%>70, Stealth&&InMeleeRange)
  • Verify backward compatibility: Deaths % 2 still implicitly means Deaths % 2 == 0
  • Verify array IntVariable resolution for aura prefixes with multiple icon IDs returns max remaining time
  • Verify SinceDamageTakenMs returns int.MaxValue when no damage taken, and correct elapsed time after damage
  • Verify PS() places highest spell rank (e.g., Immolate Rank 9 not Rank 1) on action bar
  • Verify ! operator works as replacement for not (e.g., !InCombat)

Generated with Claude Code

- Replace InfixToPostfix RPN evaluator with ExpressionParser/Tokenizer
  supporting full arithmetic expressions (+, -, *, /, %), parentheses,
  comparison operators (==, !=, <, >, <=, >=), and logical operators (&&, ||)
- Support array syntax in IntVariables for aura prefixes (Buff_, Debuff_, etc.)
  returning max remaining time across all icon IDs
- Add SinceDamageTakenMs requirement keyword (PlayerReader)
- Add NullAddonDataProvider and NullScreenImageProvider for testing
- Add DependencyInjection.AddCoreLoadOnly() for headless/benchmark use
- HeadlessServer.LoadClassProfile returns bool; loadall.bat reports summary
- Rewrite LoadAllProfiles benchmark to use DI instead of process spawning
- Fix DataToColor:PS() to place highest spell rank instead of lowest
- Bump addon version 1.9.11 → 1.9.12
- Update class profiles to use new expression syntax
- Update README with new features and examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Xian55 Xian55 added documentation Improvements or additions to documentation bugfix This pull request fixes an issue. refactor This ticket concerns the possible simplification of code/data. enhancement This pull request implements a new feature. breaking change labels Feb 27, 2026
@Xian55 Xian55 merged commit 9feedca into dev Feb 27, 2026
1 check passed
@Xian55 Xian55 deleted the feature/complex-requirements branch February 27, 2026 00:22
@Xian55 Xian55 mentioned this pull request Feb 27, 2026
@Xian55 Xian55 linked an issue Feb 27, 2026 that may be closed by this pull request
@Xian55 Xian55 added the ai Artificial Intelligence tool has been contributed. label Mar 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai Artificial Intelligence tool has been contributed. breaking change bugfix This pull request fixes an issue. documentation Improvements or additions to documentation enhancement This pull request implements a new feature. refactor This ticket concerns the possible simplification of code/data.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cure/Cleanse

1 participant