Add strategic AI framework and PlayerAI #10

Merged
smell-of-curry merged 5 commits from ai-update into master 2026-04-28 23:25:40 +00:00
smell-of-curry commented 2026-04-26 17:45:53 +00:00 (Migrated from github.com)

Introduce a new strategic-AI system and replace the legacy RandomPlayerAI surface with a unified PlayerAI. Key additions: an Engine interface and EngineContext, multiple engine implementations (RandomEngine, HeuristicEngine, MctsEngine, OnePlySearchEngine, LightHeuristicEngine), mechanics/evaluators (DamageCalc, MoveEvaluator, SwitchEvaluator, TargetPicker, TransformPolicy), policy and state modules (DifficultyPolicy, BattleStateTracker, LogParser, OpponentInference), and tests for the new modules. The PlayerAI class now hosts engines, plumbs protocol logs into BattleStateTracker, and forwards ChoiceRequests to selected engines; BattlePlayer.receiveRequest's signature was broadened to allow returning a choice string. Runners and examples were updated to use PlayerAI (defaulting to the random engine for compatibility), exhaustive-runner wires a CoordinatedRandomEngine to share pools, and the old sim/tools/random-player-ai.ts was removed. Migration note: callers importing RandomPlayerAI should switch to PlayerAI (pass engine: 'random' to retain prior behavior) and adjust for the altered receiveRequest return semantics.

Summary by CodeRabbit

  • New Features

    • Expanded AI system: unified PlayerAI entrypoint and five difficulty engines (random, light, heuristic, one-ply, MCTS) with strategic decision layers (damage, target, switch, transform).
    • Rich battle state tracking and approximate damage estimation for smarter choices.
  • Refactor

    • Consolidated AI implementations into a modular engine-based architecture and updated runner/examples to use the new PlayerAI.
  • Bug Fixes

    • Multi-battle handling: empty active slots now auto-pass.
  • Tests

    • Added test suites for damage calc, log parsing, switch evaluation, and difficulty gradients.
Introduce a new strategic-AI system and replace the legacy RandomPlayerAI surface with a unified PlayerAI. Key additions: an Engine interface and EngineContext, multiple engine implementations (RandomEngine, HeuristicEngine, MctsEngine, OnePlySearchEngine, LightHeuristicEngine), mechanics/evaluators (DamageCalc, MoveEvaluator, SwitchEvaluator, TargetPicker, TransformPolicy), policy and state modules (DifficultyPolicy, BattleStateTracker, LogParser, OpponentInference), and tests for the new modules. The PlayerAI class now hosts engines, plumbs protocol logs into BattleStateTracker, and forwards ChoiceRequests to selected engines; BattlePlayer.receiveRequest's signature was broadened to allow returning a choice string. Runners and examples were updated to use PlayerAI (defaulting to the random engine for compatibility), exhaustive-runner wires a CoordinatedRandomEngine to share pools, and the old sim/tools/random-player-ai.ts was removed. Migration note: callers importing RandomPlayerAI should switch to PlayerAI (pass engine: 'random' to retain prior behavior) and adjust for the altered receiveRequest return semantics. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Expanded AI system: unified PlayerAI entrypoint and five difficulty engines (random, light, heuristic, one-ply, MCTS) with strategic decision layers (damage, target, switch, transform). * Rich battle state tracking and approximate damage estimation for smarter choices. * **Refactor** * Consolidated AI implementations into a modular engine-based architecture and updated runner/examples to use the new PlayerAI. * **Bug Fixes** * Multi-battle handling: empty active slots now auto-pass. * **Tests** * Added test suites for damage calc, log parsing, switch evaluation, and difficulty gradients. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
coderabbitai[bot] commented 2026-04-26 17:46:07 +00:00 (Migrated from github.com)

Warning

Rate limit exceeded

@smell-of-curry has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 38 minutes and 5 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 056bd701-a2c4-4eb7-801d-5ed66302329f

📥 Commits

Reviewing files that changed from the base of the PR and between ed4d2de93b and 45f24bdbb8.

📒 Files selected for processing (11)
  • sim/tools/player-ai.ts
  • sim/tools/strategic-ai/engines/LightHeuristicEngine.ts
  • sim/tools/strategic-ai/engines/RandomEngine.ts
  • sim/tools/strategic-ai/mechanics/DamageCalc.ts
  • sim/tools/strategic-ai/mechanics/MoveEvaluator.ts
  • sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts
  • sim/tools/strategic-ai/mechanics/TargetPicker.ts
  • sim/tools/strategic-ai/mechanics/TransformPolicy.ts
  • sim/tools/strategic-ai/state/BattleStateTracker.ts
  • sim/tools/strategic-ai/state/LogParser.ts
  • sim/tools/strategic-ai/state/OpponentInference.ts
📝 Walkthrough

Walkthrough

Modular strategic-AI replaces legacy Random/Strong heuristics with a new PlayerAI host, a pluggable Engine interface (Random/Light/Heuristic/OnePly/MCTS), battle-state tracking, damage/target/switch/transform mechanics, opponent inference, widened BattlePlayer.receiveRequest signature, and runner/example/test updates.

Changes

Cohort / File(s) Summary
Core API
sim/battle-stream.ts, sim/index.ts
Widened BattlePlayer.receiveRequest(request: ChoiceRequest) return type to string | undefined | void; replaced RandomPlayerAI/StrongHeuristicsAI exports with PlayerAI and exported PlayerAIOptions.
New Player Host
sim/tools/player-ai.ts
Added PlayerAI and PlayerAIOptions: PRNG/difficulty init, lazy BattleStateTracker creation, buffered log parsing, error-handling for disabled moves, and request→engine dispatch.
Engine Architecture & Policy
sim/tools/strategic-ai/engines/Engine.ts, sim/tools/strategic-ai/policy/DifficultyPolicy.ts, sim/tools/strategic-ai/index.ts
New Engine interface and EngineContext, noise/info-forgetting knobs, difficulty→engine mapping (pickEngine, knobsForDifficulty, applyKnobs), and a barrel export for strategic-ai.
Engines
sim/tools/strategic-ai/engines/RandomEngine.ts, .../LightHeuristicEngine.ts, .../HeuristicEngine.ts, .../OnePlySearchEngine.ts, .../MctsEngine.ts
Added Random (1), Light Heuristic (2), Heuristic (3), OnePly (4), and MCTS (5) engines with choose()/scoring/search implementations and engine-specific helpers.
Mechanics & Evaluation
sim/tools/strategic-ai/mechanics/DamageCalc.ts, .../SwitchEvaluator.ts, .../TargetPicker.ts, .../TransformPolicy.ts, .../MoveEvaluator.ts
New damage calculator, matchup/switch evaluator, doubles target picker, transform policy, and move evaluation utilities used by engines.
State & Parsing
sim/tools/strategic-ai/state/BattleStateTracker.ts, .../LogParser.ts, .../OpponentInference.ts
Added BattleStateTracker, permissive log parser mapping lines→BattleEvent, and opponent inference producing move/ability/item distributions.
Runner & Exhaustive Runner
sim/tools/runner.ts, sim/tools/exhaustive-runner.ts
Runner AI factory now returns BattleStreams.BattlePlayer; default AI construction switched to PlayerAI with random engine; exhaustive runner wired to CoordinatedRandomEngine and fixMoveId.
Removed legacy AIs
sim/tools/random-player-ai.ts, sim/tools/strategic-player-ai.ts
Deleted RandomPlayerAI and StrongHeuristicsAI implementations (functionality replaced by RandomEngine + PlayerAI-hosted engines).
Examples & Tests
sim/examples/battle-stream-example.ts, test/sim/tools/strategic-ai/*
Example switched to PlayerAI with { engine: "random" }; added tests for DamageCalc, LogParser, SwitchEvaluator, and a difficulty-gradient harness.
Misc / Side changes
sim/tools/strategic-ai/..., sim/side.ts
Numerous new modules under strategic-ai; sim/side.ts adjusted multi-battle null-active handling and refined choice parsing.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(200,220,255,0.5)
    participant Stream as Player Stream
    end
    rect rgba(200,255,200,0.5)
    participant PlayerAI as PlayerAI
    end
    rect rgba(255,220,200,0.5)
    participant Engine as Engine
    end
    rect rgba(255,200,255,0.5)
    participant Tracker as BattleStateTracker
    end

    Stream->>PlayerAI: receiveLine("|...|") / buffer
    PlayerAI->>PlayerAI: ensureTracker() (on first request)
    PlayerAI->>Tracker: replay buffered events / applyEvent
    Stream->>PlayerAI: receiveLine("|request|{...}")
    PlayerAI->>PlayerAI: parse JSON request
    PlayerAI->>Tracker: applyRequest(request) (non-wait)
    PlayerAI->>Engine: engine.choose(request, engineCtx)
    Engine-->>PlayerAI: choice string (or "")
    PlayerAI->>Stream: choose(choice) OR return choice

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I hopped through logs and tracked each fight,

engines tuned from random to clever might.
PRNG seeds, choices, and damage told —
modular burrows, new code to behold!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: introducing a strategic AI framework and PlayerAI. It directly reflects the primary objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ai-update

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

<!-- This is an auto-generated comment: summarize by coderabbit.ai --> <!-- This is an auto-generated comment: rate limited by coderabbit.ai --> > [!WARNING] > ## Rate limit exceeded > > `@smell-of-curry` has exceeded the limit for the number of commits that can be reviewed per hour. Please wait **38 minutes and 5 seconds** before requesting another review. > > To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under [billing](https://app.coderabbit.ai/settings/subscription?tab=usage). > > <details> > <summary>⌛ How to resolve this issue?</summary> > > After the wait time has elapsed, a review can be triggered using the `@coderabbitai review` command as a PR comment. Alternatively, push new commits to this PR. > > We recommend that you space out your commits to avoid hitting the rate limit. > > </details> > > > <details> > <summary>🚦 How do rate limits work?</summary> > > CodeRabbit enforces hourly rate limits for each developer per organization. > > Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. > > Please see our [FAQ](https://docs.coderabbit.ai/faq) for further information. > > </details> > > <details> > <summary>ℹ️ Review info</summary> > > <details> > <summary>⚙️ Run configuration</summary> > > **Configuration used**: defaults > > **Review profile**: CHILL > > **Plan**: Pro > > **Run ID**: `056bd701-a2c4-4eb7-801d-5ed66302329f` > > </details> > > <details> > <summary>📥 Commits</summary> > > Reviewing files that changed from the base of the PR and between ed4d2de93b22958b39bb74eae19c0faa4268ce39 and 45f24bdbb865c6bb621ae63ceefeb338f85e80b7. > > </details> > > <details> > <summary>📒 Files selected for processing (11)</summary> > > * `sim/tools/player-ai.ts` > * `sim/tools/strategic-ai/engines/LightHeuristicEngine.ts` > * `sim/tools/strategic-ai/engines/RandomEngine.ts` > * `sim/tools/strategic-ai/mechanics/DamageCalc.ts` > * `sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` > * `sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` > * `sim/tools/strategic-ai/mechanics/TargetPicker.ts` > * `sim/tools/strategic-ai/mechanics/TransformPolicy.ts` > * `sim/tools/strategic-ai/state/BattleStateTracker.ts` > * `sim/tools/strategic-ai/state/LogParser.ts` > * `sim/tools/strategic-ai/state/OpponentInference.ts` > > </details> > > </details> <!-- end of auto-generated comment: rate limited by coderabbit.ai --> <!-- walkthrough_start --> <details> <summary>📝 Walkthrough</summary> ## Walkthrough Modular strategic-AI replaces legacy Random/Strong heuristics with a new PlayerAI host, a pluggable Engine interface (Random/Light/Heuristic/OnePly/MCTS), battle-state tracking, damage/target/switch/transform mechanics, opponent inference, widened BattlePlayer.receiveRequest signature, and runner/example/test updates. ## Changes |Cohort / File(s)|Summary| |---|---| |**Core API** <br> `sim/battle-stream.ts`, `sim/index.ts`|Widened `BattlePlayer.receiveRequest(request: ChoiceRequest)` return type to `string \| undefined \| void`; replaced `RandomPlayerAI`/`StrongHeuristicsAI` exports with `PlayerAI` and exported `PlayerAIOptions`.| |**New Player Host** <br> `sim/tools/player-ai.ts`|Added `PlayerAI` and `PlayerAIOptions`: PRNG/difficulty init, lazy BattleStateTracker creation, buffered log parsing, error-handling for disabled moves, and request→engine dispatch.| |**Engine Architecture & Policy** <br> `sim/tools/strategic-ai/engines/Engine.ts`, `sim/tools/strategic-ai/policy/DifficultyPolicy.ts`, `sim/tools/strategic-ai/index.ts`|New `Engine` interface and `EngineContext`, noise/info-forgetting knobs, difficulty→engine mapping (`pickEngine`, `knobsForDifficulty`, `applyKnobs`), and a barrel export for strategic-ai.| |**Engines** <br> `sim/tools/strategic-ai/engines/RandomEngine.ts`, `.../LightHeuristicEngine.ts`, `.../HeuristicEngine.ts`, `.../OnePlySearchEngine.ts`, `.../MctsEngine.ts`|Added Random (1), Light Heuristic (2), Heuristic (3), OnePly (4), and MCTS (5) engines with choose()/scoring/search implementations and engine-specific helpers.| |**Mechanics & Evaluation** <br> `sim/tools/strategic-ai/mechanics/DamageCalc.ts`, `.../SwitchEvaluator.ts`, `.../TargetPicker.ts`, `.../TransformPolicy.ts`, `.../MoveEvaluator.ts`|New damage calculator, matchup/switch evaluator, doubles target picker, transform policy, and move evaluation utilities used by engines.| |**State & Parsing** <br> `sim/tools/strategic-ai/state/BattleStateTracker.ts`, `.../LogParser.ts`, `.../OpponentInference.ts`|Added BattleStateTracker, permissive log parser mapping lines→BattleEvent, and opponent inference producing move/ability/item distributions.| |**Runner & Exhaustive Runner** <br> `sim/tools/runner.ts`, `sim/tools/exhaustive-runner.ts`|Runner AI factory now returns `BattleStreams.BattlePlayer`; default AI construction switched to `PlayerAI` with random engine; exhaustive runner wired to `CoordinatedRandomEngine` and `fixMoveId`.| |**Removed legacy AIs** <br> `sim/tools/random-player-ai.ts`, `sim/tools/strategic-player-ai.ts`|Deleted `RandomPlayerAI` and `StrongHeuristicsAI` implementations (functionality replaced by RandomEngine + PlayerAI-hosted engines).| |**Examples & Tests** <br> `sim/examples/battle-stream-example.ts`, `test/sim/tools/strategic-ai/*`|Example switched to `PlayerAI` with `{ engine: "random" }`; added tests for DamageCalc, LogParser, SwitchEvaluator, and a difficulty-gradient harness.| |**Misc / Side changes** <br> `sim/tools/strategic-ai/...`, `sim/side.ts`|Numerous new modules under strategic-ai; `sim/side.ts` adjusted multi-battle null-active handling and refined choice parsing. | ## Sequence Diagram(s) ```mermaid sequenceDiagram rect rgba(200,220,255,0.5) participant Stream as Player Stream end rect rgba(200,255,200,0.5) participant PlayerAI as PlayerAI end rect rgba(255,220,200,0.5) participant Engine as Engine end rect rgba(255,200,255,0.5) participant Tracker as BattleStateTracker end Stream->>PlayerAI: receiveLine("|...|") / buffer PlayerAI->>PlayerAI: ensureTracker() (on first request) PlayerAI->>Tracker: replay buffered events / applyEvent Stream->>PlayerAI: receiveLine("|request|{...}") PlayerAI->>PlayerAI: parse JSON request PlayerAI->>Tracker: applyRequest(request) (non-wait) PlayerAI->>Engine: engine.choose(request, engineCtx) Engine-->>PlayerAI: choice string (or "") PlayerAI->>Stream: choose(choice) OR return choice ``` ## Estimated code review effort 🎯 5 (Critical) | ⏱️ ~120 minutes ## Poem > 🐰 I hopped through logs and tracked each fight, > > engines tuned from random to clever might. > > PRNG seeds, choices, and damage told — > > modular burrows, new code to behold! </details> <!-- walkthrough_end --> <!-- pre_merge_checks_walkthrough_start --> <details> <summary>🚥 Pre-merge checks | ✅ 4 | ❌ 1</summary> ### ❌ Failed checks (1 warning) | Check name | Status | Explanation | Resolution | | :----------------: | :--------- | :------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------- | | Docstring Coverage | ⚠️ Warning | Docstring coverage is 50.47% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. | <details> <summary>✅ Passed checks (4 passed)</summary> | Check name | Status | Explanation | | :------------------------: | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. | | Title check | ✅ Passed | The title clearly and concisely describes the main change: introducing a strategic AI framework and PlayerAI. It directly reflects the primary objective of the changeset. | | Linked Issues check | ✅ Passed | Check skipped because no linked issues were found for this pull request. | | Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request. | </details> <sub>✏️ Tip: You can configure your own custom pre-merge checks in the settings.</sub> </details> <!-- pre_merge_checks_walkthrough_end --> <!-- finishing_touch_checkbox_start --> <details> <summary>✨ Finishing Touches</summary> <details> <summary>🧪 Generate unit tests (beta)</summary> - [ ] <!-- {"checkboxId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} --> Create PR with unit tests - [ ] <!-- {"checkboxId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} --> Commit unit tests in branch `ai-update` </details> </details> <!-- finishing_touch_checkbox_end --> <!-- tips_start --> --- Thanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=smell-of-curry/pokebedrock-showdown&utm_content=10)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. <details> <summary>❤️ Share</summary> - [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai) - [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai) - [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai) - [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code) </details> <sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub> <!-- tips_end --> <!-- internal state start --> <!-- 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 --> <!-- internal state end -->
github-advanced-security[bot] (Migrated from github.com) reviewed 2026-04-26 17:48:59 +00:00
@ -0,0 +1,234 @@
/**
github-advanced-security[bot] (Migrated from github.com) commented 2026-04-26 17:48:59 +00:00

CodeQL / Polynomial regular expression used on uncontrolled data

This regular expression that depends on library input may run slow on strings starting with '[] ' and with many repetitions of '] a'.
This regular expression that depends on library input may run slow on strings starting with '[] : ' and with many repetitions of ': a'.
This regular expression that depends on library input may run slow on strings starting with '[] ' and with many repetitions of '] a'.
This regular expression that depends on library input may run slow on strings starting with '[] : ' and with many repetitions of ': a'.
This regular expression that depends on library input may run slow on strings starting with '[] ' and with many repetitions of '] a'.
This regular expression that depends on library input may run slow on strings starting with '[] : ' and with many repetitions of ': a'.

Show more details

## CodeQL / Polynomial regular expression used on uncontrolled data This [regular expression](1) that depends on [library input](2) may run slow on strings starting with '\[\] ' and with many repetitions of '\] a'. This [regular expression](3) that depends on [library input](2) may run slow on strings starting with '\[\] : ' and with many repetitions of ': a'. This [regular expression](1) that depends on [library input](4) may run slow on strings starting with '\[\] ' and with many repetitions of '\] a'. This [regular expression](3) that depends on [library input](4) may run slow on strings starting with '\[\] : ' and with many repetitions of ': a'. This [regular expression](1) that depends on [library input](5) may run slow on strings starting with '\[\] ' and with many repetitions of '\] a'. This [regular expression](3) that depends on [library input](5) may run slow on strings starting with '\[\] : ' and with many repetitions of ': a'. [Show more details](https://github.com/smell-of-curry/pokebedrock-showdown/security/code-scanning/32)
github-advanced-security[bot] (Migrated from github.com) commented 2026-04-26 17:48:59 +00:00

CodeQL / Polynomial regular expression used on uncontrolled data

This regular expression that depends on library input may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'.
This regular expression that depends on library input may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'.
This regular expression that depends on library input may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'.

Show more details

## CodeQL / Polynomial regular expression used on uncontrolled data This [regular expression](1) that depends on [library input](2) may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'. This [regular expression](1) that depends on [library input](3) may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'. This [regular expression](1) that depends on [library input](4) may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'. [Show more details](https://github.com/smell-of-curry/pokebedrock-showdown/security/code-scanning/33)
@ -0,0 +1,929 @@
/**
github-advanced-security[bot] (Migrated from github.com) commented 2026-04-26 17:48:59 +00:00

CodeQL / Prototype-polluting assignment

This assignment may alter Object.prototype if a malicious 'proto' string is injected from library input.

Show more details

## CodeQL / Prototype-polluting assignment This assignment may alter Object.prototype if a malicious '__proto__' string is injected from [library input](1). [Show more details](https://github.com/smell-of-curry/pokebedrock-showdown/security/code-scanning/30)
@ -0,0 +500,4 @@
this.sides[event.pokemon.side].fainted += 1;
const slotIdx = event.pokemon.position;
if (isValidActivePosition(slotIdx) && this.active[event.pokemon.side][slotIdx] === mon.id) {
this.active[event.pokemon.side][slotIdx] = "";
github-advanced-security[bot] (Migrated from github.com) commented 2026-04-26 17:48:59 +00:00

CodeQL / Prototype-polluting assignment

This assignment may alter Object.prototype if a malicious 'proto' string is injected from library input.

Show more details

## CodeQL / Prototype-polluting assignment This assignment may alter Object.prototype if a malicious '__proto__' string is injected from [library input](1). [Show more details](https://github.com/smell-of-curry/pokebedrock-showdown/security/code-scanning/31)
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2026-04-26 17:51:58 +00:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull request overview

This PR introduces a new strategic AI framework with a unified PlayerAI host that routes decision-making to pluggable engines (random → heuristic → search), while replacing/removing the legacy RandomPlayerAI and StrongHeuristicsAI surfaces. It also adds a battle state tracker + protocol log parser so higher-tier engines can reason over hazards, revealed moves, boosts, items, and field state.

Changes:

  • Added PlayerAI host shell + engine interface (Engine, EngineContext) and difficulty policy mapping difficulty→engine/knobs.
  • Implemented multiple engines and mechanics modules (damage calc, move/switch evaluators, target picker, transform policy, opponent inference, one-ply and MCTS scaffolding).
  • Updated runners/examples to use PlayerAI (defaulting to random engine) and added tests for core strategic-AI modules.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
test/sim/tools/strategic-ai/switch-evaluator.js Unit tests for switch matchup scoring and hazard/boots interactions.
test/sim/tools/strategic-ai/log-parser.js Unit tests for protocol line parsing into normalized events.
test/sim/tools/strategic-ai/difficulty-gradient.js Smoke + deterministic “difficulty gradient” integration tests using PlayerAI.
test/sim/tools/strategic-ai/damage-calc.js Unit tests for strategic damage calc behavior (immunities, STAB, accuracy, weather, etc.).
sim/tools/strategic-player-ai.ts Removes legacy heuristics AI implementation.
sim/tools/strategic-ai/state/OpponentInference.ts Adds probabilistic inference for foe moves/abilities/items.
sim/tools/strategic-ai/state/LogParser.ts Adds permissive Showdown protocol log parser for strategic AI.
sim/tools/strategic-ai/state/BattleStateTracker.ts Adds tracker combining request + log events to maintain rich battle state.
sim/tools/strategic-ai/policy/DifficultyPolicy.ts Centralizes difficulty→engine selection and tuning knobs.
sim/tools/strategic-ai/mechanics/TransformPolicy.ts Chooses Mega/Z/Dynamax/Tera usage as move suffixes.
sim/tools/strategic-ai/mechanics/TargetPicker.ts Picks targets for doubles/triples using damage + redirection heuristics.
sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts Replaces legacy matchup logic with move-type + hazard-aware evaluation.
sim/tools/strategic-ai/mechanics/MoveEvaluator.ts Data-driven move scoring across categories (damage/status/hazards/pivot/etc.).
sim/tools/strategic-ai/index.ts Exposes the strategic-AI public surface (exports for engines/mechanics/state).
sim/tools/strategic-ai/engines/Engine.ts Defines engine contract + epsilon-noise helper.
sim/tools/strategic-ai/engines/RandomEngine.ts Random-play engine replacing old RandomPlayerAI.
sim/tools/strategic-ai/engines/LightHeuristicEngine.ts Lightweight heuristic engine intended to mimic legacy low-tier behavior.
sim/tools/strategic-ai/engines/HeuristicEngine.ts Main heuristic engine composing tracker + evaluators + policies.
sim/tools/strategic-ai/engines/OnePlySearchEngine.ts One-ply lookahead layered on heuristic evaluation.
sim/tools/strategic-ai/engines/MctsEngine.ts Budgeted MCTS-style sampler scaffolding.
sim/tools/player-ai.ts New unified PlayerAI host that plumbs logs into tracker and dispatches to engines.
sim/tools/runner.ts Updates default runner AI creation to PlayerAI(engine: 'random').
sim/tools/exhaustive-runner.ts Reworks exhaustive runner to coordinate via a custom RandomEngine subclass.
sim/tools/random-player-ai.ts Removes legacy random AI class.
sim/index.ts Updates sim exports to surface PlayerAI instead of legacy AIs.
sim/examples/battle-stream-example.ts Updates example to use PlayerAI(engine: 'random').
sim/battle-stream.ts Broadens BattlePlayer.receiveRequest return type to support returning a choice string.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull request overview This PR introduces a new strategic AI framework with a unified `PlayerAI` host that routes decision-making to pluggable engines (random → heuristic → search), while replacing/removing the legacy `RandomPlayerAI` and `StrongHeuristicsAI` surfaces. It also adds a battle state tracker + protocol log parser so higher-tier engines can reason over hazards, revealed moves, boosts, items, and field state. **Changes:** - Added `PlayerAI` host shell + engine interface (`Engine`, `EngineContext`) and difficulty policy mapping difficulty→engine/knobs. - Implemented multiple engines and mechanics modules (damage calc, move/switch evaluators, target picker, transform policy, opponent inference, one-ply and MCTS scaffolding). - Updated runners/examples to use `PlayerAI` (defaulting to random engine) and added tests for core strategic-AI modules. ### Reviewed changes Copilot reviewed 28 out of 28 changed files in this pull request and generated 7 comments. <details> <summary>Show a summary per file</summary> | File | Description | | ---- | ----------- | | test/sim/tools/strategic-ai/switch-evaluator.js | Unit tests for switch matchup scoring and hazard/boots interactions. | | test/sim/tools/strategic-ai/log-parser.js | Unit tests for protocol line parsing into normalized events. | | test/sim/tools/strategic-ai/difficulty-gradient.js | Smoke + deterministic “difficulty gradient” integration tests using `PlayerAI`. | | test/sim/tools/strategic-ai/damage-calc.js | Unit tests for strategic damage calc behavior (immunities, STAB, accuracy, weather, etc.). | | sim/tools/strategic-player-ai.ts | Removes legacy heuristics AI implementation. | | sim/tools/strategic-ai/state/OpponentInference.ts | Adds probabilistic inference for foe moves/abilities/items. | | sim/tools/strategic-ai/state/LogParser.ts | Adds permissive Showdown protocol log parser for strategic AI. | | sim/tools/strategic-ai/state/BattleStateTracker.ts | Adds tracker combining `request` + log events to maintain rich battle state. | | sim/tools/strategic-ai/policy/DifficultyPolicy.ts | Centralizes difficulty→engine selection and tuning knobs. | | sim/tools/strategic-ai/mechanics/TransformPolicy.ts | Chooses Mega/Z/Dynamax/Tera usage as move suffixes. | | sim/tools/strategic-ai/mechanics/TargetPicker.ts | Picks targets for doubles/triples using damage + redirection heuristics. | | sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts | Replaces legacy matchup logic with move-type + hazard-aware evaluation. | | sim/tools/strategic-ai/mechanics/MoveEvaluator.ts | Data-driven move scoring across categories (damage/status/hazards/pivot/etc.). | | sim/tools/strategic-ai/index.ts | Exposes the strategic-AI public surface (exports for engines/mechanics/state). | | sim/tools/strategic-ai/engines/Engine.ts | Defines engine contract + epsilon-noise helper. | | sim/tools/strategic-ai/engines/RandomEngine.ts | Random-play engine replacing old `RandomPlayerAI`. | | sim/tools/strategic-ai/engines/LightHeuristicEngine.ts | Lightweight heuristic engine intended to mimic legacy low-tier behavior. | | sim/tools/strategic-ai/engines/HeuristicEngine.ts | Main heuristic engine composing tracker + evaluators + policies. | | sim/tools/strategic-ai/engines/OnePlySearchEngine.ts | One-ply lookahead layered on heuristic evaluation. | | sim/tools/strategic-ai/engines/MctsEngine.ts | Budgeted MCTS-style sampler scaffolding. | | sim/tools/player-ai.ts | New unified `PlayerAI` host that plumbs logs into tracker and dispatches to engines. | | sim/tools/runner.ts | Updates default runner AI creation to `PlayerAI(engine: 'random')`. | | sim/tools/exhaustive-runner.ts | Reworks exhaustive runner to coordinate via a custom `RandomEngine` subclass. | | sim/tools/random-player-ai.ts | Removes legacy random AI class. | | sim/index.ts | Updates sim exports to surface `PlayerAI` instead of legacy AIs. | | sim/examples/battle-stream-example.ts | Updates example to use `PlayerAI(engine: 'random')`. | | sim/battle-stream.ts | Broadens `BattlePlayer.receiveRequest` return type to support returning a choice string. | </details> --- 💡 <a href="/smell-of-curry/pokebedrock-showdown/new/master?filename=.github/instructions/*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
@ -0,0 +1,234 @@
/**
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 17:51:57 +00:00

receiveError stores disabled moves under a ${side}|${name} key, but the engines key disabledMovesByMon by the monId returned from monIdForSlot (which prefers req.uuid). Since PokemonSwitchRequestData.uuid is present, disabled moves will never be filtered out, causing the AI to potentially repeat invalid choices. Consider resolving the UUID from the latest request/tracker and storing the disabled move under that UUID key (optionally also under the ${side}|${name} fallback).

`receiveError` stores disabled moves under a `${side}|${name}` key, but the engines key `disabledMovesByMon` by the monId returned from `monIdForSlot` (which prefers `req.uuid`). Since `PokemonSwitchRequestData.uuid` is present, disabled moves will never be filtered out, causing the AI to potentially repeat invalid choices. Consider resolving the UUID from the latest request/tracker and storing the disabled move under that UUID key (optionally also under the `${side}|${name}` fallback).
@ -0,0 +1,226 @@
/**
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 17:51:57 +00:00

This doc comment includes an absolute local filesystem path (D:\bedrock-server\...) inside a markdown link. That won’t resolve for other contributors and is likely to end up stale. Prefer a repo-relative link (or plain file reference like sim/battle.ts:L327-L333) instead.

 *    `sim/battle.ts:L327-L333`)
This doc comment includes an absolute local filesystem path (`D:\bedrock-server\...`) inside a markdown link. That won’t resolve for other contributors and is likely to end up stale. Prefer a repo-relative link (or plain file reference like `sim/battle.ts:L327-L333`) instead. ```suggestion * `sim/battle.ts:L327-L333`) ```
@ -0,0 +111,4 @@
if (chosenSlots.includes(benchSlot)) return false;
const fainted = benchPoke.condition.endsWith(" fnt");
if (fainted && !benchPoke.reviving) return false;
return true;
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 17:51:58 +00:00

forceSwitch filters out benchSlot <= mustSwitchFlags.length instead of excluding currently-active Pokémon via benchPoke.active. Since Side.getRequestData() preserves team order (active mons are not guaranteed to be the first N entries), this can both (a) wrongly exclude valid switch-ins (e.g. slot 1) and (b) allow switching to an active slot if the active mon isn't in the first N positions. Use benchPoke.active (and chosenSlots) to filter actives rather than relying on list position.

`forceSwitch` filters out `benchSlot <= mustSwitchFlags.length` instead of excluding currently-active Pokémon via `benchPoke.active`. Since `Side.getRequestData()` preserves team order (active mons are not guaranteed to be the first N entries), this can both (a) wrongly exclude valid switch-ins (e.g. slot 1) and (b) allow switching to an active slot if the active mon isn't in the first N positions. Use `benchPoke.active` (and `chosenSlots`) to filter actives rather than relying on list position.
@ -0,0 +1,929 @@
/**
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 17:51:56 +00:00

hazardDamageFraction's Spikes grounding check ignores field-wide grounding effects like Gravity. Under Gravity, Flying/Levitate/Air Balloon mons should be treated as grounded for Spikes damage, but this code will still exempt them. Consider incorporating this.field.gravity into the grounded calculation (or reusing isGrounded logic where feasible).

`hazardDamageFraction`'s Spikes grounding check ignores field-wide grounding effects like Gravity. Under Gravity, Flying/Levitate/Air Balloon mons should be treated as grounded for Spikes damage, but this code will still exempt them. Consider incorporating `this.field.gravity` into the `grounded` calculation (or reusing `isGrounded` logic where feasible).
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 17:51:57 +00:00

Typo in comment: "faiunted" → "fainted".

	/** True when the mon has been fainted at least once. */
Typo in comment: "faiunted" → "fainted". ```suggestion /** True when the mon has been fainted at least once. */ ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 17:51:57 +00:00

The enditem handler has a conditional intended to treat item swaps differently, but both branches set mon.item = "" and the comment suggests behavior that isn’t implemented. This is misleading and can be simplified; if special-casing is needed for certain event.from values, implement it explicitly, otherwise remove the dead conditional/comment.

				mon.item = "";
The `enditem` handler has a conditional intended to treat item swaps differently, but both branches set `mon.item = ""` and the comment suggests behavior that isn’t implemented. This is misleading and can be simplified; if special-casing is needed for certain `event.from` values, implement it explicitly, otherwise remove the dead conditional/comment. ```suggestion mon.item = ""; ```
@ -0,0 +1,268 @@
/**
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 17:51:57 +00:00

The module header comment says unrevealed moves are sampled from the species' learnset "intersected with the format's legal moves", but legalMoves() currently just returns all learnset moves it finds in Dex.data.Learnsets and doesn't take a format/ruleset into account. Either implement the format-legal intersection (by passing format/rule context into inference) or adjust the comment to match current behavior to avoid overstating inference correctness.

 *   the rest, we sample a uniform prior over the species' learnset as
 *   available in `Dex.data.Learnsets`. This module does not currently
 *   intersect that learnset with the active format's legal move pool.
The module header comment says unrevealed moves are sampled from the species' learnset "intersected with the format's legal moves", but `legalMoves()` currently just returns all learnset moves it finds in `Dex.data.Learnsets` and doesn't take a format/ruleset into account. Either implement the format-legal intersection (by passing format/rule context into inference) or adjust the comment to match current behavior to avoid overstating inference correctness. ```suggestion * the rest, we sample a uniform prior over the species' learnset as * available in `Dex.data.Learnsets`. This module does not currently * intersect that learnset with the active format's legal move pool. ```
coderabbitai[bot] (Migrated from github.com) reviewed 2026-04-26 18:03:47 +00:00
coderabbitai[bot] (Migrated from github.com) left a comment

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
sim/battle-stream.ts (1)

423-433: ⚠️ Potential issue | 🟠 Major

Documented return-string contract isn't honored by base receiveLine.

The comment on lines 428–432 says implementers may "return the choice string for the caller to dispatch." However, receiveLine (line 423) only returns the value up to receive (line 413–417), which discards it. So a subclass that returns a string without calling this.choose will have its command silently dropped.

In practice, PlayerAI avoids this by overriding receiveLine entirely and dispatching via this.choose internally. But the documented contract is misleading for future subclasses. Two options:

♻️ Option A — make the base class actually dispatch returned strings
 	receiveLine(line: string) {
 		if (this.debug) console.log(line);
 		if (!line.startsWith('|')) return;
 		const [cmd, rest] = splitFirst(line.slice(1), '|');
-		if (cmd === 'request') return this.receiveRequest(JSON.parse(rest));
+		if (cmd === 'request') {
+			const choice = this.receiveRequest(JSON.parse(rest));
+			if (typeof choice === 'string' && choice !== '') this.choose(choice);
+			return;
+		}
 		if (cmd === 'error') return this.receiveError(new Error(rest));
 		this.log.push(line);
 	}

Option B — keep the return type as void and document that subclasses must override receiveLine or call this.choose themselves.

The type signature should match what the base pipeline actually does.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/battle-stream.ts` around lines 423 - 433, The base receiveLine currently
discards any string returned by receiveRequest, breaking the documented
contract; update receiveLine to capture the return value from
receiveRequest(request) and, if it's a non-empty string, call
this.choose(returnedString) so returned choices are dispatched by the base
pipeline (referencing receiveLine, receiveRequest, and this.choose), and update
the comment/type if you prefer the alternate behavior; ensure PlayerAI remains
compatible.
🟡 Minor comments (16)
sim/tools/strategic-ai/mechanics/MoveEvaluator.ts-71-75 (1)

71-75: ⚠️ Potential issue | 🟡 Minor

Order the null guard before the property access.

m.id is dereferenced on line 72 before the !m guard on line 73, so the guard can never actually catch a null/undefined m. In practice Dex.moves.get always returns an object, but if a caller ever passes null/undefined directly (the Move arg has no runtime type-narrowing) this throws on line 72 instead of returning the intended unknown sentinel.

 	const m = typeof move === "string" ? Dex.moves.get(move) : move;
-	const moveId = toID(m.id || (m as { name?: string }).name || "");
-	if (!m || !moveId) {
-		return { moveId, score: -Infinity, rationale: "unknown" };
+	if (!m) {
+		return { moveId: "", score: -Infinity, rationale: "unknown" };
 	}
+	const moveId = toID(m.id || (m as { name?: string }).name || "");
+	if (!moveId) {
+		return { moveId, score: -Infinity, rationale: "unknown" };
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 71 - 75, The
null-check for the resolved move must happen before accessing its properties:
ensure you verify m is truthy (the result of Dex.moves.get(move) or the
passed-in move) prior to using m.id when computing moveId in MoveEvaluator.ts;
update the logic around Dex.moves.get, the m variable, and the toID(...) call so
that if m is null/undefined you immediately return the sentinel ({ moveId,
score: -Infinity, rationale: "unknown" }) instead of dereferencing m.id — e.g.,
perform the !m guard before calling toID or restructure the moveId expression to
only access m.id/name when m is non-null.
sim/index.ts-18-18 (1)

18-18: ⚠️ Potential issue | 🟡 Minor

Public-API surface change is a breaking change for downstream importers.

Removing RandomPlayerAI / StrongHeuristicsAI from the published sim entrypoint is a breaking change for any external consumer of pokemon-showdown that imports these symbols. The PR description does cover the migration (PlayerAI with engine: "random"); just make sure this is reflected in the package's CHANGELOG / release notes so callers updating across the boundary aren't caught by silent runtime undefined.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/index.ts` at line 18, The public API removed RandomPlayerAI and
StrongHeuristicsAI from the sim entrypoint which is a breaking change; restore
compatibility by either re-exporting those symbols as deprecated aliases that
forward to PlayerAI (e.g., RandomPlayerAI -> PlayerAI with engine: "random",
StrongHeuristicsAI -> PlayerAI with engine: "strongHeuristics") or add an
explicit migration note in the package CHANGELOG/release notes mapping
RandomPlayerAI and StrongHeuristicsAI to PlayerAI (engine:
"random"/"strongHeuristics") and warn consumers about the removal, and ensure
the export line for PlayerAI in sim/index.ts is updated accordingly to include
those compatibility exports or document their removal.
test/sim/tools/strategic-ai/difficulty-gradient.js-122-123 (1)

122-123: ⚠️ Potential issue | 🟡 Minor

Win-rate threshold message is off-by-one vs the actual computed bound.

Math.ceil(N * 0.55) with N = 10 is 6, i.e. the assertion enforces ≥60%, but both the it(...) title and the failure message say "55%" / ">=55%". Either bump the threshold down, or fix the wording so a future reader debugging a flake doesn't have to recompute the bound.

📝 Suggested wording fix
-		assert(result.p1Wins >= Math.ceil(N * 0.55),
-			`difficulty 3 should beat difficulty 1 in >=55% of ${N} games (got p1=${result.p1Wins} p2=${result.p2Wins} draws=${result.draws})`);
+		assert(result.p1Wins >= Math.ceil(N * 0.55),
+			`difficulty 3 should win >= ceil(${N} * 0.55) games vs difficulty 1 ` +
+			`(got p1=${result.p1Wins} p2=${result.p2Wins} draws=${result.draws})`);

(and the same for the difficulty-5-vs-2 case.)

Also applies to: 135-136

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sim/tools/strategic-ai/difficulty-gradient.js` around lines 122 - 123,
The assertion uses Math.ceil(N * 0.55) (with N=10 this yields 6) but the test
title and failure message say ">=55%", which is misleading; define a threshold
variable like threshold = Math.ceil(N * 0.55) and use that numeric threshold in
the assertion message (e.g. `>= ${threshold} of ${N} games
(${Math.round(threshold/N*100)}%)`) so the text matches the actual bound checked
for result.p1Wins, update the corresponding assertion and message that uses
Math.ceil(N * 0.55) (and do the same fix for the difficulty-5-vs-2 assertion at
the other case).
sim/tools/strategic-ai/engines/MctsEngine.ts-106-118 (1)

106-118: ⚠️ Potential issue | 🟡 Minor

Mixing rollout reward and raw heuristic score in the final argmax.

meanReward is in the rollout units (damageFraction*100 plus KO bonuses, range roughly 0–~150), while the fallback for zero-visit candidates is heuristicScores[i].heuristic from evaluateMove, which has its own unit scale (often hundreds). On a tiny searchBudgetMs (or when totalVisits is small), unrolled candidates can outrank visited ones purely because of unit mismatch, not policy quality.

Either (a) softmax/normalize both onto a shared scale before merging, or (b) for unvisited nodes synthesise a 1-visit rollout instead of falling back to the raw heuristic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/MctsEngine.ts` around lines 106 - 118, The
final scoring mixes rollout units (meanReward from n.totalReward/n.visits in
damageFraction*100 + KO bonuses) with raw evaluateMove heuristic units
(heuristicScores[i].heuristic), causing unvisited candidates to be wrongly
preferred; in the nodes.map block (refer to nodes, n.visits, n.totalReward,
meanReward, heuristicScores and evaluateMove) replace the raw heuristic fallback
with a synthesized 1-visit rollout value: convert heuristicScores[i].heuristic
into the same rollout reward scale (apply the same damageFraction*100 and KO
bonus conventions used elsewhere) and use that as the meanReward for zero-visit
nodes (or equivalently treat it as totalReward with visits=1) so all candidates
are compared on the same unit.
test/sim/tools/strategic-ai/damage-calc.js-139-147 (1)

139-147: ⚠️ Potential issue | 🟡 Minor

hpFraction is ignored by calculateDamage, so this test doesn't exercise low-HP KOs.

Per the CalcPokemon interface, there is no hpFraction field — the calc estimates defenderMaxHp from species stats and computes koProbability against full HP. Setting hpFraction: 0.05 here has no effect, and koProbability for Garchomp +6 vs Blissey at full HP is essentially trivial. The assertion only checks bounds, so the test is effectively a tautology. Either thread current HP into the calc input (and update CalcPokemon/calc accordingly) or drop the misleading hpFraction argument and pick a scenario where koProbability actually moves.

sim/tools/strategic-ai/policy/DifficultyPolicy.ts-100-108 (1)

100-108: ⚠️ Potential issue | 🟡 Minor

applyKnobs leaks stale searchBudgetMs across difficulty changes.

Because the assignment is gated on if (knobs.searchBudgetMs), applying tier-5 knobs (200ms) to a context and then re-applying tier-1 knobs (no searchBudgetMs) leaves the old 200ms budget in place. The doc says "idempotent" but it's actually only monotone-by-tier. If difficulty is fixed at construction this is harmless, but it's a footgun for tooling/tests that reuse a context.

🔧 Always reset `searchBudgetMs`
 export function applyKnobs(ctx: EngineContext, difficulty: number): void {
 	const knobs = knobsForDifficulty(difficulty);
 	ctx.noiseEpsilon = knobs.noiseEpsilon;
 	ctx.infoForgetting = knobs.infoForgetting;
-	if (knobs.searchBudgetMs) ctx.searchBudgetMs = knobs.searchBudgetMs;
+	ctx.searchBudgetMs = knobs.searchBudgetMs;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/policy/DifficultyPolicy.ts` around lines 100 - 108,
applyKnobs currently only assigns ctx.searchBudgetMs when
knobsForDifficulty(difficulty) returns a truthy value, causing a stale
searchBudgetMs to persist when re-applying a difficulty that omits the field;
change applyKnobs to always set ctx.searchBudgetMs to the knob value (or clear
it when absent) instead of guarding with if, e.g., unconditionally assign
ctx.searchBudgetMs = knobs.searchBudgetMs (or ctx.searchBudgetMs = undefined
when knobs.searchBudgetMs is undefined) so re-applying different difficulties is
idempotent; see applyKnobs, knobsForDifficulty, and ctx.searchBudgetMs.
sim/tools/strategic-ai/policy/DifficultyPolicy.ts-9-20 (1)

9-20: ⚠️ Potential issue | 🟡 Minor

Stale "(TBD)" rows in the docstring table.

Difficulties 4 and 5 in the comment table say OnePly (TBD) and MCTS (TBD), but pickEngine already instantiates OnePlySearchEngine and MctsEngine directly. Update the table so consumers don't think those tiers fall back to the heuristic engine.

📝 Docstring fix
- * | 4          | OnePly (TBD)  | 0.05          | 0.10            |
- * | 5          | MCTS (TBD)    | 0.00          | 0.00            |
+ * | 4          | OnePlySearch  | 0.05          | 0.10            |
+ * | 5          | MCTS          | 0.00          | 0.00            |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/policy/DifficultyPolicy.ts` around lines 9 - 20,
Update the docstring table in DifficultyPolicy.ts to remove the "(TBD)" labels
for tiers 4 and 5 so it reflects the actual engines instantiated by pickEngine
(i.e., OnePlySearchEngine for difficulty 4 and MctsEngine for difficulty 5);
specifically edit the table rows referencing "OnePly (TBD)" and "MCTS (TBD)" to
"OnePly" and "MCTS" (or "MctsEngine") so the comment matches the behavior of the
pickEngine function and the referenced classes OnePlySearchEngine and
MctsEngine.
test/sim/tools/strategic-ai/damage-calc.js-95-112 (1)

95-112: ⚠️ Potential issue | 🟡 Minor

EQ-vs-Skarmory comparison is trivialized by Flying immunity.

Skarmory is Steel/Flying, so Earthquake hits for 0 damage. That makes fireFraction === 0 and the assertion groundFraction > fireFraction * 1.5 reduces to groundFraction > 0, which is already implied by the first assertion. The variable name fire/fireFraction also doesn't match the species. To actually exercise the "super-effective vs neutral/resisted" comparison the test name implies, pick a non-Flying defender (e.g. Magnezone for 2× resist, or a neutral Normal-type).

🔧 Suggested rename + neutral target
-		const fire = calc({
+		const neutral = calc({
 			attacker: mkMon('Garchomp'),
-			defender: mkMon('Skarmory'),
+			defender: mkMon('Magnezone'),
 			move: 'earthquake',
 		});
 		assert(!ground.immune);
 		const groundFraction = ground.avgDamage / ground.defenderMaxHp;
-		const fireFraction = fire.avgDamage / fire.defenderMaxHp;
+		const neutralFraction = neutral.avgDamage / neutral.defenderMaxHp;
 		assert(groundFraction > 0.4, `Earthquake vs Heatran should deal heavy damage; got ${groundFraction.toFixed(2)}`);
-		assert(groundFraction > fireFraction * 1.5,
-			`Heatran should take more from EQ than Skarmory does (got ${groundFraction.toFixed(2)} vs ${fireFraction.toFixed(2)})`);
+		assert(groundFraction > neutralFraction * 1.5,
+			`4× target should take noticeably more than a 2× target (got ${groundFraction.toFixed(2)} vs ${neutralFraction.toFixed(2)})`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sim/tools/strategic-ai/damage-calc.js` around lines 95 - 112, The test
compares Earthquake on Heatran to Earthquake on Skarmory, but Skarmory's Flying
type makes EQ do 0 damage, trivializing the second assertion and the misleading
variable name `fire`; change the second defender from mkMon('Skarmory') to a
non-Flying neutral target (e.g. mkMon('Snorlax')), rename the `fire` and
`fireFraction` variables to `neutral` and `neutralFraction`, and update the
assertion message to reflect the neutral target so the comparison between
STAB+super-effective (ground) and neutral damage is meaningful.
sim/tools/strategic-ai/engines/MctsEngine.ts-122-143 (1)

122-143: ⚠️ Potential issue | 🟡 Minor

Warm-up is sequential, not round-robin (comment misleading).

The loop returns the first node with visits < MIN_VISITS_PER_CANDIDATE, so nodes[0] is rolled out 4 times in a row before nodes[1] is touched. The comment claims this avoids domination by an early-lucky reward, but a round-robin would actually require picking the least-visited node. As-is, on small budgets you risk burning the entire budget on nodes[0] and never visiting later candidates at all.

🔧 Pick the least-visited candidate during warm-up
-	for (const n of nodes) {
-		// Until each candidate has been explored a minimum number of
-		// times we round-robin so the search isn't dominated by an
-		// early-lucky high-variance reward.
-		if (n.visits < MIN_VISITS_PER_CANDIDATE) {
-			return n;
-		}
-		const q = n.totalReward / n.visits;
-		...
-	}
+	let leastVisited: CandidateNode | null = null;
+	for (const n of nodes) {
+		if (n.visits < MIN_VISITS_PER_CANDIDATE) {
+			if (!leastVisited || n.visits < leastVisited.visits) leastVisited = n;
+		}
+	}
+	if (leastVisited) return leastVisited;
+	for (const n of nodes) {
+		const q = n.totalReward / n.visits;
+		...
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/MctsEngine.ts` around lines 122 - 143, The
comment is misleading because selectByPUCT currently returns the first node with
visits < MIN_VISITS_PER_CANDIDATE, causing sequential warm-up of nodes[0]
instead of a round-robin; change the warm-up logic in selectByPUCT so it selects
the least-visited candidate (i.e., scan nodes to find the node with the minimal
visits that is still < MIN_VISITS_PER_CANDIDATE and return that) rather than
returning the first such node—use MIN_VISITS_PER_CANDIDATE and nodes[].visits as
the unique symbols to locate and update the code.
sim/tools/strategic-ai/state/LogParser.ts-198-215 (1)

198-215: ⚠️ Potential issue | 🟡 Minor

Minor: forced is hardcoded to false for switches.

The BattleEvent union exposes a forced flag on switch, but it's never populated. Showdown emits [from] move: U-turn / [from] move: Volt Switch / [from] item: Eject Button etc. on forced switches, and downstream consumers may want to distinguish those from voluntary switches (e.g., for opponent-pivot inference / Choice-lock heuristics).

Suggested patch
 case "switch":
 case "drag": {
     const ref = parsePokemonRef(args[0]);
     if (!ref) return null;
     const [hp, ...statusParts] = (args[2] || "").split(" ");
     const status = statusParts.join(" ").trim();
     if (kind === "drag") {
         return { kind: "drag", pokemon: ref, details: args[1] || "", hp, status };
     }
+    const fromKwarg = extractKwarg(args, "from");
     return {
         kind: "switch",
         pokemon: ref,
         details: args[1] || "",
         hp,
         status,
-        forced: false,
+        forced: !!fromKwarg,
     };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/LogParser.ts` around lines 198 - 215, The switch
handler in LogParser.ts currently hardcodes forced: false for switch events, so
the BattleEvent.union's forced flag is never populated; update the switch
parsing branch (the case "switch" within the switch/drag block that uses
parsePokemonRef and builds the returned object) to detect Showdown "forced"
indicators emitted earlier in the log (e.g., lines like "[from] move: U-turn",
"[from] move: Volt Switch", or "[from] item: Eject Button") and set forced: true
when appropriate (or derive it from the args if the switch log entry includes a
flag), otherwise keep false, ensuring the returned object for kind "switch"
populates the forced property correctly for downstream consumers.
sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts-241-251 (1)

241-251: ⚠️ Potential issue | 🟡 Minor

Swift Swim doubles speed unconditionally — should require rain.

scaledSpeed applies swiftswim spe *= 2 regardless of tracker.field.weather. Swift Swim only triggers under raindance / primordialsea, so the AI will overrate rain abusers (Kingdra, Barraskewda, Greninja w/ Battle Bond, etc.) in every neutral-weather matchup, biasing both evaluateMatchup speed scoring and switch picks.

Same concern would apply to Chlorophyll/Sand Rush/Slush Rush/Surge Surfer if you choose to model those — at minimum gating Swift Swim on weather is the safe baseline.

Suggested patch
-function scaledSpeed(mon: TrackedPokemon, tailwind: boolean): number {
+function scaledSpeed(
+    mon: TrackedPokemon,
+    tailwind: boolean,
+    weather: string,
+): number {
   let spe = mon.stats?.spe ?? approximateSpeed(mon);
   const stage = mon.boosts.spe || 0;
   if (stage > 0) spe = Math.floor(spe * (2 + stage) / 2);
   else if (stage < 0) spe = Math.floor(spe * 2 / (2 - stage));
   if (toID(mon.item) === "choicescarf") spe = Math.floor(spe * 1.5);
-  if (toID(mon.ability) === "swiftswim") spe *= 2;
+  if (toID(mon.ability) === "swiftswim" &&
+      (weather === "raindance" || weather === "primordialsea")) {
+    spe *= 2;
+  }
   if (tailwind) spe *= 2;
   if (mon.status === "par") spe = Math.floor(spe / 2);
   return spe;
 }

…and pass tracker.field.weather from computeSpeedDelta.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` around lines 241 - 251,
scaledSpeed currently doubles speed for Swift Swim unconditionally; update it to
only apply the swiftswim multiplier when the field weather indicates rain.
Modify scaledSpeed to check the provided weather (e.g. require weather ===
"raindance" || weather === "primordialsea") before applying the swiftswim
branch, and propagate that weather into scaledSpeed by updating
computeSpeedDelta (and any callers) to pass tracker.field.weather (or a weather
string) alongside the existing tailwind parameter so Swift Swim is gated on
actual rain.
sim/tools/player-ai.ts-130-142 (1)

130-142: ⚠️ Potential issue | 🟡 Minor

Falling through to super.receiveLine on bad request JSON will re-throw.

super.receiveLine for |request| does its own JSON.parse(rest) (see sim/battle-stream.ts lines 419–426), which means the catch here is effectively a no-op: super hits the same parse error and throws. If the intent of the catch is to be tolerant, just return:

Suggested patch
-      try {
-        request = JSON.parse(rest);
-      } catch {
-        super.receiveLine(line);
-        return;
-      }
+      try {
+        request = JSON.parse(rest);
+      } catch {
+        // Malformed request payload — ignore rather than crash.
+        return;
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/player-ai.ts` around lines 130 - 142, The try/catch around
JSON.parse in the receiveLine handler for "|request|" is catching parse errors
but then calls super.receiveLine(line), which will re-parsed and re-throw the
same error; update the catch block in the receiveLine implementation so that on
JSON.parse failure it does not delegate to super.receiveLine but simply returns
(silently ignore the bad request), leaving the rest of the logic (use of
receiveRequest and choose) unchanged—look for the receiveLine method handling
lines starting with "|request|" in sim/tools/player-ai.ts and replace the
super.receiveLine(line) call in the catch with a plain return.
sim/tools/strategic-ai/engines/HeuristicEngine.ts-242-246 (1)

242-246: ⚠️ Potential issue | 🟡 Minor

Noise pool filter fails when the best move has a negative score.

After descending sort, pick.score is the maximum. For non-negative scores, pick.score * 0.5 creates a reasonable lower bound on "near-best" candidates. However, evaluateMove can return negative scores (e.g., recovery moves at high HP return –10, status moves on immune types return –20, moves without viable targets return negative penalties).

When pick.score < 0, the arithmetic reverses: pick.score * 0.5 becomes greater than pick.score (less negative). The filter then excludes pick itself, and if no other evaluated options exceed this threshold, noisedPool becomes empty. applyNoise detects candidates.length ≤ 1 and returns bestPick unchanged, silently disabling epsilon noise at the worst time—when the mon faces only bad options and exploration could help.

Suggested fix (uses absolute span around pick.score)
-      const noisedPool = scored.filter(s => s.score >= pick.score * 0.5);
+      // Half-best window, robust to negative scores.
+      const span = Math.max(1e-6, Math.abs(pick.score));
+      const cutoff = pick.score - 0.5 * span;
+      const noisedPool = scored.filter(s => s.score >= cutoff);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/HeuristicEngine.ts` around lines 242 - 246,
The current noise pool filter uses scored.filter(s => s.score >= pick.score *
0.5) which breaks when pick.score is negative and can exclude the best move;
replace that logic with an absolute-span filter around pick.score so candidates
whose score is within a symmetric range are kept. Compute a span (e.g., span =
Math.abs(pick.score) * 0.5 or a small constant when pick.score is zero) and
select noisedPool as scored.filter(s => Math.abs(s.score - pick.score) <= span),
ensuring pick remains in noisedPool before calling applyNoise(pick, noisedPool,
ctx.noiseEpsilon, ctx.prng) and preserving the ctx.lastMoveByMon update.
sim/tools/strategic-ai/state/BattleStateTracker.ts-652-661 (1)

652-661: ⚠️ Potential issue | 🟡 Minor

enditem has dead branches — comment and code disagree.

Both branches assign mon.item = "", so the if/else is dead code. The comment ("Don't stomp item if the message indicates a swap (from move:Trick etc).") suggests intent to skip clearing on Trick/Switcheroo/etc., but the implementation does the opposite. Either the comment is stale and the branching should be removed, or the swap-detection branch should preserve the item id.

For protocol fidelity, |-enditem| does mean the mon no longer holds that item (regardless of cause), so unconditional clearing is correct — but please remove the misleading branch and comment.

Suggested simplification
 			case "enditem": {
 				const mon = this.resolveByRef(event.pokemon);
-				// Don't stomp item if the message indicates a swap (`from move:Trick` etc).
-				if (!event.from || /^(?:move:|ability:)/i.test(event.from)) {
-					mon.item = "";
-				} else {
-					mon.item = "";
-				}
+				mon.item = "";
 				return;
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 652 - 661,
The case "enditem" branch in BattleStateTracker currently has a misleading
comment and dead if/else that both set mon.item = ""; change the handler for
case "enditem" (the block that calls this.resolveByRef(event.pokemon)) to remove
the swap-detection comment and the conditional entirely and simply set mon.item
= "" unconditionally (preserve the existing return); this keeps protocol-correct
behavior while removing the confusing dead branch.
sim/tools/strategic-ai/state/BattleStateTracker.ts-465-477 (1)

465-477: ⚠️ Potential issue | 🟡 Minor

Possible double-count of sides[...].fainted on replay.

this.sides[event.pokemon.side].fainted += 1; runs on every |faint| without checking mon.fainted. If a |faint| is ever replayed (e.g. tracker re-initialized over a buffered log), the side's fainted counter will overshoot the team size, which downstream KO-pressure heuristics may consult.

Proposed guard
 			case "faint": {
 				const mon = this.resolveByRef(event.pokemon);
+				if (mon.fainted) return;
 				mon.fainted = true;
 				mon.active = false;
 				mon.hpFraction = 0;
 				mon.condition = "0 fnt";
 				this.sides[event.pokemon.side].fainted += 1;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 465 - 477,
The faint handler in BattleStateTracker increments this.sides[...].fainted
unconditionally, risking double-counting on replay; update the case "faint"
branch (the code that calls this.resolveByRef(event.pokemon) and sets
mon.fainted = true) to first check if mon.fainted is already true and only
increment this.sides[event.pokemon.side].fainted when mon.fainted was previously
false, then proceed to set mon.fainted = true and update
active/hpFraction/condition as before.
sim/tools/strategic-ai/state/BattleStateTracker.ts-417-436 (1)

417-436: ⚠️ Potential issue | 🟡 Minor

Choice-lock inference produces false positives for multi-turn locks and non-Choice items.

mon.choiceLocked is set to true on any 2-in-a-row same move from the foe and is never cleared except on switch. This will misclassify Outrage/Petal Dance/Solar Beam/Encore-locked users, and any mon simply spamming its strongest move when its item is already revealed to be non-Choice (e.g. Leftovers, Life Orb).

Consider gating on whether the item is unknown or actually a Choice band/specs/scarf, and clearing the flag when the streak breaks (e.g. on move to a different move id or on cant).

Proposed gating
-					if (event.user.side === this.foeSide && mon.sameMoveStreak >= 2) {
-						mon.choiceLocked = true;
-					}
+					if (event.user.side === this.foeSide && mon.sameMoveStreak >= 2) {
+						const item = mon.item;
+						const consistentWithChoice = !item ||
+							item === "choiceband" || item === "choicescarf" || item === "choicespecs";
+						if (consistentWithChoice) mon.choiceLocked = true;
+					}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 417 - 436,
The current choice-lock logic in the "move" case of BattleStateTracker sets
mon.choiceLocked whenever a foe uses the same move twice (mon.sameMoveStreak >=
2) which yields false positives for multi-turn moves and when the item is known
non-Choice; update the logic in the move handler (resolveByRef, mon.lastMove,
mon.sameMoveStreak, mon.choiceLocked) to only set mon.choiceLocked if the mon's
held item is unknown or explicitly a Choice item (check against a ChoiceItems
set), and ensure you clear mon.choiceLocked when the streak breaks (on a
different move) or when a "cant" event occurs; also avoid setting choiceLocked
for known multi-turn moves (e.g., Outrage/Petal Dance/Solar Beam/Encore) by
checking moveId against a MultiTurnMoves set before inferring choice-lock.
🧹 Nitpick comments (14)
sim/tools/exhaustive-runner.ts (1)

84-90: engine: 'random' is redundant when engineInstance is supplied.

Per PlayerAI's constructor, options.engineInstance ?? pickEngine(...) means engineInstance already wins; the engine: 'random' field is inert here and can be dropped to avoid the appearance of a meaningful choice.

♻️ Proposed cleanup
 		const createAI = (s: ObjectReadWriteStream<string>, o: AIOptions) => new PlayerAI(s, {
-			engine: 'random',
 			engineInstance: new CoordinatedRandomEngine(pools),
 			seed: o.seed,
 			randomMoveProb: o.move,
 			randomMegaProb: o.mega,
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/exhaustive-runner.ts` around lines 84 - 90, The object passed to
new PlayerAI in createAI includes a redundant engine: 'random' property because
PlayerAI prefers options.engineInstance (see options.engineInstance ??
pickEngine(...)); remove the inert engine: 'random' field from the options
object in the createAI function so only engineInstance: new
CoordinatedRandomEngine(pools), seed: o.seed, randomMoveProb: o.move,
randomMegaProb: o.mega remain.
test/sim/tools/strategic-ai/difficulty-gradient.js (1)

51-67: playGame mishandles ties / 4-player names and won't terminate cleanly on draws.

A few small robustness concerns in this helper that future tests will inherit:

  • name === 'P1' ? 'p1' : name === 'P2' ? 'p2' : name.toLowerCase() silently falls through to a lowercased side name on any non-P1/P2 input, so a |win|Bot 3 line would yield 'bot 3' instead of 'p3'. Today only P1/P2 are used, but this trips up anyone copy-pasting the helper for a 4-player test.
  • The for-await loop keeps draining streams.omniscient after |win|/|tie| is observed; this is fine functionally but means winner is the last terminal marker seen.
  • The smoke test below asserts winner === 'p1' || winner === 'p2', so if a deterministic seed ever produces a |tie, the test fails with a confusing message instead of a clear "tie observed". Consider asserting winner !== 'tie' && winner !== null separately for a clearer failure.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sim/tools/strategic-ai/difficulty-gradient.js` around lines 51 - 67, The
playGame helper mishandles non-P1/P2 winners and keeps draining
streams.omniscient after seeing terminal markers; update the winner-parsing
logic (the block that reads lines from streams.omniscient and sets winner) to
explicitly map 'P1'/'P2'/'P3'/'P4' to 'p1'/'p2'/'p3'/'p4' (do not fall back to
name.toLowerCase()), detect '|win|' or '|tie' and immediately stop processing
(break/return) once a terminal marker is seen, and update the smoke test
assertion that checks winner to assert winner !== null and assert separately
that winner !== 'tie' (or handle ties explicitly) so a tie produces a clear test
result.
sim/tools/strategic-ai/engines/MctsEngine.ts (1)

95-104: 200 * nodes.length hard cap collides visually with DEFAULT_BUDGET_MS = 200.

The cap is unrelated to the time budget but uses the same magic number. Pull it into a named constant (e.g. MAX_VISITS_PER_CANDIDATE = 200) so future readers don't read this as "budget × node count."

-const DEFAULT_BUDGET_MS = 200;
+const DEFAULT_BUDGET_MS = 200;
+/** Hard cap per candidate to avoid spinning on a fast machine. */
+const MAX_VISITS_PER_CANDIDATE = 200;
@@
-			if (totalVisits >= 200 * nodes.length) break;
+			if (totalVisits >= MAX_VISITS_PER_CANDIDATE * nodes.length) break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/MctsEngine.ts` around lines 95 - 104, The loop
in MctsEngine.ts uses the magic literal 200 in the hard cap (if (totalVisits >=
200 * nodes.length) ...) which visually collides with DEFAULT_BUDGET_MS = 200;
introduce a clearly named constant such as MAX_VISITS_PER_CANDIDATE = 200 at the
top of the module and replace the literal with (totalVisits >=
MAX_VISITS_PER_CANDIDATE * nodes.length), updating the inline comment to reflect
it is a per-candidate visit cap; this change touches the loop that calls
selectByPUCT and sampleRollout and the variables totalVisits and nodes.
sim/tools/strategic-ai/engines/Engine.ts (1)

85-106: sampleArray signature lies about emptiness.

sampleArray<T>(arr: T[], prng: PRNG): T returns undefined when arr.length === 0. Today the only caller is applyNoise, which guards length first, but as soon as a future caller picks this up the type system will say "I always get a T" and quietly hand back undefined. Either narrow the input type to a non-empty tuple or widen the return type:

-export function sampleArray<T>(arr: T[], prng: PRNG): T {
-	return arr[Math.floor(prng.random() * arr.length)];
+export function sampleArray<T>(arr: readonly T[], prng: PRNG): T | undefined {
+	if (!arr.length) return undefined;
+	return arr[Math.floor(prng.random() * arr.length)];
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/Engine.ts` around lines 85 - 106, The
sampleArray signature claims to always return a T but currently returns
undefined for empty arrays; change its signature to require a non-empty array
(e.g. arr: readonly [T, ...T[]] or a NonEmptyArray<T>) so callers like
applyNoise can rely on a guaranteed element, and keep the implementation as-is;
alternatively, if you prefer allowing empty input, widen the return type to T |
undefined and update callers (applyNoise already guards length so no change
there). Ensure to update the function declaration for sampleArray and any
related types so the type system reflects the new contract.
sim/tools/strategic-ai/mechanics/TargetPicker.ts (1)

122-124: allyUsedHelpingHand multiplier is a no-op for target selection.

score *= 1.5 is applied uniformly to every candidate foe, so it does not change which foe wins the argmax — pickTarget returns the same bestIdx whether or not Helping Hand is up. Either drop it (and let scoring elsewhere model HH), or, if the goal is to prefer foes that score above a KO threshold when boosted, gate the KO bonuses (+1500/+800) on the boosted damage instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts` around lines 122 - 124, The
multiplier applied when input.allyUsedHelpingHand is a no-op because multiplying
every candidate's score (in TargetPicker) doesn't change argmax; remove the
uniform "if (input.allyUsedHelpingHand) score *= 1.5" line OR instead change the
KO bonus logic in the same scoring routine so the +1500/+800 KO bonuses are
applied based on boostedDamage (damage * 1.5) rather than base damage; update
references in TargetPicker's scoring function (the variable/logic that adds KO
bonuses and the use of input.allyUsedHelpingHand) accordingly so Helping Hand
either has no-op removed or meaningfully affects target ranking by gating KO
thresholds on boosted damage.
sim/tools/strategic-ai/engines/RandomEngine.ts (1)

186-191: Ally-presence check assumes doubles-only layout.

myPokemon[i ^ 1] correctly maps slot 0↔1 in doubles, but in triples (i=2 → 3) it indexes a non-active bench slot and the adjacentAlly filter silently breaks. The legacy RandomPlayerAI had the same limitation, so this is parity-preserving, but worth a TODO if triples support is on the roadmap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 186 - 191, The
ally-presence check in RandomEngine.ts uses myPokemon[i ^ 1] which only works
for doubles; change the hasAlly logic to detect any other active, non-fainted
teammate instead of assuming the adjacent slot. Specifically, replace the
myPokemon[i ^ 1] access with a scan (e.g., Array.prototype.some) over myPokemon
to find an index !== i where the slot is active/present and condition does not
endWith(" fnt"), then use that boolean in the existing finalMoves filter so
moves with target "adjacentAlly" are allowed when any active ally exists.
sim/tools/strategic-ai/engines/LightHeuristicEngine.ts (1)

161-180: Subtle bug in second-best tracking: ties don't update second.

When score > bestScore, you correctly demote best into second. But when score === bestScore (or ties on the first iteration where bestScore starts at -Infinity), you fall into the else if (score > secondScore) branch, and second remains the very first move because it's the same secondScore = -Infinity first iteration. This is mostly harmless except that second is initialised to moves[0] on line 163, so on the first iteration the strict > skips updating second to itself — net behaviour is fine, but the algorithm is fragile to refactors. Worth tightening:

-		if (score > bestScore) {
+		if (score > bestScore) {
 			secondScore = bestScore;
 			second = best;
 			bestScore = score;
 			best = m;
-		} else if (score > secondScore) {
+		} else if (score >= secondScore) {
 			secondScore = score;
 			second = m;
 		}

Optional, file behaviour is currently OK.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/LightHeuristicEngine.ts` around lines 161 -
180, The second-best tracking is fragile because second is initialized to
moves[0] and ties can leave second pointing incorrectly; change the algorithm so
second starts unset (e.g., null) and only gets assigned inside the loop when a
genuine demotion or a valid runner-up is found: keep best, bestScore,
secondScore as numeric sentinels but set second to null initially, on each
iteration use if (score > bestScore) to demote best->second, otherwise update
second when score > secondScore (and optionally treat score === secondScore
carefully or require m !== best) so ties don't accidentally preserve the initial
moves[0]; refer to variables best, second, bestScore, secondScore and the loop
that calls this.scoreMove.
sim/tools/strategic-ai/engines/HeuristicEngine.ts (1)

206-213: Optional: Protect probe only considers protect, not its family.

The probe matches m.id === "protect" exactly, missing Detect, Spiky Shield, King's Shield, Baneful Bunker, Obstruct, Silk Trap, Burning Bulwark, and Max Guard. Mons that carry one of those instead won't get the anti-spam guard or the probabilistic-protect behavior advertised by PROTECT_CONSIDER_CHANCE.

-const protectIdx = availableMoves.findIndex(m => m.id === "protect");
+const protectFamily = new Set([
+  "protect", "detect", "spikyshield", "kingsshield", "banefulbunker",
+  "obstruct", "silktrap", "burningbulwark", "maxguard",
+]);
+const protectIdx = availableMoves.findIndex(m => protectFamily.has(m.id));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/HeuristicEngine.ts` around lines 206 - 213,
The current protect probe only matches m.id === "protect" so it misses other
guard-family moves; update the logic to detect any guard/protect family member
(e.g., create a PROTECT_FAMILY set containing
"protect","detect","spikyshield","kingsshield","banefulbunker","obstruct","silktrap","burningbulwark","maxguard"
or use an existing move flag/property if available) and use
availableMoves.find(m => PROTECT_FAMILY.has(m.id) /* or m.flags?.protect */) to
locate a candidate; when selecting it, set ctx.lastMoveByMon to that move's id
(not always "protect") and return moveCommandIndex(active, chosenMove.id) so the
probabilistic-protect behavior applies to all guard-family moves.
sim/tools/player-ai.ts (1)

99-107: Optional: redundant searchBudgetMs assignment.

searchBudgetMs is assigned from options on line 101, possibly overwritten by applyKnobs (DifficultyPolicy.applyKnobs only sets it when knobs.searchBudgetMs is truthy), then unconditionally re-assigned from options on line 106. The line 101 assignment is dead and the dual-write makes the precedence non-obvious. Consider one of:

   this.engineCtx = {
     ...
     searchBudgetMs: options.searchBudgetMs,
     ...
   };
   applyKnobs(this.engineCtx, this.difficulty);
-  if (options.searchBudgetMs) this.engineCtx.searchBudgetMs = options.searchBudgetMs;
+  // applyKnobs may overwrite ctx.searchBudgetMs from the difficulty
+  // table; restore the explicit caller override last.
+  if (options.searchBudgetMs !== undefined) {
+    this.engineCtx.searchBudgetMs = options.searchBudgetMs;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/player-ai.ts` around lines 99 - 107, The object initializer
currently sets searchBudgetMs from options and then applyKnobs(this.engineCtx,
this.difficulty) may overwrite it, after which code unconditionally re-assigns
this.engineCtx.searchBudgetMs from options again, making the first write
redundant; remove the initial searchBudgetMs: options.searchBudgetMs from the
engine context initializer and keep the existing post-applyKnobs assignment (if
options.searchBudgetMs) this.engineCtx.searchBudgetMs = options.searchBudgetMs
so that options explicitly override knobs, referencing applyKnobs,
this.engineCtx, this.difficulty, and options.searchBudgetMs.
sim/tools/strategic-ai/index.ts (1)

33-33: Optional: also re-export PokemonRef from LogParser.

Consumers iterating over a BattleEvent typically destructure event.pokemon/event.user/event.target (typed PokemonRef) and will need that type to write helpers. Including it here keeps the barrel self-contained.

-export { parseLine, type BattleEvent, type SideId } from "./state/LogParser";
+export { parseLine, type BattleEvent, type PokemonRef, type SideId } from "./state/LogParser";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/index.ts` at line 33, Export PokemonRef from the
LogParser barrel so consumers can import the type alongside parseLine,
BattleEvent, and SideId; update the export statement that currently re-exports
parseLine, BattleEvent, and SideId to also include PokemonRef (i.e., export {
parseLine, type BattleEvent, type SideId, type PokemonRef } from
"./state/LogParser") so helpers that destructure
event.pokemon/event.user/event.target have the PokemonRef type available.
sim/tools/strategic-ai/mechanics/TransformPolicy.ts (1)

215-225: Optional: Z-move BP heuristic over-shoots for high-BP base moves.

Math.max(100, (move.basePower || 80) * 1.7) gives ~255 BP for Hyper Beam vs Showdown's actual Z-Hyper Beam (200), and similarly inflates other 150-BP base moves. Z-power BP is a fixed table (<55 → 100, 60 → 120, ..., ≥140 → 200); a small lookup will track Showdown more closely if Z-move policy quality matters at higher difficulty.

Not blocking — the policy still only fires on koProbability ≥ 0.95, so the upside vs a tighter approximation is mainly avoiding false KO confirmations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts` around lines 215 - 225,
The Z-move basePower heuristic in zMove (constructed using Math.max(100,
(move.basePower || 80) * 1.7)) overestimates high-BP moves; replace that
multiplication heuristic with a small lookup that maps move.basePower ranges to
Showdown's fixed Z-BP table (e.g., <55→100, 60→120, …, ≥140→200) and use the
mapped value when constructing zMove (update where zMove is created and used by
calculateDamage to reference the lookup result instead of the current formula).
sim/tools/strategic-ai/state/OpponentInference.ts (1)

159-194: Focus Sash heuristic documented but not implemented.

The module docstring (lines 12–13) advertises "Lots of survives-at-1HP signal Focus Sash", but sashScore is just a flat 0.05 prior with no observation-derived signal. If you intend to fold this in later, a TODO would help; otherwise the docstring should be trimmed to match what the code actually does.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/OpponentInference.ts` around lines 159 - 194,
The docstring promises "survives-at-1HP signal Focus Sash" but sashScore is only
a flat prior; implement an observation-driven heuristic: detect focus-sash-like
events (e.g., tracker emitting an activate like '|-activate|<name>|focus sash'
or a per-mon flag such as mon.sashTriggered / mon.survivedAt1HP, or infer from
tracker events where the mon took damage that would be lethal but its HPFraction
remained ~0 or ~(≈1/maxHP) after the hit) and boost sashScore accordingly (e.g.,
+0.6 on a confirmed sash activation, +0.3 on strong survive-at-1HP evidence);
update code in this block around the existing sashScore, referencing sashScore,
mon and tracker to locate where to add the checks or set TODO if deferred.
sim/tools/strategic-ai/mechanics/DamageCalc.ts (1)

536-539: Cleanup: dead switch cases and a tautological condition.

A few minor dead-code spots worth tidying — none affect behavior, but they obscure the intent:

  • Line 536–538: case "rage Fist": (capital F + space) is unreachable because moveId is run through toID, which lowercases and strips whitespace. Either drop the label or fix the spelling. Same shape as case "rageFist": would be dead.
  • Line 773–778 in typeBoostingItem: the chain of nevermeltice variants (neverMeltIce / nevermelltice / nevermelitice / nevermellice) is unreachable for the same reason — toID("Never-Melt Ice") produces only nevermeltice. Keep the canonical case and drop the others.
  • Line 580 in computeBasePower: if (moveType === "Normal" && (move.type !== "Normal" || true)) — the || true makes the parenthesized expression a constant. The condition simplifies to moveType === "Normal"; the inner check was probably meant to be move.type !== "Normal" (i.e. the "ate" path that converted Normal → Normal and shouldn't re-boost).

Also applies to: 762-797

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts` around lines 536 - 539,
Remove unreachable/duplicate case labels and the tautological condition: in the
switch that handles move IDs, drop the incorrect "rage Fist" (and any other
spaced/capitalized variants) and keep the canonical toID form "ragefist" so
cases match the normalized moveId; in the typeBoostingItem mapping remove all
non-canonical variants of Never-Melt Ice and keep only "nevermeltice" (the toID
result for "Never-Melt Ice"); and in computeBasePower replace the tautology `if
(moveType === "Normal" && (move.type !== "Normal" || true))` by the intended
check `if (moveType === "Normal" && move.type !== "Normal")` so the
ate/converted-Normal path is evaluated correctly.
sim/tools/strategic-ai/state/BattleStateTracker.ts (1)

86-86: Minor: typo and inconsistent expiry tracking for magicRoom/wonderRoom.

  • Line 86 docstring: faiunted → fainted.
  • FieldState exposes magicRoom/wonderRoom as plain booleans, while trickRoom and gravity come paired with turn counters. Without magicRoomTurns/wonderRoomTurns, tickTimedConditions has no way to expire them; they rely entirely on |fieldend| arriving. If the protocol stream is ever truncated/replayed, those flags will leak.
Proposed change
-	/** True after we've seen `-terastallize` for this mon. */
+	/** True after we've seen `-terastallize` for this mon. */
@@
-	/** True when the mon has been faiunted at least once. */
+	/** True when the mon has been fainted at least once. */
 	fainted: boolean;
@@
 	/** Magic Room active. */
 	magicRoom: boolean;
+	magicRoomTurns: number;
 	/** Wonder Room active. */
 	wonderRoom: boolean;
+	wonderRoomTurns: number;

…and decrement them in tickTimedConditions like the others.

Also applies to: 138-156

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` at line 86, Fix the
docstring typo in BattleStateTracker: change "faiunted" to "fainted". Add turn
counters for magicRoom and wonderRoom to FieldState (e.g., magicRoomTurns and
wonderRoomTurns), update all places that set magicRoom/wonderRoom (such as
handlers that process |fieldstart|/|fieldend| or similar) to initialize the
corresponding *Turns fields, and change tickTimedConditions to decrement
magicRoomTurns and wonderRoomTurns each turn and clear magicRoom/wonderRoom when
their turns hit 0 (or derive the boolean from turns > 0). Ensure any
initialization/clear logic and exports that previously used plain
magicRoom/wonderRoom are updated to use the new paired-turn semantics so these
effects expire correctly even if |fieldend| is missing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7eed26c0-a7eb-4116-b63b-eaf23b9c308b

📥 Commits

Reviewing files that changed from the base of the PR and between 62ea003476 and 457d8c7ba4.

📒 Files selected for processing (28)
  • sim/battle-stream.ts
  • sim/examples/battle-stream-example.ts
  • sim/index.ts
  • sim/tools/exhaustive-runner.ts
  • sim/tools/player-ai.ts
  • sim/tools/random-player-ai.ts
  • sim/tools/runner.ts
  • sim/tools/strategic-ai/engines/Engine.ts
  • sim/tools/strategic-ai/engines/HeuristicEngine.ts
  • sim/tools/strategic-ai/engines/LightHeuristicEngine.ts
  • sim/tools/strategic-ai/engines/MctsEngine.ts
  • sim/tools/strategic-ai/engines/OnePlySearchEngine.ts
  • sim/tools/strategic-ai/engines/RandomEngine.ts
  • sim/tools/strategic-ai/index.ts
  • sim/tools/strategic-ai/mechanics/DamageCalc.ts
  • sim/tools/strategic-ai/mechanics/MoveEvaluator.ts
  • sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts
  • sim/tools/strategic-ai/mechanics/TargetPicker.ts
  • sim/tools/strategic-ai/mechanics/TransformPolicy.ts
  • sim/tools/strategic-ai/policy/DifficultyPolicy.ts
  • sim/tools/strategic-ai/state/BattleStateTracker.ts
  • sim/tools/strategic-ai/state/LogParser.ts
  • sim/tools/strategic-ai/state/OpponentInference.ts
  • sim/tools/strategic-player-ai.ts
  • test/sim/tools/strategic-ai/damage-calc.js
  • test/sim/tools/strategic-ai/difficulty-gradient.js
  • test/sim/tools/strategic-ai/log-parser.js
  • test/sim/tools/strategic-ai/switch-evaluator.js
💤 Files with no reviewable changes (2)
  • sim/tools/strategic-player-ai.ts
  • sim/tools/random-player-ai.ts
**Actionable comments posted: 11** > [!NOTE] > Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments. > [!CAUTION] > Some comments are outside the diff and can’t be posted inline due to platform limitations. > > > > <details> > <summary>⚠️ Outside diff range comments (1)</summary><blockquote> > > <details> > <summary>sim/battle-stream.ts (1)</summary><blockquote> > > `423-433`: _⚠️ Potential issue_ | _🟠 Major_ > > **Documented return-string contract isn't honored by base `receiveLine`.** > > The comment on lines 428–432 says implementers may "return the choice string for the caller to dispatch." However, `receiveLine` (line 423) only returns the value up to `receive` (line 413–417), which discards it. So a subclass that returns a string without calling `this.choose` will have its command silently dropped. > > In practice, `PlayerAI` avoids this by overriding `receiveLine` entirely and dispatching via `this.choose` internally. But the documented contract is misleading for future subclasses. Two options: > > <details> > <summary>♻️ Option A — make the base class actually dispatch returned strings</summary> > > ```diff > receiveLine(line: string) { > if (this.debug) console.log(line); > if (!line.startsWith('|')) return; > const [cmd, rest] = splitFirst(line.slice(1), '|'); > - if (cmd === 'request') return this.receiveRequest(JSON.parse(rest)); > + if (cmd === 'request') { > + const choice = this.receiveRequest(JSON.parse(rest)); > + if (typeof choice === 'string' && choice !== '') this.choose(choice); > + return; > + } > if (cmd === 'error') return this.receiveError(new Error(rest)); > this.log.push(line); > } > ``` > </details> > > Option B — keep the return type as `void` and document that subclasses must override `receiveLine` or call `this.choose` themselves. > > The type signature should match what the base pipeline actually does. > > <details> > <summary>🤖 Prompt for AI Agents</summary> > > ``` > Verify each finding against the current code and only fix it if needed. > > In `@sim/battle-stream.ts` around lines 423 - 433, The base receiveLine currently > discards any string returned by receiveRequest, breaking the documented > contract; update receiveLine to capture the return value from > receiveRequest(request) and, if it's a non-empty string, call > this.choose(returnedString) so returned choices are dispatched by the base > pipeline (referencing receiveLine, receiveRequest, and this.choose), and update > the comment/type if you prefer the alternate behavior; ensure PlayerAI remains > compatible. > ``` > > </details> > > </blockquote></details> > > </blockquote></details> <details> <summary>🟡 Minor comments (16)</summary><blockquote> <details> <summary>sim/tools/strategic-ai/mechanics/MoveEvaluator.ts-71-75 (1)</summary><blockquote> `71-75`: _⚠️ Potential issue_ | _🟡 Minor_ **Order the null guard before the property access.** `m.id` is dereferenced on line 72 before the `!m` guard on line 73, so the guard can never actually catch a null/undefined `m`. In practice `Dex.moves.get` always returns an object, but if a caller ever passes `null`/`undefined` directly (the `Move` arg has no runtime type-narrowing) this throws on line 72 instead of returning the intended `unknown` sentinel. ```diff const m = typeof move === "string" ? Dex.moves.get(move) : move; - const moveId = toID(m.id || (m as { name?: string }).name || ""); - if (!m || !moveId) { - return { moveId, score: -Infinity, rationale: "unknown" }; + if (!m) { + return { moveId: "", score: -Infinity, rationale: "unknown" }; } + const moveId = toID(m.id || (m as { name?: string }).name || ""); + if (!moveId) { + return { moveId, score: -Infinity, rationale: "unknown" }; + } ``` <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 71 - 75, The null-check for the resolved move must happen before accessing its properties: ensure you verify m is truthy (the result of Dex.moves.get(move) or the passed-in move) prior to using m.id when computing moveId in MoveEvaluator.ts; update the logic around Dex.moves.get, the m variable, and the toID(...) call so that if m is null/undefined you immediately return the sentinel ({ moveId, score: -Infinity, rationale: "unknown" }) instead of dereferencing m.id — e.g., perform the !m guard before calling toID or restructure the moveId expression to only access m.id/name when m is non-null. ``` </details> </blockquote></details> <details> <summary>sim/index.ts-18-18 (1)</summary><blockquote> `18-18`: _⚠️ Potential issue_ | _🟡 Minor_ **Public-API surface change is a breaking change for downstream importers.** Removing `RandomPlayerAI` / `StrongHeuristicsAI` from the published `sim` entrypoint is a breaking change for any external consumer of `pokemon-showdown` that imports these symbols. The PR description does cover the migration (`PlayerAI` with `engine: "random"`); just make sure this is reflected in the package's CHANGELOG / release notes so callers updating across the boundary aren't caught by silent runtime `undefined`. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/index.ts` at line 18, The public API removed RandomPlayerAI and StrongHeuristicsAI from the sim entrypoint which is a breaking change; restore compatibility by either re-exporting those symbols as deprecated aliases that forward to PlayerAI (e.g., RandomPlayerAI -> PlayerAI with engine: "random", StrongHeuristicsAI -> PlayerAI with engine: "strongHeuristics") or add an explicit migration note in the package CHANGELOG/release notes mapping RandomPlayerAI and StrongHeuristicsAI to PlayerAI (engine: "random"/"strongHeuristics") and warn consumers about the removal, and ensure the export line for PlayerAI in sim/index.ts is updated accordingly to include those compatibility exports or document their removal. ``` </details> </blockquote></details> <details> <summary>test/sim/tools/strategic-ai/difficulty-gradient.js-122-123 (1)</summary><blockquote> `122-123`: _⚠️ Potential issue_ | _🟡 Minor_ **Win-rate threshold message is off-by-one vs the actual computed bound.** `Math.ceil(N * 0.55)` with `N = 10` is `6`, i.e. the assertion enforces ≥60%, but both the `it(...)` title and the failure message say "55%" / ">=55%". Either bump the threshold down, or fix the wording so a future reader debugging a flake doesn't have to recompute the bound. <details> <summary>📝 Suggested wording fix</summary> ```diff - assert(result.p1Wins >= Math.ceil(N * 0.55), - `difficulty 3 should beat difficulty 1 in >=55% of ${N} games (got p1=${result.p1Wins} p2=${result.p2Wins} draws=${result.draws})`); + assert(result.p1Wins >= Math.ceil(N * 0.55), + `difficulty 3 should win >= ceil(${N} * 0.55) games vs difficulty 1 ` + + `(got p1=${result.p1Wins} p2=${result.p2Wins} draws=${result.draws})`); ``` (and the same for the difficulty-5-vs-2 case.) </details> Also applies to: 135-136 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@test/sim/tools/strategic-ai/difficulty-gradient.js` around lines 122 - 123, The assertion uses Math.ceil(N * 0.55) (with N=10 this yields 6) but the test title and failure message say ">=55%", which is misleading; define a threshold variable like threshold = Math.ceil(N * 0.55) and use that numeric threshold in the assertion message (e.g. `>= ${threshold} of ${N} games (${Math.round(threshold/N*100)}%)`) so the text matches the actual bound checked for result.p1Wins, update the corresponding assertion and message that uses Math.ceil(N * 0.55) (and do the same fix for the difficulty-5-vs-2 assertion at the other case). ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/MctsEngine.ts-106-118 (1)</summary><blockquote> `106-118`: _⚠️ Potential issue_ | _🟡 Minor_ **Mixing rollout reward and raw heuristic score in the final argmax.** `meanReward` is in the rollout units (`damageFraction*100` plus KO bonuses, range roughly 0–~150), while the fallback for zero-visit candidates is `heuristicScores[i].heuristic` from `evaluateMove`, which has its own unit scale (often hundreds). On a tiny `searchBudgetMs` (or when `totalVisits` is small), unrolled candidates can outrank visited ones purely because of unit mismatch, not policy quality. Either (a) softmax/normalize both onto a shared scale before merging, or (b) for unvisited nodes synthesise a 1-visit rollout instead of falling back to the raw heuristic. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/MctsEngine.ts` around lines 106 - 118, The final scoring mixes rollout units (meanReward from n.totalReward/n.visits in damageFraction*100 + KO bonuses) with raw evaluateMove heuristic units (heuristicScores[i].heuristic), causing unvisited candidates to be wrongly preferred; in the nodes.map block (refer to nodes, n.visits, n.totalReward, meanReward, heuristicScores and evaluateMove) replace the raw heuristic fallback with a synthesized 1-visit rollout value: convert heuristicScores[i].heuristic into the same rollout reward scale (apply the same damageFraction*100 and KO bonus conventions used elsewhere) and use that as the meanReward for zero-visit nodes (or equivalently treat it as totalReward with visits=1) so all candidates are compared on the same unit. ``` </details> </blockquote></details> <details> <summary>test/sim/tools/strategic-ai/damage-calc.js-139-147 (1)</summary><blockquote> `139-147`: _⚠️ Potential issue_ | _🟡 Minor_ **`hpFraction` is ignored by `calculateDamage`, so this test doesn't exercise low-HP KOs.** Per the `CalcPokemon` interface, there is no `hpFraction` field — the calc estimates `defenderMaxHp` from species stats and computes `koProbability` against full HP. Setting `hpFraction: 0.05` here has no effect, and `koProbability` for Garchomp +6 vs Blissey at full HP is essentially trivial. The assertion only checks bounds, so the test is effectively a tautology. Either thread current HP into the calc input (and update `CalcPokemon`/calc accordingly) or drop the misleading `hpFraction` argument and pick a scenario where `koProbability` actually moves. </blockquote></details> <details> <summary>sim/tools/strategic-ai/policy/DifficultyPolicy.ts-100-108 (1)</summary><blockquote> `100-108`: _⚠️ Potential issue_ | _🟡 Minor_ **`applyKnobs` leaks stale `searchBudgetMs` across difficulty changes.** Because the assignment is gated on `if (knobs.searchBudgetMs)`, applying tier-5 knobs (200ms) to a context and then re-applying tier-1 knobs (no `searchBudgetMs`) leaves the old 200ms budget in place. The doc says "idempotent" but it's actually only monotone-by-tier. If difficulty is fixed at construction this is harmless, but it's a footgun for tooling/tests that reuse a context. <details> <summary>🔧 Always reset `searchBudgetMs`</summary> ```diff export function applyKnobs(ctx: EngineContext, difficulty: number): void { const knobs = knobsForDifficulty(difficulty); ctx.noiseEpsilon = knobs.noiseEpsilon; ctx.infoForgetting = knobs.infoForgetting; - if (knobs.searchBudgetMs) ctx.searchBudgetMs = knobs.searchBudgetMs; + ctx.searchBudgetMs = knobs.searchBudgetMs; } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/policy/DifficultyPolicy.ts` around lines 100 - 108, applyKnobs currently only assigns ctx.searchBudgetMs when knobsForDifficulty(difficulty) returns a truthy value, causing a stale searchBudgetMs to persist when re-applying a difficulty that omits the field; change applyKnobs to always set ctx.searchBudgetMs to the knob value (or clear it when absent) instead of guarding with if, e.g., unconditionally assign ctx.searchBudgetMs = knobs.searchBudgetMs (or ctx.searchBudgetMs = undefined when knobs.searchBudgetMs is undefined) so re-applying different difficulties is idempotent; see applyKnobs, knobsForDifficulty, and ctx.searchBudgetMs. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/policy/DifficultyPolicy.ts-9-20 (1)</summary><blockquote> `9-20`: _⚠️ Potential issue_ | _🟡 Minor_ **Stale "(TBD)" rows in the docstring table.** Difficulties 4 and 5 in the comment table say `OnePly (TBD)` and `MCTS (TBD)`, but `pickEngine` already instantiates `OnePlySearchEngine` and `MctsEngine` directly. Update the table so consumers don't think those tiers fall back to the heuristic engine. <details> <summary>📝 Docstring fix</summary> ```diff - * | 4 | OnePly (TBD) | 0.05 | 0.10 | - * | 5 | MCTS (TBD) | 0.00 | 0.00 | + * | 4 | OnePlySearch | 0.05 | 0.10 | + * | 5 | MCTS | 0.00 | 0.00 | ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/policy/DifficultyPolicy.ts` around lines 9 - 20, Update the docstring table in DifficultyPolicy.ts to remove the "(TBD)" labels for tiers 4 and 5 so it reflects the actual engines instantiated by pickEngine (i.e., OnePlySearchEngine for difficulty 4 and MctsEngine for difficulty 5); specifically edit the table rows referencing "OnePly (TBD)" and "MCTS (TBD)" to "OnePly" and "MCTS" (or "MctsEngine") so the comment matches the behavior of the pickEngine function and the referenced classes OnePlySearchEngine and MctsEngine. ``` </details> </blockquote></details> <details> <summary>test/sim/tools/strategic-ai/damage-calc.js-95-112 (1)</summary><blockquote> `95-112`: _⚠️ Potential issue_ | _🟡 Minor_ **EQ-vs-Skarmory comparison is trivialized by Flying immunity.** Skarmory is Steel/**Flying**, so Earthquake hits for 0 damage. That makes `fireFraction === 0` and the assertion `groundFraction > fireFraction * 1.5` reduces to `groundFraction > 0`, which is already implied by the first assertion. The variable name `fire`/`fireFraction` also doesn't match the species. To actually exercise the "super-effective vs neutral/resisted" comparison the test name implies, pick a non-Flying defender (e.g. Magnezone for 2× resist, or a neutral Normal-type). <details> <summary>🔧 Suggested rename + neutral target</summary> ```diff - const fire = calc({ + const neutral = calc({ attacker: mkMon('Garchomp'), - defender: mkMon('Skarmory'), + defender: mkMon('Magnezone'), move: 'earthquake', }); assert(!ground.immune); const groundFraction = ground.avgDamage / ground.defenderMaxHp; - const fireFraction = fire.avgDamage / fire.defenderMaxHp; + const neutralFraction = neutral.avgDamage / neutral.defenderMaxHp; assert(groundFraction > 0.4, `Earthquake vs Heatran should deal heavy damage; got ${groundFraction.toFixed(2)}`); - assert(groundFraction > fireFraction * 1.5, - `Heatran should take more from EQ than Skarmory does (got ${groundFraction.toFixed(2)} vs ${fireFraction.toFixed(2)})`); + assert(groundFraction > neutralFraction * 1.5, + `4× target should take noticeably more than a 2× target (got ${groundFraction.toFixed(2)} vs ${neutralFraction.toFixed(2)})`); ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@test/sim/tools/strategic-ai/damage-calc.js` around lines 95 - 112, The test compares Earthquake on Heatran to Earthquake on Skarmory, but Skarmory's Flying type makes EQ do 0 damage, trivializing the second assertion and the misleading variable name `fire`; change the second defender from mkMon('Skarmory') to a non-Flying neutral target (e.g. mkMon('Snorlax')), rename the `fire` and `fireFraction` variables to `neutral` and `neutralFraction`, and update the assertion message to reflect the neutral target so the comparison between STAB+super-effective (ground) and neutral damage is meaningful. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/MctsEngine.ts-122-143 (1)</summary><blockquote> `122-143`: _⚠️ Potential issue_ | _🟡 Minor_ **Warm-up is sequential, not round-robin (comment misleading).** The loop returns the first node with `visits < MIN_VISITS_PER_CANDIDATE`, so `nodes[0]` is rolled out 4 times **in a row** before `nodes[1]` is touched. The comment claims this avoids domination by an early-lucky reward, but a round-robin would actually require picking the *least-visited* node. As-is, on small budgets you risk burning the entire budget on `nodes[0]` and never visiting later candidates at all. <details> <summary>🔧 Pick the least-visited candidate during warm-up</summary> ```diff - for (const n of nodes) { - // Until each candidate has been explored a minimum number of - // times we round-robin so the search isn't dominated by an - // early-lucky high-variance reward. - if (n.visits < MIN_VISITS_PER_CANDIDATE) { - return n; - } - const q = n.totalReward / n.visits; - ... - } + let leastVisited: CandidateNode | null = null; + for (const n of nodes) { + if (n.visits < MIN_VISITS_PER_CANDIDATE) { + if (!leastVisited || n.visits < leastVisited.visits) leastVisited = n; + } + } + if (leastVisited) return leastVisited; + for (const n of nodes) { + const q = n.totalReward / n.visits; + ... + } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/MctsEngine.ts` around lines 122 - 143, The comment is misleading because selectByPUCT currently returns the first node with visits < MIN_VISITS_PER_CANDIDATE, causing sequential warm-up of nodes[0] instead of a round-robin; change the warm-up logic in selectByPUCT so it selects the least-visited candidate (i.e., scan nodes to find the node with the minimal visits that is still < MIN_VISITS_PER_CANDIDATE and return that) rather than returning the first such node—use MIN_VISITS_PER_CANDIDATE and nodes[].visits as the unique symbols to locate and update the code. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/state/LogParser.ts-198-215 (1)</summary><blockquote> `198-215`: _⚠️ Potential issue_ | _🟡 Minor_ **Minor: `forced` is hardcoded to `false` for switches.** The `BattleEvent` union exposes a `forced` flag on `switch`, but it's never populated. Showdown emits `[from] move: U-turn` / `[from] move: Volt Switch` / `[from] item: Eject Button` etc. on forced switches, and downstream consumers may want to distinguish those from voluntary switches (e.g., for opponent-pivot inference / Choice-lock heuristics). <details> <summary>Suggested patch</summary> ```diff case "switch": case "drag": { const ref = parsePokemonRef(args[0]); if (!ref) return null; const [hp, ...statusParts] = (args[2] || "").split(" "); const status = statusParts.join(" ").trim(); if (kind === "drag") { return { kind: "drag", pokemon: ref, details: args[1] || "", hp, status }; } + const fromKwarg = extractKwarg(args, "from"); return { kind: "switch", pokemon: ref, details: args[1] || "", hp, status, - forced: false, + forced: !!fromKwarg, }; } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/LogParser.ts` around lines 198 - 215, The switch handler in LogParser.ts currently hardcodes forced: false for switch events, so the BattleEvent.union's forced flag is never populated; update the switch parsing branch (the case "switch" within the switch/drag block that uses parsePokemonRef and builds the returned object) to detect Showdown "forced" indicators emitted earlier in the log (e.g., lines like "[from] move: U-turn", "[from] move: Volt Switch", or "[from] item: Eject Button") and set forced: true when appropriate (or derive it from the args if the switch log entry includes a flag), otherwise keep false, ensuring the returned object for kind "switch" populates the forced property correctly for downstream consumers. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts-241-251 (1)</summary><blockquote> `241-251`: _⚠️ Potential issue_ | _🟡 Minor_ **Swift Swim doubles speed unconditionally — should require rain.** `scaledSpeed` applies `swiftswim spe *= 2` regardless of `tracker.field.weather`. Swift Swim only triggers under `raindance` / `primordialsea`, so the AI will overrate rain abusers (Kingdra, Barraskewda, Greninja w/ Battle Bond, etc.) in every neutral-weather matchup, biasing both `evaluateMatchup` speed scoring and switch picks. Same concern would apply to Chlorophyll/Sand Rush/Slush Rush/Surge Surfer if you choose to model those — at minimum gating Swift Swim on weather is the safe baseline. <details> <summary>Suggested patch</summary> ```diff -function scaledSpeed(mon: TrackedPokemon, tailwind: boolean): number { +function scaledSpeed( + mon: TrackedPokemon, + tailwind: boolean, + weather: string, +): number { let spe = mon.stats?.spe ?? approximateSpeed(mon); const stage = mon.boosts.spe || 0; if (stage > 0) spe = Math.floor(spe * (2 + stage) / 2); else if (stage < 0) spe = Math.floor(spe * 2 / (2 - stage)); if (toID(mon.item) === "choicescarf") spe = Math.floor(spe * 1.5); - if (toID(mon.ability) === "swiftswim") spe *= 2; + if (toID(mon.ability) === "swiftswim" && + (weather === "raindance" || weather === "primordialsea")) { + spe *= 2; + } if (tailwind) spe *= 2; if (mon.status === "par") spe = Math.floor(spe / 2); return spe; } ``` …and pass `tracker.field.weather` from `computeSpeedDelta`. </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` around lines 241 - 251, scaledSpeed currently doubles speed for Swift Swim unconditionally; update it to only apply the swiftswim multiplier when the field weather indicates rain. Modify scaledSpeed to check the provided weather (e.g. require weather === "raindance" || weather === "primordialsea") before applying the swiftswim branch, and propagate that weather into scaledSpeed by updating computeSpeedDelta (and any callers) to pass tracker.field.weather (or a weather string) alongside the existing tailwind parameter so Swift Swim is gated on actual rain. ``` </details> </blockquote></details> <details> <summary>sim/tools/player-ai.ts-130-142 (1)</summary><blockquote> `130-142`: _⚠️ Potential issue_ | _🟡 Minor_ **Falling through to `super.receiveLine` on bad request JSON will re-throw.** `super.receiveLine` for `|request|` does its own `JSON.parse(rest)` (see `sim/battle-stream.ts` lines 419–426), which means the catch here is effectively a no-op: super hits the same parse error and throws. If the intent of the catch is to be tolerant, just `return`: <details> <summary>Suggested patch</summary> ```diff - try { - request = JSON.parse(rest); - } catch { - super.receiveLine(line); - return; - } + try { + request = JSON.parse(rest); + } catch { + // Malformed request payload — ignore rather than crash. + return; + } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/player-ai.ts` around lines 130 - 142, The try/catch around JSON.parse in the receiveLine handler for "|request|" is catching parse errors but then calls super.receiveLine(line), which will re-parsed and re-throw the same error; update the catch block in the receiveLine implementation so that on JSON.parse failure it does not delegate to super.receiveLine but simply returns (silently ignore the bad request), leaving the rest of the logic (use of receiveRequest and choose) unchanged—look for the receiveLine method handling lines starting with "|request|" in sim/tools/player-ai.ts and replace the super.receiveLine(line) call in the catch with a plain return. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/HeuristicEngine.ts-242-246 (1)</summary><blockquote> `242-246`: _⚠️ Potential issue_ | _🟡 Minor_ **Noise pool filter fails when the best move has a negative score.** After descending sort, `pick.score` is the maximum. For non-negative scores, `pick.score * 0.5` creates a reasonable lower bound on "near-best" candidates. However, `evaluateMove` can return negative scores (e.g., recovery moves at high HP return –10, status moves on immune types return –20, moves without viable targets return negative penalties). When `pick.score < 0`, the arithmetic reverses: `pick.score * 0.5` becomes *greater* than `pick.score` (less negative). The filter then excludes `pick` itself, and if no other evaluated options exceed this threshold, `noisedPool` becomes empty. `applyNoise` detects `candidates.length ≤ 1` and returns `bestPick` unchanged, silently disabling epsilon noise at the worst time—when the mon faces only bad options and exploration could help. <details> <summary>Suggested fix (uses absolute span around pick.score)</summary> ```diff - const noisedPool = scored.filter(s => s.score >= pick.score * 0.5); + // Half-best window, robust to negative scores. + const span = Math.max(1e-6, Math.abs(pick.score)); + const cutoff = pick.score - 0.5 * span; + const noisedPool = scored.filter(s => s.score >= cutoff); ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/HeuristicEngine.ts` around lines 242 - 246, The current noise pool filter uses scored.filter(s => s.score >= pick.score * 0.5) which breaks when pick.score is negative and can exclude the best move; replace that logic with an absolute-span filter around pick.score so candidates whose score is within a symmetric range are kept. Compute a span (e.g., span = Math.abs(pick.score) * 0.5 or a small constant when pick.score is zero) and select noisedPool as scored.filter(s => Math.abs(s.score - pick.score) <= span), ensuring pick remains in noisedPool before calling applyNoise(pick, noisedPool, ctx.noiseEpsilon, ctx.prng) and preserving the ctx.lastMoveByMon update. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/state/BattleStateTracker.ts-652-661 (1)</summary><blockquote> `652-661`: _⚠️ Potential issue_ | _🟡 Minor_ **`enditem` has dead branches — comment and code disagree.** Both branches assign `mon.item = ""`, so the `if/else` is dead code. The comment ("Don't stomp item if the message indicates a swap (`from move:Trick` etc).") suggests intent to *skip* clearing on Trick/Switcheroo/etc., but the implementation does the opposite. Either the comment is stale and the branching should be removed, or the swap-detection branch should preserve the item id. For protocol fidelity, `|-enditem|` does mean the mon no longer holds that item (regardless of cause), so unconditional clearing is correct — but please remove the misleading branch and comment. <details> <summary>Suggested simplification</summary> ```diff case "enditem": { const mon = this.resolveByRef(event.pokemon); - // Don't stomp item if the message indicates a swap (`from move:Trick` etc). - if (!event.from || /^(?:move:|ability:)/i.test(event.from)) { - mon.item = ""; - } else { - mon.item = ""; - } + mon.item = ""; return; } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 652 - 661, The case "enditem" branch in BattleStateTracker currently has a misleading comment and dead if/else that both set mon.item = ""; change the handler for case "enditem" (the block that calls this.resolveByRef(event.pokemon)) to remove the swap-detection comment and the conditional entirely and simply set mon.item = "" unconditionally (preserve the existing return); this keeps protocol-correct behavior while removing the confusing dead branch. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/state/BattleStateTracker.ts-465-477 (1)</summary><blockquote> `465-477`: _⚠️ Potential issue_ | _🟡 Minor_ **Possible double-count of `sides[...].fainted` on replay.** `this.sides[event.pokemon.side].fainted += 1;` runs on every `|faint|` without checking `mon.fainted`. If a `|faint|` is ever replayed (e.g. tracker re-initialized over a buffered log), the side's fainted counter will overshoot the team size, which downstream KO-pressure heuristics may consult. <details> <summary>Proposed guard</summary> ```diff case "faint": { const mon = this.resolveByRef(event.pokemon); + if (mon.fainted) return; mon.fainted = true; mon.active = false; mon.hpFraction = 0; mon.condition = "0 fnt"; this.sides[event.pokemon.side].fainted += 1; ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 465 - 477, The faint handler in BattleStateTracker increments this.sides[...].fainted unconditionally, risking double-counting on replay; update the case "faint" branch (the code that calls this.resolveByRef(event.pokemon) and sets mon.fainted = true) to first check if mon.fainted is already true and only increment this.sides[event.pokemon.side].fainted when mon.fainted was previously false, then proceed to set mon.fainted = true and update active/hpFraction/condition as before. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/state/BattleStateTracker.ts-417-436 (1)</summary><blockquote> `417-436`: _⚠️ Potential issue_ | _🟡 Minor_ **Choice-lock inference produces false positives for multi-turn locks and non-Choice items.** `mon.choiceLocked` is set to `true` on any 2-in-a-row same move from the foe and is never cleared except on switch. This will misclassify Outrage/Petal Dance/Solar Beam/Encore-locked users, and any mon simply spamming its strongest move when its item is already revealed to be non-Choice (e.g. Leftovers, Life Orb). Consider gating on whether the item is unknown or actually a Choice band/specs/scarf, and clearing the flag when the streak breaks (e.g. on `move` to a different move id or on `cant`). <details> <summary>Proposed gating</summary> ```diff - if (event.user.side === this.foeSide && mon.sameMoveStreak >= 2) { - mon.choiceLocked = true; - } + if (event.user.side === this.foeSide && mon.sameMoveStreak >= 2) { + const item = mon.item; + const consistentWithChoice = !item || + item === "choiceband" || item === "choicescarf" || item === "choicespecs"; + if (consistentWithChoice) mon.choiceLocked = true; + } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 417 - 436, The current choice-lock logic in the "move" case of BattleStateTracker sets mon.choiceLocked whenever a foe uses the same move twice (mon.sameMoveStreak >= 2) which yields false positives for multi-turn moves and when the item is known non-Choice; update the logic in the move handler (resolveByRef, mon.lastMove, mon.sameMoveStreak, mon.choiceLocked) to only set mon.choiceLocked if the mon's held item is unknown or explicitly a Choice item (check against a ChoiceItems set), and ensure you clear mon.choiceLocked when the streak breaks (on a different move) or when a "cant" event occurs; also avoid setting choiceLocked for known multi-turn moves (e.g., Outrage/Petal Dance/Solar Beam/Encore) by checking moveId against a MultiTurnMoves set before inferring choice-lock. ``` </details> </blockquote></details> </blockquote></details> <details> <summary>🧹 Nitpick comments (14)</summary><blockquote> <details> <summary>sim/tools/exhaustive-runner.ts (1)</summary><blockquote> `84-90`: **`engine: 'random'` is redundant when `engineInstance` is supplied.** Per `PlayerAI`'s constructor, `options.engineInstance ?? pickEngine(...)` means `engineInstance` already wins; the `engine: 'random'` field is inert here and can be dropped to avoid the appearance of a meaningful choice. <details> <summary>♻️ Proposed cleanup</summary> ```diff const createAI = (s: ObjectReadWriteStream<string>, o: AIOptions) => new PlayerAI(s, { - engine: 'random', engineInstance: new CoordinatedRandomEngine(pools), seed: o.seed, randomMoveProb: o.move, randomMegaProb: o.mega, }); ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/exhaustive-runner.ts` around lines 84 - 90, The object passed to new PlayerAI in createAI includes a redundant engine: 'random' property because PlayerAI prefers options.engineInstance (see options.engineInstance ?? pickEngine(...)); remove the inert engine: 'random' field from the options object in the createAI function so only engineInstance: new CoordinatedRandomEngine(pools), seed: o.seed, randomMoveProb: o.move, randomMegaProb: o.mega remain. ``` </details> </blockquote></details> <details> <summary>test/sim/tools/strategic-ai/difficulty-gradient.js (1)</summary><blockquote> `51-67`: **`playGame` mishandles ties / 4-player names and won't terminate cleanly on draws.** A few small robustness concerns in this helper that future tests will inherit: - `name === 'P1' ? 'p1' : name === 'P2' ? 'p2' : name.toLowerCase()` silently falls through to a lowercased side name on any non-P1/P2 input, so a `|win|Bot 3` line would yield `'bot 3'` instead of `'p3'`. Today only `P1`/`P2` are used, but this trips up anyone copy-pasting the helper for a 4-player test. - The for-await loop keeps draining `streams.omniscient` after `|win|`/`|tie|` is observed; this is fine functionally but means `winner` is the last terminal marker seen. - The smoke test below asserts `winner === 'p1' || winner === 'p2'`, so if a deterministic seed ever produces a `|tie`, the test fails with a confusing message instead of a clear "tie observed". Consider asserting `winner !== 'tie' && winner !== null` separately for a clearer failure. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@test/sim/tools/strategic-ai/difficulty-gradient.js` around lines 51 - 67, The playGame helper mishandles non-P1/P2 winners and keeps draining streams.omniscient after seeing terminal markers; update the winner-parsing logic (the block that reads lines from streams.omniscient and sets winner) to explicitly map 'P1'/'P2'/'P3'/'P4' to 'p1'/'p2'/'p3'/'p4' (do not fall back to name.toLowerCase()), detect '|win|' or '|tie' and immediately stop processing (break/return) once a terminal marker is seen, and update the smoke test assertion that checks winner to assert winner !== null and assert separately that winner !== 'tie' (or handle ties explicitly) so a tie produces a clear test result. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/MctsEngine.ts (1)</summary><blockquote> `95-104`: **`200 * nodes.length` hard cap collides visually with `DEFAULT_BUDGET_MS = 200`.** The cap is unrelated to the time budget but uses the same magic number. Pull it into a named constant (e.g. `MAX_VISITS_PER_CANDIDATE = 200`) so future readers don't read this as "budget × node count." ```diff -const DEFAULT_BUDGET_MS = 200; +const DEFAULT_BUDGET_MS = 200; +/** Hard cap per candidate to avoid spinning on a fast machine. */ +const MAX_VISITS_PER_CANDIDATE = 200; @@ - if (totalVisits >= 200 * nodes.length) break; + if (totalVisits >= MAX_VISITS_PER_CANDIDATE * nodes.length) break; ``` <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/MctsEngine.ts` around lines 95 - 104, The loop in MctsEngine.ts uses the magic literal 200 in the hard cap (if (totalVisits >= 200 * nodes.length) ...) which visually collides with DEFAULT_BUDGET_MS = 200; introduce a clearly named constant such as MAX_VISITS_PER_CANDIDATE = 200 at the top of the module and replace the literal with (totalVisits >= MAX_VISITS_PER_CANDIDATE * nodes.length), updating the inline comment to reflect it is a per-candidate visit cap; this change touches the loop that calls selectByPUCT and sampleRollout and the variables totalVisits and nodes. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/Engine.ts (1)</summary><blockquote> `85-106`: **`sampleArray` signature lies about emptiness.** `sampleArray<T>(arr: T[], prng: PRNG): T` returns `undefined` when `arr.length === 0`. Today the only caller is `applyNoise`, which guards length first, but as soon as a future caller picks this up the type system will say "I always get a `T`" and quietly hand back `undefined`. Either narrow the input type to a non-empty tuple or widen the return type: ```diff -export function sampleArray<T>(arr: T[], prng: PRNG): T { - return arr[Math.floor(prng.random() * arr.length)]; +export function sampleArray<T>(arr: readonly T[], prng: PRNG): T | undefined { + if (!arr.length) return undefined; + return arr[Math.floor(prng.random() * arr.length)]; } ``` <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/Engine.ts` around lines 85 - 106, The sampleArray signature claims to always return a T but currently returns undefined for empty arrays; change its signature to require a non-empty array (e.g. arr: readonly [T, ...T[]] or a NonEmptyArray<T>) so callers like applyNoise can rely on a guaranteed element, and keep the implementation as-is; alternatively, if you prefer allowing empty input, widen the return type to T | undefined and update callers (applyNoise already guards length so no change there). Ensure to update the function declaration for sampleArray and any related types so the type system reflects the new contract. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/mechanics/TargetPicker.ts (1)</summary><blockquote> `122-124`: **`allyUsedHelpingHand` multiplier is a no-op for target selection.** `score *= 1.5` is applied uniformly to every candidate foe, so it does not change which foe wins the argmax — `pickTarget` returns the same `bestIdx` whether or not Helping Hand is up. Either drop it (and let scoring elsewhere model HH), or, if the goal is to *prefer foes that score above a KO threshold when boosted*, gate the KO bonuses (`+1500`/`+800`) on the boosted damage instead. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts` around lines 122 - 124, The multiplier applied when input.allyUsedHelpingHand is a no-op because multiplying every candidate's score (in TargetPicker) doesn't change argmax; remove the uniform "if (input.allyUsedHelpingHand) score *= 1.5" line OR instead change the KO bonus logic in the same scoring routine so the +1500/+800 KO bonuses are applied based on boostedDamage (damage * 1.5) rather than base damage; update references in TargetPicker's scoring function (the variable/logic that adds KO bonuses and the use of input.allyUsedHelpingHand) accordingly so Helping Hand either has no-op removed or meaningfully affects target ranking by gating KO thresholds on boosted damage. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/RandomEngine.ts (1)</summary><blockquote> `186-191`: **Ally-presence check assumes doubles-only layout.** `myPokemon[i ^ 1]` correctly maps slot 0↔1 in doubles, but in triples (i=2 → 3) it indexes a non-active bench slot and the `adjacentAlly` filter silently breaks. The legacy `RandomPlayerAI` had the same limitation, so this is parity-preserving, but worth a TODO if triples support is on the roadmap. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 186 - 191, The ally-presence check in RandomEngine.ts uses myPokemon[i ^ 1] which only works for doubles; change the hasAlly logic to detect any other active, non-fainted teammate instead of assuming the adjacent slot. Specifically, replace the myPokemon[i ^ 1] access with a scan (e.g., Array.prototype.some) over myPokemon to find an index !== i where the slot is active/present and condition does not endWith(" fnt"), then use that boolean in the existing finalMoves filter so moves with target "adjacentAlly" are allowed when any active ally exists. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/LightHeuristicEngine.ts (1)</summary><blockquote> `161-180`: **Subtle bug in second-best tracking: ties don't update `second`.** When `score > bestScore`, you correctly demote `best` into `second`. But when `score === bestScore` (or ties on the first iteration where `bestScore` starts at `-Infinity`), you fall into the `else if (score > secondScore)` branch, and `second` remains the very first move because it's the same `secondScore = -Infinity` first iteration. This is mostly harmless except that `second` is initialised to `moves[0]` on line 163, so on the *first* iteration the strict `>` skips updating second to itself — net behaviour is fine, but the algorithm is fragile to refactors. Worth tightening: ```diff - if (score > bestScore) { + if (score > bestScore) { secondScore = bestScore; second = best; bestScore = score; best = m; - } else if (score > secondScore) { + } else if (score >= secondScore) { secondScore = score; second = m; } ``` Optional, file behaviour is currently OK. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/LightHeuristicEngine.ts` around lines 161 - 180, The second-best tracking is fragile because second is initialized to moves[0] and ties can leave second pointing incorrectly; change the algorithm so second starts unset (e.g., null) and only gets assigned inside the loop when a genuine demotion or a valid runner-up is found: keep best, bestScore, secondScore as numeric sentinels but set second to null initially, on each iteration use if (score > bestScore) to demote best->second, otherwise update second when score > secondScore (and optionally treat score === secondScore carefully or require m !== best) so ties don't accidentally preserve the initial moves[0]; refer to variables best, second, bestScore, secondScore and the loop that calls this.scoreMove. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/engines/HeuristicEngine.ts (1)</summary><blockquote> `206-213`: **Optional: Protect probe only considers `protect`, not its family.** The probe matches `m.id === "protect"` exactly, missing Detect, Spiky Shield, King's Shield, Baneful Bunker, Obstruct, Silk Trap, Burning Bulwark, and Max Guard. Mons that carry one of those instead won't get the anti-spam guard or the probabilistic-protect behavior advertised by `PROTECT_CONSIDER_CHANCE`. ```diff -const protectIdx = availableMoves.findIndex(m => m.id === "protect"); +const protectFamily = new Set([ + "protect", "detect", "spikyshield", "kingsshield", "banefulbunker", + "obstruct", "silktrap", "burningbulwark", "maxguard", +]); +const protectIdx = availableMoves.findIndex(m => protectFamily.has(m.id)); ``` <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/HeuristicEngine.ts` around lines 206 - 213, The current protect probe only matches m.id === "protect" so it misses other guard-family moves; update the logic to detect any guard/protect family member (e.g., create a PROTECT_FAMILY set containing "protect","detect","spikyshield","kingsshield","banefulbunker","obstruct","silktrap","burningbulwark","maxguard" or use an existing move flag/property if available) and use availableMoves.find(m => PROTECT_FAMILY.has(m.id) /* or m.flags?.protect */) to locate a candidate; when selecting it, set ctx.lastMoveByMon to that move's id (not always "protect") and return moveCommandIndex(active, chosenMove.id) so the probabilistic-protect behavior applies to all guard-family moves. ``` </details> </blockquote></details> <details> <summary>sim/tools/player-ai.ts (1)</summary><blockquote> `99-107`: **Optional: redundant `searchBudgetMs` assignment.** `searchBudgetMs` is assigned from options on line 101, possibly overwritten by `applyKnobs` (DifficultyPolicy.applyKnobs only sets it when `knobs.searchBudgetMs` is truthy), then unconditionally re-assigned from options on line 106. The line 101 assignment is dead and the dual-write makes the precedence non-obvious. Consider one of: ```diff this.engineCtx = { ... searchBudgetMs: options.searchBudgetMs, ... }; applyKnobs(this.engineCtx, this.difficulty); - if (options.searchBudgetMs) this.engineCtx.searchBudgetMs = options.searchBudgetMs; + // applyKnobs may overwrite ctx.searchBudgetMs from the difficulty + // table; restore the explicit caller override last. + if (options.searchBudgetMs !== undefined) { + this.engineCtx.searchBudgetMs = options.searchBudgetMs; + } ``` <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/player-ai.ts` around lines 99 - 107, The object initializer currently sets searchBudgetMs from options and then applyKnobs(this.engineCtx, this.difficulty) may overwrite it, after which code unconditionally re-assigns this.engineCtx.searchBudgetMs from options again, making the first write redundant; remove the initial searchBudgetMs: options.searchBudgetMs from the engine context initializer and keep the existing post-applyKnobs assignment (if options.searchBudgetMs) this.engineCtx.searchBudgetMs = options.searchBudgetMs so that options explicitly override knobs, referencing applyKnobs, this.engineCtx, this.difficulty, and options.searchBudgetMs. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/index.ts (1)</summary><blockquote> `33-33`: **Optional: also re-export `PokemonRef` from `LogParser`.** Consumers iterating over a `BattleEvent` typically destructure `event.pokemon`/`event.user`/`event.target` (typed `PokemonRef`) and will need that type to write helpers. Including it here keeps the barrel self-contained. ```diff -export { parseLine, type BattleEvent, type SideId } from "./state/LogParser"; +export { parseLine, type BattleEvent, type PokemonRef, type SideId } from "./state/LogParser"; ``` <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/index.ts` at line 33, Export PokemonRef from the LogParser barrel so consumers can import the type alongside parseLine, BattleEvent, and SideId; update the export statement that currently re-exports parseLine, BattleEvent, and SideId to also include PokemonRef (i.e., export { parseLine, type BattleEvent, type SideId, type PokemonRef } from "./state/LogParser") so helpers that destructure event.pokemon/event.user/event.target have the PokemonRef type available. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/mechanics/TransformPolicy.ts (1)</summary><blockquote> `215-225`: **Optional: Z-move BP heuristic over-shoots for high-BP base moves.** `Math.max(100, (move.basePower || 80) * 1.7)` gives ~255 BP for Hyper Beam vs Showdown's actual Z-Hyper Beam (200), and similarly inflates other 150-BP base moves. Z-power BP is a fixed table (`<55 → 100`, `60 → 120`, ..., `≥140 → 200`); a small lookup will track Showdown more closely if Z-move policy quality matters at higher difficulty. Not blocking — the policy still only fires on `koProbability ≥ 0.95`, so the upside vs a tighter approximation is mainly avoiding false KO confirmations. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts` around lines 215 - 225, The Z-move basePower heuristic in zMove (constructed using Math.max(100, (move.basePower || 80) * 1.7)) overestimates high-BP moves; replace that multiplication heuristic with a small lookup that maps move.basePower ranges to Showdown's fixed Z-BP table (e.g., <55→100, 60→120, …, ≥140→200) and use the mapped value when constructing zMove (update where zMove is created and used by calculateDamage to reference the lookup result instead of the current formula). ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/state/OpponentInference.ts (1)</summary><blockquote> `159-194`: **Focus Sash heuristic documented but not implemented.** The module docstring (lines 12–13) advertises *"Lots of survives-at-1HP signal Focus Sash"*, but `sashScore` is just a flat 0.05 prior with no observation-derived signal. If you intend to fold this in later, a TODO would help; otherwise the docstring should be trimmed to match what the code actually does. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/OpponentInference.ts` around lines 159 - 194, The docstring promises "survives-at-1HP signal Focus Sash" but sashScore is only a flat prior; implement an observation-driven heuristic: detect focus-sash-like events (e.g., tracker emitting an activate like '|-activate|<name>|focus sash' or a per-mon flag such as mon.sashTriggered / mon.survivedAt1HP, or infer from tracker events where the mon took damage that would be lethal but its HPFraction remained ~0 or ~(≈1/maxHP) after the hit) and boost sashScore accordingly (e.g., +0.6 on a confirmed sash activation, +0.3 on strong survive-at-1HP evidence); update code in this block around the existing sashScore, referencing sashScore, mon and tracker to locate where to add the checks or set TODO if deferred. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/mechanics/DamageCalc.ts (1)</summary><blockquote> `536-539`: **Cleanup: dead `switch` cases and a tautological condition.** A few minor dead-code spots worth tidying — none affect behavior, but they obscure the intent: - Line 536–538: `case "rage Fist":` (capital F + space) is unreachable because `moveId` is run through `toID`, which lowercases and strips whitespace. Either drop the label or fix the spelling. Same shape as `case "rageFist":` would be dead. - Line 773–778 in `typeBoostingItem`: the chain of `nevermeltice` variants (`neverMeltIce` / `nevermelltice` / `nevermelitice` / `nevermellice`) is unreachable for the same reason — `toID("Never-Melt Ice")` produces only `nevermeltice`. Keep the canonical case and drop the others. - Line 580 in `computeBasePower`: `if (moveType === "Normal" && (move.type !== "Normal" || true))` — the `|| true` makes the parenthesized expression a constant. The condition simplifies to `moveType === "Normal"`; the inner check was probably meant to be `move.type !== "Normal"` (i.e. the "ate" path that converted Normal → Normal and shouldn't re-boost). Also applies to: 762-797 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts` around lines 536 - 539, Remove unreachable/duplicate case labels and the tautological condition: in the switch that handles move IDs, drop the incorrect "rage Fist" (and any other spaced/capitalized variants) and keep the canonical toID form "ragefist" so cases match the normalized moveId; in the typeBoostingItem mapping remove all non-canonical variants of Never-Melt Ice and keep only "nevermeltice" (the toID result for "Never-Melt Ice"); and in computeBasePower replace the tautology `if (moveType === "Normal" && (move.type !== "Normal" || true))` by the intended check `if (moveType === "Normal" && move.type !== "Normal")` so the ate/converted-Normal path is evaluated correctly. ``` </details> </blockquote></details> <details> <summary>sim/tools/strategic-ai/state/BattleStateTracker.ts (1)</summary><blockquote> `86-86`: **Minor: typo and inconsistent expiry tracking for `magicRoom`/`wonderRoom`.** - Line 86 docstring: `faiunted` → `fainted`. - `FieldState` exposes `magicRoom`/`wonderRoom` as plain booleans, while `trickRoom` and `gravity` come paired with turn counters. Without `magicRoomTurns`/`wonderRoomTurns`, `tickTimedConditions` has no way to expire them; they rely entirely on `|fieldend|` arriving. If the protocol stream is ever truncated/replayed, those flags will leak. <details> <summary>Proposed change</summary> ```diff - /** True after we've seen `-terastallize` for this mon. */ + /** True after we've seen `-terastallize` for this mon. */ @@ - /** True when the mon has been faiunted at least once. */ + /** True when the mon has been fainted at least once. */ fainted: boolean; @@ /** Magic Room active. */ magicRoom: boolean; + magicRoomTurns: number; /** Wonder Room active. */ wonderRoom: boolean; + wonderRoomTurns: number; ``` …and decrement them in `tickTimedConditions` like the others. </details> Also applies to: 138-156 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` at line 86, Fix the docstring typo in BattleStateTracker: change "faiunted" to "fainted". Add turn counters for magicRoom and wonderRoom to FieldState (e.g., magicRoomTurns and wonderRoomTurns), update all places that set magicRoom/wonderRoom (such as handlers that process |fieldstart|/|fieldend| or similar) to initialize the corresponding *Turns fields, and change tickTimedConditions to decrement magicRoomTurns and wonderRoomTurns each turn and clear magicRoom/wonderRoom when their turns hit 0 (or derive the boolean from turns > 0). Ensure any initialization/clear logic and exports that previously used plain magicRoom/wonderRoom are updated to use the new paired-turn semantics so these effects expire correctly even if |fieldend| is missing. ``` </details> </blockquote></details> </blockquote></details> --- <details> <summary>ℹ️ Review info</summary> <details> <summary>⚙️ Run configuration</summary> **Configuration used**: defaults **Review profile**: CHILL **Plan**: Pro **Run ID**: `7eed26c0-a7eb-4116-b63b-eaf23b9c308b` </details> <details> <summary>📥 Commits</summary> Reviewing files that changed from the base of the PR and between 62ea0034766117e05b1d0a451589f6160cdadbdf and 457d8c7ba43c2b459ec0158cea7783b5c95d49bc. </details> <details> <summary>📒 Files selected for processing (28)</summary> * `sim/battle-stream.ts` * `sim/examples/battle-stream-example.ts` * `sim/index.ts` * `sim/tools/exhaustive-runner.ts` * `sim/tools/player-ai.ts` * `sim/tools/random-player-ai.ts` * `sim/tools/runner.ts` * `sim/tools/strategic-ai/engines/Engine.ts` * `sim/tools/strategic-ai/engines/HeuristicEngine.ts` * `sim/tools/strategic-ai/engines/LightHeuristicEngine.ts` * `sim/tools/strategic-ai/engines/MctsEngine.ts` * `sim/tools/strategic-ai/engines/OnePlySearchEngine.ts` * `sim/tools/strategic-ai/engines/RandomEngine.ts` * `sim/tools/strategic-ai/index.ts` * `sim/tools/strategic-ai/mechanics/DamageCalc.ts` * `sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` * `sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` * `sim/tools/strategic-ai/mechanics/TargetPicker.ts` * `sim/tools/strategic-ai/mechanics/TransformPolicy.ts` * `sim/tools/strategic-ai/policy/DifficultyPolicy.ts` * `sim/tools/strategic-ai/state/BattleStateTracker.ts` * `sim/tools/strategic-ai/state/LogParser.ts` * `sim/tools/strategic-ai/state/OpponentInference.ts` * `sim/tools/strategic-player-ai.ts` * `test/sim/tools/strategic-ai/damage-calc.js` * `test/sim/tools/strategic-ai/difficulty-gradient.js` * `test/sim/tools/strategic-ai/log-parser.js` * `test/sim/tools/strategic-ai/switch-evaluator.js` </details> <details> <summary>💤 Files with no reviewable changes (2)</summary> * sim/tools/strategic-player-ai.ts * sim/tools/random-player-ai.ts </details> </details> <!-- This is an auto-generated comment by CodeRabbit for review status -->
@ -0,0 +365,4 @@
let defStat = (isPhysical ? def.stats?.def : def.stats?.spd) ?? 100;
atkStat *= boostMul(atk.boosts?.[isPhysical ? "atk" : "spa"] || 0);
defStat *= boostMul(def.boosts?.[isPhysical ? "def" : "spd"] || 0);
const ability = (atk.ability || atk.baseAbility || "").toLowerCase();
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:44 +00:00

⚠️ Potential issue | 🟠 Major

simpleDamage will throw when foe stats is undefined.

PokemonSwitchRequestData.stats is regularly absent for foes whose set hasn't been revealed (the rest of this file already guards with me.stats?.spe ?? 0 / foe.stats?.spe ?? 0). Lines 349–352 access atk.stats.atk, atk.stats.spa, def.stats.def, def.stats.spd directly, so the very first damage estimate against an unrevealed foe will throw TypeError: Cannot read properties of undefined, which propagates out of decideForSlot and breaks the engine for that turn.

🛡️ Guard with optional chaining + sane defaults
 	const isPhysical = move.category === "Physical";
-	let atkStat = isPhysical ? atk.stats.atk : atk.stats.spa;
-	let defStat = isPhysical ? def.stats.def : def.stats.spd;
+	let atkStat = (isPhysical ? atk.stats?.atk : atk.stats?.spa) ?? 100;
+	let defStat = (isPhysical ? def.stats?.def : def.stats?.spd) ?? 100;
 	atkStat *= boostMul(atk.boosts[isPhysical ? "atk" : "spa"] || 0);
 	defStat *= boostMul(def.boosts[isPhysical ? "def" : "spd"] || 0);

Pick a more principled default if you have a species-based estimator handy (e.g. estimateMaxHp-style stats), but at minimum stop dereferencing through undefined.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

	const isPhysical = move.category === "Physical";
	let atkStat = (isPhysical ? atk.stats?.atk : atk.stats?.spa) ?? 100;
	let defStat = (isPhysical ? def.stats?.def : def.stats?.spd) ?? 100;
	atkStat *= boostMul(atk.boosts[isPhysical ? "atk" : "spa"] || 0);
	defStat *= boostMul(def.boosts[isPhysical ? "def" : "spd"] || 0);
	const ability = (atk.ability || atk.baseAbility || "").toLowerCase();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/LightHeuristicEngine.ts` around lines 348 -
353, simpleDamage currently dereferences foe and ally stats (atk.stats.atk,
atk.stats.spa, def.stats.def, def.stats.spd) and will throw when
PokemonSwitchRequestData.stats is undefined; update simpleDamage (and the call
site in decideForSlot) to use optional chaining with sensible defaults (e.g.,
atk.stats?.atk ?? estimatedAtkForSpecies(atk) or a numeric fallback like 0) and
apply boostMul to those fallback values, or use an existing species-based
estimator (like estimateMaxHp-style logic) to derive defaults; ensure the code
references the existing boostMul function and ability extraction (ability /
baseAbility) remain unchanged while replacing direct stat dereferences with
guarded expressions.

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ **`simpleDamage` will throw when foe `stats` is undefined.** `PokemonSwitchRequestData.stats` is regularly absent for foes whose set hasn't been revealed (the rest of this file already guards with `me.stats?.spe ?? 0` / `foe.stats?.spe ?? 0`). Lines 349–352 access `atk.stats.atk`, `atk.stats.spa`, `def.stats.def`, `def.stats.spd` directly, so the very first damage estimate against an unrevealed foe will throw `TypeError: Cannot read properties of undefined`, which propagates out of `decideForSlot` and breaks the engine for that turn. <details> <summary>🛡️ Guard with optional chaining + sane defaults</summary> ```diff const isPhysical = move.category === "Physical"; - let atkStat = isPhysical ? atk.stats.atk : atk.stats.spa; - let defStat = isPhysical ? def.stats.def : def.stats.spd; + let atkStat = (isPhysical ? atk.stats?.atk : atk.stats?.spa) ?? 100; + let defStat = (isPhysical ? def.stats?.def : def.stats?.spd) ?? 100; atkStat *= boostMul(atk.boosts[isPhysical ? "atk" : "spa"] || 0); defStat *= boostMul(def.boosts[isPhysical ? "def" : "spd"] || 0); ``` </details> Pick a more principled default if you have a species-based estimator handy (e.g. `estimateMaxHp`-style stats), but at minimum stop dereferencing through `undefined`. <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion const isPhysical = move.category === "Physical"; let atkStat = (isPhysical ? atk.stats?.atk : atk.stats?.spa) ?? 100; let defStat = (isPhysical ? def.stats?.def : def.stats?.spd) ?? 100; atkStat *= boostMul(atk.boosts[isPhysical ? "atk" : "spa"] || 0); defStat *= boostMul(def.boosts[isPhysical ? "def" : "spd"] || 0); const ability = (atk.ability || atk.baseAbility || "").toLowerCase(); ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/LightHeuristicEngine.ts` around lines 348 - 353, simpleDamage currently dereferences foe and ally stats (atk.stats.atk, atk.stats.spa, def.stats.def, def.stats.spd) and will throw when PokemonSwitchRequestData.stats is undefined; update simpleDamage (and the call site in decideForSlot) to use optional chaining with sensible defaults (e.g., atk.stats?.atk ?? estimatedAtkForSpecies(atk) or a numeric fallback like 0) and apply boostMul to those fallback values, or use an existing species-based estimator (like estimateMaxHp-style logic) to derive defaults; ensure the code references the existing boostMul function and ability extraction (ability / baseAbility) remain unchanged while replacing direct stat dereferences with guarded expressions. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:3bbc88f6-3d45-4af9-a9b6-ed20117c94ac --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +284,4 @@
}
}
return chosenMove;
});
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

Mega/Dynamax/Terastallize exclusivity isn't enforced across active slots.

canMegaEvo, canDynamax, canTerastallize, canUltraBurst are declared inside the .map(active, i) callback, so each active slot has its own copy. In doubles both active[0].canMegaEvo and active[1].canMegaEvo can be true (the request just signals "either mon is eligible"); each iteration independently rolls doTransform against megaProb and can emit ... mega (or dynamax/terastallize) for both slots. Showdown only allows one mega/dynamax/tera per battle, so the second command will be rejected or silently dropped — corrupting test runs and confusing replays generated by the runners.

🔧 Lift transformation flags out of the per-slot callback
-		const commands = activeSlots.map((active, i) => {
+		const transformsUsed = {
+			mega: false,
+			ultra: false,
+			dynamax: false,
+			tera: false,
+		};
+		const commands = activeSlots.map((active, i) => {
 			const me = myPokemon[i];
 			if (!me) return "pass";
 			if (me.condition.endsWith(" fnt") || me.commanding) return "pass";
 
-			let canMegaEvo = !!active.canMegaEvo;
-			let canUltraBurst = !!active.canUltraBurst;
+			const canMegaEvo = !!active.canMegaEvo && !transformsUsed.mega;
+			const canUltraBurst = !!active.canUltraBurst && !transformsUsed.ultra;
 			let canZMove = !!active.canZMove;
-			let canDynamax = !!active.canDynamax;
-			let canTerastallize = !!active.canTerastallize;
+			const canDynamax = !!active.canDynamax && !transformsUsed.dynamax;
+			const canTerastallize = !!active.canTerastallize && !transformsUsed.tera;
@@
 			if (doTransform) {
 				if (canTerastallize) {
-					canTerastallize = false;
+					transformsUsed.tera = true;
 					return `${chosenMove} terastallize`;
 				} else if (canDynamax) {
-					canDynamax = false;
+					transformsUsed.dynamax = true;
 					return `${chosenMove} dynamax`;
 				} else if (canMegaEvo) {
-					canMegaEvo = false;
+					transformsUsed.mega = true;
 					return `${chosenMove} mega`;
 				} else if (canUltraBurst) {
-					canUltraBurst = false;
+					transformsUsed.ultra = true;
 					return `${chosenMove} ultra`;
 				}
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 136 - 258, The
transform eligibility booleans (canMegaEvo, canUltraBurst, canDynamax,
canTerastallize) are declared inside the activeSlots.map callback so each active
slot can independently transform; lift those flags out of the per-slot callback
(declare them once before computing commands) and use them as shared "remaining"
flags when computing doTransform and when emitting a transform string (the
branches that return `${chosenMove} mega` / `ultra` / `dynamax` /
`terastallize`); after you emit a transform for one slot set the corresponding
outer flag to false so subsequent slots cannot transform. Ensure pick of
usingMaxMoves and doTransform refer to these outer flags (and still use ctx.prng
as before).

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ **Mega/Dynamax/Terastallize exclusivity isn't enforced across active slots.** `canMegaEvo`, `canDynamax`, `canTerastallize`, `canUltraBurst` are declared inside the `.map(active, i)` callback, so each active slot has its own copy. In doubles both `active[0].canMegaEvo` and `active[1].canMegaEvo` can be `true` (the request just signals "either mon is eligible"); each iteration independently rolls `doTransform` against `megaProb` and can emit `... mega` (or `dynamax`/`terastallize`) for both slots. Showdown only allows one mega/dynamax/tera per battle, so the second command will be rejected or silently dropped — corrupting test runs and confusing replays generated by the runners. <details> <summary>🔧 Lift transformation flags out of the per-slot callback</summary> ```diff - const commands = activeSlots.map((active, i) => { + const transformsUsed = { + mega: false, + ultra: false, + dynamax: false, + tera: false, + }; + const commands = activeSlots.map((active, i) => { const me = myPokemon[i]; if (!me) return "pass"; if (me.condition.endsWith(" fnt") || me.commanding) return "pass"; - let canMegaEvo = !!active.canMegaEvo; - let canUltraBurst = !!active.canUltraBurst; + const canMegaEvo = !!active.canMegaEvo && !transformsUsed.mega; + const canUltraBurst = !!active.canUltraBurst && !transformsUsed.ultra; let canZMove = !!active.canZMove; - let canDynamax = !!active.canDynamax; - let canTerastallize = !!active.canTerastallize; + const canDynamax = !!active.canDynamax && !transformsUsed.dynamax; + const canTerastallize = !!active.canTerastallize && !transformsUsed.tera; @@ if (doTransform) { if (canTerastallize) { - canTerastallize = false; + transformsUsed.tera = true; return `${chosenMove} terastallize`; } else if (canDynamax) { - canDynamax = false; + transformsUsed.dynamax = true; return `${chosenMove} dynamax`; } else if (canMegaEvo) { - canMegaEvo = false; + transformsUsed.mega = true; return `${chosenMove} mega`; } else if (canUltraBurst) { - canUltraBurst = false; + transformsUsed.ultra = true; return `${chosenMove} ultra`; } } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 136 - 258, The transform eligibility booleans (canMegaEvo, canUltraBurst, canDynamax, canTerastallize) are declared inside the activeSlots.map callback so each active slot can independently transform; lift those flags out of the per-slot callback (declare them once before computing commands) and use them as shared "remaining" flags when computing doTransform and when emitting a transform string (the branches that return `${chosenMove} mega` / `ultra` / `dynamax` / `terastallize`); after you emit a transform for one slot set the corresponding outer flag to false so subsequent slots cannot transform. Ensure pick of usingMaxMoves and doTransform refer to these outer flags (and still use ctx.prng as before). ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:3bbc88f6-3d45-4af9-a9b6-ed20117c94ac --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +1,943 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm there's no other code path computing the Tera-on-STAB multiplier.
rg -nP -C3 'terastallized|teraType|teraStab' --type=ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 50392


Tera-on-existing-STAB never gets its 2.0×/2.25× multiplier.

Callers (e.g. TransformPolicy.teraOffensiveValue, line 131) set attacker.types = [teraType] and terastallized: true when terastallizing. With that, line 287's moveTypeMatchesUserType returns true whenever moveType === teraType, and the block at 288–291 applies 1.5× (or 2.0× with Adaptability). The dedicated Tera bonus at line 293 then short-circuits because !stabApplies is false.

The actual gen 9 rule for Tera-into-original-STAB is 2.0× (and 2.25× with Adaptability). In its current form, this calc undercounts that case by ~33% (or ~12.5% with Adaptability), which directly biases teraOffensiveValue and KO-conversion checks in TransformPolicy.

The CalcPokemon shape doesn't carry the original types post-Tera, so a clean fix likely needs an extra field (e.g. originalTypes) or for callers to keep the original types and only set teraType + terastallized: true. Worth fixing now while there's only one caller.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts` around lines 286 - 296, The
STAB logic must consider the attacker's original types when terastallized: add
an originalTypes (string[] or similar) field to CalcPokemon and ensure callers
(e.g., TransformPolicy.teraOffensiveValue) populate it before setting
attacker.types to [teraType]; then change the STAB computation in DamageCalc
(around moveTypeMatchesUserType, stabApplies, and the terastallized block) to
first compute whether the move matches the original types (e.g.,
originalStabApplies = attacker.originalTypes ?
moveTypeMatchesUserTypeAgainstTypes(moveType, attacker.originalTypes) :
stabApplies) and use originalStabApplies to apply the 2.0× (or 2.25× with
Adaptability) multiplier for the “Tera into existing STAB” case, leaving the
existing tera fallback (1.5× when moveType === attacker.teraType and no original
STAB) as-is.

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ <details> <summary>🧩 Analysis chain</summary> 🏁 Script executed: ```shell #!/bin/bash # Confirm there's no other code path computing the Tera-on-STAB multiplier. rg -nP -C3 'terastallized|teraType|teraStab' --type=ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 50392 --- </details> **Tera-on-existing-STAB never gets its 2.0×/2.25× multiplier.** Callers (e.g. `TransformPolicy.teraOffensiveValue`, line 131) set `attacker.types = [teraType]` and `terastallized: true` when terastallizing. With that, line 287's `moveTypeMatchesUserType` returns true whenever `moveType === teraType`, and the block at 288–291 applies 1.5× (or 2.0× with Adaptability). The dedicated Tera bonus at line 293 then short-circuits because `!stabApplies` is false. The actual gen 9 rule for Tera-into-original-STAB is 2.0× (and 2.25× with Adaptability). In its current form, this calc undercounts that case by ~33% (or ~12.5% with Adaptability), which directly biases `teraOffensiveValue` and KO-conversion checks in `TransformPolicy`. The CalcPokemon shape doesn't carry the *original* types post-Tera, so a clean fix likely needs an extra field (e.g. `originalTypes`) or for callers to keep the original types and only set `teraType` + `terastallized: true`. Worth fixing now while there's only one caller. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts` around lines 286 - 296, The STAB logic must consider the attacker's original types when terastallized: add an originalTypes (string[] or similar) field to CalcPokemon and ensure callers (e.g., TransformPolicy.teraOffensiveValue) populate it before setting attacker.types to [teraType]; then change the STAB computation in DamageCalc (around moveTypeMatchesUserType, stabApplies, and the terastallized block) to first compute whether the move matches the original types (e.g., originalStabApplies = attacker.originalTypes ? moveTypeMatchesUserTypeAgainstTypes(moveType, attacker.originalTypes) : stabApplies) and use originalStabApplies to apply the 2.0× (or 2.25× with Adaptability) multiplier for the “Tera into existing STAB” case, leaving the existing tera fallback (1.5× when moveType === attacker.teraType and no original STAB) as-is. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:d8985210-6c07-4d20-8bf3-b624e4aa1557 --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +707,4 @@
if (useStage > 0) value = Math.floor(value * (2 + useStage) / 2);
else if (useStage < 0) value = Math.floor(value * 2 / (2 - useStage));
return value;
}
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

Crit ignores stat stages in the wrong direction (defender) and not at all (attacker).

Showdown's crit rules are: ignore the defender's positive defensive stages and the attacker's negative offensive stages — they're the boosts that would otherwise help the side hit by the crit. The current code does the opposite for the defender and skips it entirely for the attacker:

  • computeDefensiveStat (line 647) calls effectiveStat(defender, stat, !!forceCrit). With ignoreNegativeBoost=true, effectiveStat clamps stage < 0 to 0 (line 669). That zeroes the defender's negative defensive stages — which actually helped us — and leaves positive stages intact, so a +6 Def defender keeps full +6 against our crit.
  • computeOffensiveStat (line 612) never passes forceCrit, so an attacker with -2 Atk from Intimidate still has its negative stage applied during a crit.

This will materially over-/under-count expected damage in any rollout that uses forceCrit: true, and the MCTS/OnePly engines do rely on those numbers.

Suggested fix
-function effectiveStat(mon: CalcPokemon, stat: keyof StatBlock, ignoreNegativeBoost = false): number {
+/**
+ * `@param` ignoreNegativeStage - drop stages < 0 (use for the attacker on crit).
+ * `@param` ignorePositiveStage - drop stages > 0 (use for the defender on crit).
+ */
+function effectiveStat(
+    mon: CalcPokemon,
+    stat: keyof StatBlock,
+    ignoreNegativeStage = false,
+    ignorePositiveStage = false,
+): number {
   const stats = ensureStats(mon);
   let value = stats[stat] || 1;
   const stage = mon.boosts[stat] || 0;
-  const useStage = ignoreNegativeBoost && stage < 0 ? 0 : stage;
+  let useStage = stage;
+  if (ignoreNegativeStage && useStage < 0) useStage = 0;
+  if (ignorePositiveStage && useStage > 0) useStage = 0;
   if (useStage > 0) value = Math.floor(value * (2 + useStage) / 2);
   else if (useStage < 0) value = Math.floor(value * 2 / (2 - useStage));
   return value;
 }

…and update the call sites:

-    baseStat = effectiveStat(input.attacker, physical ? "atk" : "spa");
+    baseStat = effectiveStat(input.attacker, physical ? "atk" : "spa",
+        /* ignoreNegativeStage = */ !!input.forceCrit);
...
-  let value = effectiveStat(input.defender, stat, !!input.forceCrit);
+  let value = effectiveStat(input.defender, stat,
+      /* ignoreNegativeStage = */ false,
+      /* ignorePositiveStage = */ !!input.forceCrit);

(Body Press / Foul Play call sites need the same treatment for the stat they actually read.)

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ **Crit ignores stat stages in the wrong direction (defender) and not at all (attacker).** Showdown's crit rules are: ignore the defender's *positive* defensive stages and the attacker's *negative* offensive stages — they're the boosts that would otherwise help the side hit by the crit. The current code does the opposite for the defender and skips it entirely for the attacker: - `computeDefensiveStat` (line 647) calls `effectiveStat(defender, stat, !!forceCrit)`. With `ignoreNegativeBoost=true`, `effectiveStat` clamps `stage < 0` to 0 (line 669). That zeroes the defender's *negative* defensive stages — which actually helped us — and leaves positive stages intact, so a +6 Def defender keeps full +6 against our crit. - `computeOffensiveStat` (line 612) never passes `forceCrit`, so an attacker with -2 Atk from Intimidate still has its negative stage applied during a crit. This will materially over-/under-count expected damage in any rollout that uses `forceCrit: true`, and the MCTS/OnePly engines do rely on those numbers. <details> <summary>Suggested fix</summary> ```diff -function effectiveStat(mon: CalcPokemon, stat: keyof StatBlock, ignoreNegativeBoost = false): number { +/** + * `@param` ignoreNegativeStage - drop stages < 0 (use for the attacker on crit). + * `@param` ignorePositiveStage - drop stages > 0 (use for the defender on crit). + */ +function effectiveStat( + mon: CalcPokemon, + stat: keyof StatBlock, + ignoreNegativeStage = false, + ignorePositiveStage = false, +): number { const stats = ensureStats(mon); let value = stats[stat] || 1; const stage = mon.boosts[stat] || 0; - const useStage = ignoreNegativeBoost && stage < 0 ? 0 : stage; + let useStage = stage; + if (ignoreNegativeStage && useStage < 0) useStage = 0; + if (ignorePositiveStage && useStage > 0) useStage = 0; if (useStage > 0) value = Math.floor(value * (2 + useStage) / 2); else if (useStage < 0) value = Math.floor(value * 2 / (2 - useStage)); return value; } ``` …and update the call sites: ```diff - baseStat = effectiveStat(input.attacker, physical ? "atk" : "spa"); + baseStat = effectiveStat(input.attacker, physical ? "atk" : "spa", + /* ignoreNegativeStage = */ !!input.forceCrit); ... - let value = effectiveStat(input.defender, stat, !!input.forceCrit); + let value = effectiveStat(input.defender, stat, + /* ignoreNegativeStage = */ false, + /* ignorePositiveStage = */ !!input.forceCrit); ``` (Body Press / Foul Play call sites need the same treatment for the stat they actually read.) </details> <!-- fingerprinting:phantom:medusa:nectarine:d8985210-6c07-4d20-8bf3-b624e4aa1557 --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +1,466 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm there are non-self target moves in the Showdown data that would hit this branch.
rg -nP -C2 "boosts:\s*\{[^}]*-\d" --type=ts | head -n 80
echo "---"
# Sanity-check the Move.target type literal we rely on for the suggested fix.
ast-grep --pattern $'interface MoveData {
  $$$
  target$_: $_;
  $$$
}'
rg -nP "target\s*:\s*['\"]self['\"]" --type=ts -C1 | head -n 40

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 3090


🏁 Script executed:

# Check if MoveEvaluator.ts exists
find . -name "MoveEvaluator.ts" -type f

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 131


🏁 Script executed:

# Search for Growl, Charm, TailWhip, Sand Attack move definitions
rg -nA5 "^\s*(growl|charm|tailwhip|sandattack|featherdance|cottonspore):\s*\{" data/moves.ts | head -60

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 893


🏁 Script executed:

# Also check for these moves with different case/format
rg -nA10 "\"(growl|charm|tailwhip|sandattack)\"" data/moves.ts | head -100

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 61


🏁 Script executed:

# Get full definitions of these moves
rg -nA15 "^\s*charm:\s*\{" data/moves.ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 474


🏁 Script executed:

rg -nA15 "^\s*growl:\s*\{" data/moves.ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 494


🏁 Script executed:

rg -nA15 "^\s*tailwhip:\s*\{" data/moves.ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 493


🏁 Script executed:

# Now let's look at the actual MoveEvaluator.ts file around lines 162-166
sed -n '160,170p' sim/tools/strategic-ai/mechanics/MoveEvaluator.ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 416


🏁 Script executed:

# Look at the scoreBoostMove function
sed -n '319,344p' sim/tools/strategic-ai/mechanics/MoveEvaluator.ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 954


🏁 Script executed:

# Check the selfdestruct code around lines 121-126
sed -n '120,130p' sim/tools/strategic-ai/mechanics/MoveEvaluator.ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 529


🏁 Script executed:

# Check the Move/MoveData type definition for the selfdestruct field
rg -nA2 "selfdestruct" data/mods/ --type=ts | head -40

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 2396


🏁 Script executed:

# Check Move data for selfdestruct moves and their values
rg -nB2 -A8 "selfdestruct" data/moves.ts | head -60

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 1626


🏁 Script executed:

# Let's verify what target values exist and check if "self" is used
rg -n "target:\s*['\"]self['\"]" data/moves.ts | head -20

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 531


🏁 Script executed:

# Check if there are any moves with move.boosts (not move.self?.boosts) that have target "self"
rg -nB5 "target:\s*['\"]self['\"]" data/moves.ts | rg -B5 "boosts:\s*\{" | head -40

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 710


Foe-target debuff moves are misclassified as self-boosts.

The condition move.boosts || move.self?.boosts gates into scoreBoostMove, which does not distinguish between self-boosts and opponent debuffs. Moves like growl (atk: -1, target: "allAdjacentFoes"), charm (atk: -2, target: "normal"), and tailwhip (def: -1, target: "allAdjacentFoes") have negative stat changes in move.boosts with non-self targets. When routed through scoreBoostMove, they produce negative scores (e.g., Growl → -1 * 9 = -9), making them worse than unknownStatus (score 2), so the AI avoids them.

Restrict the boost branch to genuine self-boosts by checking move.target === "self" alongside move.self?.boosts, and route foe-target debuffs to a dedicated debuff scorer.

As a minor follow-up: the selfdestruct check at line 124 includes m.selfdestruct === true, which is dead code. Move data only uses string literals ("always", "ifHit"), never boolean true.

🛠 Suggested fix
-	// Stat-up moves.
-	if (move.boosts || move.self?.boosts || moveId === "shellsmash" || moveId === "bellydrum") {
-		const boostScore = scoreBoostMove(move, moveId, ctx);
-		return { moveId, score: boostScore, rationale: "boost" };
-	}
+	// Self stat-up moves.
+	const isSelfBoost =
+		move.self?.boosts ||
+		(move.boosts && move.target === "self") ||
+		moveId === "shellsmash" || moveId === "bellydrum";
+	if (isSelfBoost) {
+		const boostScore = scoreBoostMove(move, moveId, ctx);
+		return { moveId, score: boostScore, rationale: "boost" };
+	}
+	// Foe stat-drop moves (Growl, Charm, Sand Attack, ...).
+	if (move.boosts) {
+		const debuffScore = scoreDebuffMove(move, ctx);
+		return { moveId, score: debuffScore, rationale: "debuff" };
+	}

…and a companion scorer that inverts the sign and applies diminishing returns against the defender's current boosts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 162 - 166,
The boost-handling branch in MoveEvaluator misclassifies foe-target debuffs as
self-boosts; change the condition so only genuine self boosts (move.self?.boosts
&& move.target === "self") or explicit self-boost moves (moveId === "shellsmash"
|| moveId === "bellydrum") call scoreBoostMove, and add a new
scoreDebuffMove(move, moveId, ctx) to handle moves where move.boosts has
negative values and move.target targets foes (e.g., target !== "self" or
contains "Foe"); scoreDebuffMove should invert the sign, apply diminishing
returns against the defender's current boosts, and return { moveId, score,
rationale: "debuff" }; also remove the dead boolean check m.selfdestruct ===
true (move data uses string literals) wherever it appears.

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ <details> <summary>🧩 Analysis chain</summary> 🏁 Script executed: ```shell #!/bin/bash # Confirm there are non-self target moves in the Showdown data that would hit this branch. rg -nP -C2 "boosts:\s*\{[^}]*-\d" --type=ts | head -n 80 echo "---" # Sanity-check the Move.target type literal we rely on for the suggested fix. ast-grep --pattern $'interface MoveData { $$$ target$_: $_; $$$ }' rg -nP "target\s*:\s*['\"]self['\"]" --type=ts -C1 | head -n 40 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 3090 --- 🏁 Script executed: ```shell # Check if MoveEvaluator.ts exists find . -name "MoveEvaluator.ts" -type f ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 131 --- 🏁 Script executed: ```shell # Search for Growl, Charm, TailWhip, Sand Attack move definitions rg -nA5 "^\s*(growl|charm|tailwhip|sandattack|featherdance|cottonspore):\s*\{" data/moves.ts | head -60 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 893 --- 🏁 Script executed: ```shell # Also check for these moves with different case/format rg -nA10 "\"(growl|charm|tailwhip|sandattack)\"" data/moves.ts | head -100 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 61 --- 🏁 Script executed: ```shell # Get full definitions of these moves rg -nA15 "^\s*charm:\s*\{" data/moves.ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 474 --- 🏁 Script executed: ```shell rg -nA15 "^\s*growl:\s*\{" data/moves.ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 494 --- 🏁 Script executed: ```shell rg -nA15 "^\s*tailwhip:\s*\{" data/moves.ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 493 --- 🏁 Script executed: ```shell # Now let's look at the actual MoveEvaluator.ts file around lines 162-166 sed -n '160,170p' sim/tools/strategic-ai/mechanics/MoveEvaluator.ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 416 --- 🏁 Script executed: ```shell # Look at the scoreBoostMove function sed -n '319,344p' sim/tools/strategic-ai/mechanics/MoveEvaluator.ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 954 --- 🏁 Script executed: ```shell # Check the selfdestruct code around lines 121-126 sed -n '120,130p' sim/tools/strategic-ai/mechanics/MoveEvaluator.ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 529 --- 🏁 Script executed: ```shell # Check the Move/MoveData type definition for the selfdestruct field rg -nA2 "selfdestruct" data/mods/ --type=ts | head -40 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 2396 --- 🏁 Script executed: ```shell # Check Move data for selfdestruct moves and their values rg -nB2 -A8 "selfdestruct" data/moves.ts | head -60 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 1626 --- 🏁 Script executed: ```shell # Let's verify what target values exist and check if "self" is used rg -n "target:\s*['\"]self['\"]" data/moves.ts | head -20 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 531 --- 🏁 Script executed: ```shell # Check if there are any moves with move.boosts (not move.self?.boosts) that have target "self" rg -nB5 "target:\s*['\"]self['\"]" data/moves.ts | rg -B5 "boosts:\s*\{" | head -40 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 710 --- </details> **Foe-target debuff moves are misclassified as self-boosts.** The condition `move.boosts || move.self?.boosts` gates into `scoreBoostMove`, which does not distinguish between self-boosts and opponent debuffs. Moves like `growl` (atk: -1, target: "allAdjacentFoes"), `charm` (atk: -2, target: "normal"), and `tailwhip` (def: -1, target: "allAdjacentFoes") have negative stat changes in `move.boosts` with non-self targets. When routed through `scoreBoostMove`, they produce negative scores (e.g., Growl → -1 * 9 = -9), making them worse than unknownStatus (score 2), so the AI avoids them. Restrict the boost branch to genuine self-boosts by checking `move.target === "self"` alongside `move.self?.boosts`, and route foe-target debuffs to a dedicated debuff scorer. As a minor follow-up: the selfdestruct check at line 124 includes `m.selfdestruct === true`, which is dead code. Move data only uses string literals ("always", "ifHit"), never boolean true. <details> <summary>🛠 Suggested fix</summary> ```diff - // Stat-up moves. - if (move.boosts || move.self?.boosts || moveId === "shellsmash" || moveId === "bellydrum") { - const boostScore = scoreBoostMove(move, moveId, ctx); - return { moveId, score: boostScore, rationale: "boost" }; - } + // Self stat-up moves. + const isSelfBoost = + move.self?.boosts || + (move.boosts && move.target === "self") || + moveId === "shellsmash" || moveId === "bellydrum"; + if (isSelfBoost) { + const boostScore = scoreBoostMove(move, moveId, ctx); + return { moveId, score: boostScore, rationale: "boost" }; + } + // Foe stat-drop moves (Growl, Charm, Sand Attack, ...). + if (move.boosts) { + const debuffScore = scoreDebuffMove(move, ctx); + return { moveId, score: debuffScore, rationale: "debuff" }; + } ``` …and a companion scorer that inverts the sign and applies diminishing returns against the defender's current boosts. </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 162 - 166, The boost-handling branch in MoveEvaluator misclassifies foe-target debuffs as self-boosts; change the condition so only genuine self boosts (move.self?.boosts && move.target === "self") or explicit self-boost moves (moveId === "shellsmash" || moveId === "bellydrum") call scoreBoostMove, and add a new scoreDebuffMove(move, moveId, ctx) to handle moves where move.boosts has negative values and move.target targets foes (e.g., target !== "self" or contains "Foe"); scoreDebuffMove should invert the sign, apply diminishing returns against the defender's current boosts, and return { moveId, score, rationale: "debuff" }; also remove the dead boolean check m.selfdestruct === true (move data uses string literals) wherever it appears. ``` </details> <!-- fingerprinting:phantom:poseidon:nectarine:adbbdf85-94e8-42af-9291-b6bcbdb1d298 --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +1,280 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

Side state is swapped when scoring foe → us damage.

bestAttackingDamage hardcodes attackerSide: tracker.sides[tracker.mySide] and defenderSide: tracker.sides[tracker.foeSide] (lines 215–216). That works for the us → foe call on line 89, but for the foe → us call on line 87 the attacker is the foe and the defender is us, so the sides are inverted:

  • defenderSide is consumed in DamageCalc for Reflect / Light Screen / Aurora Veil (see sim/tools/strategic-ai/mechanics/DamageCalc.ts lines 336–339). With it pointing at foeSide, the foe's screens get applied to its own attacks against us, while our screens get ignored entirely.
  • attackerSide is documented to carry Tailwind-style state and would be similarly wrong.

Net effect: matchup scores systematically misweigh foeDealFraction whenever either side has screens / tailwind up — exactly the situations switch decisions are most sensitive to.

Suggested fix: thread the side ids through to the calc
 function bestAttackingDamage(
     attacker: CalcPokemon,
     defender: CalcPokemon,
     tracker: BattleStateTracker,
     attackerMon: TrackedPokemon,
     defenderMon: TrackedPokemon,
-    useKnownOnly: boolean
+    useKnownOnly: boolean,
+    attackerSideId: SideId,
+    defenderSideId: SideId
 ): { avgDamage: number, moveId: string } | null {
   ...
       const range = calculateDamage({
         attacker,
         defender,
         move: m,
         field: tracker.field,
-        attackerSide: tracker.sides[tracker.mySide],
-        defenderSide: tracker.sides[tracker.foeSide],
+        attackerSide: tracker.sides[attackerSideId],
+        defenderSide: tracker.sides[defenderSideId],
       });

…and update the two call sites in evaluateMatchup:

-  const foeBest = bestAttackingDamage(foeCalc, myCalc, tracker, foe, mon, /* known */ true);
-  const myBest = bestAttackingDamage(myCalc, foeCalc, tracker, mon, foe, /* known */ false);
+  const foeBest = bestAttackingDamage(
+    foeCalc, myCalc, tracker, foe, mon, /* known */ true,
+    tracker.foeSide, tracker.mySide,
+  );
+  const myBest = bestAttackingDamage(
+    myCalc, foeCalc, tracker, mon, foe, /* known */ false,
+    tracker.mySide, tracker.foeSide,
+  );

Also applies to: 209-217

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` around lines 86 - 102,
bestAttackingDamage is using tracker.sides[tracker.mySide] as attackerSide and
tracker.sides[tracker.foeSide] as defenderSide regardless of which Pokemon is
attacking, so when evaluateMatchup calls bestAttackingDamage for the foe→us case
the side roles are swapped and screens/tailwind apply to the wrong side; fix by
adding explicit attackerSideId and defenderSideId parameters (or pass the
correct side objects) through bestAttackingDamage into DamageCalc so
attackerSide/defenderSide reflect the actual attacker and defender, update the
two evaluateMatchup call sites that compute foeBest and myBest to pass the sides
in the correct order (use tracker.sides[tracker.foeSide] as attacker when
computing foeBest and tracker.sides[tracker.mySide] as attacker when computing
myBest), and adjust DamageCalc usage to consume these side args for
Reflect/Light Screen/Aurora Veil and Tailwind logic.

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ **Side state is swapped when scoring foe → us damage.** `bestAttackingDamage` hardcodes `attackerSide: tracker.sides[tracker.mySide]` and `defenderSide: tracker.sides[tracker.foeSide]` (lines 215–216). That works for the us → foe call on line 89, but for the foe → us call on line 87 the attacker is the foe and the defender is us, so the sides are inverted: - `defenderSide` is consumed in `DamageCalc` for Reflect / Light Screen / Aurora Veil (see `sim/tools/strategic-ai/mechanics/DamageCalc.ts` lines 336–339). With it pointing at `foeSide`, the foe's screens get applied to its *own* attacks against us, while our screens get ignored entirely. - `attackerSide` is documented to carry Tailwind-style state and would be similarly wrong. Net effect: matchup scores systematically misweigh `foeDealFraction` whenever either side has screens / tailwind up — exactly the situations switch decisions are most sensitive to. <details> <summary>Suggested fix: thread the side ids through to the calc</summary> ```diff function bestAttackingDamage( attacker: CalcPokemon, defender: CalcPokemon, tracker: BattleStateTracker, attackerMon: TrackedPokemon, defenderMon: TrackedPokemon, - useKnownOnly: boolean + useKnownOnly: boolean, + attackerSideId: SideId, + defenderSideId: SideId ): { avgDamage: number, moveId: string } | null { ... const range = calculateDamage({ attacker, defender, move: m, field: tracker.field, - attackerSide: tracker.sides[tracker.mySide], - defenderSide: tracker.sides[tracker.foeSide], + attackerSide: tracker.sides[attackerSideId], + defenderSide: tracker.sides[defenderSideId], }); ``` …and update the two call sites in `evaluateMatchup`: ```diff - const foeBest = bestAttackingDamage(foeCalc, myCalc, tracker, foe, mon, /* known */ true); - const myBest = bestAttackingDamage(myCalc, foeCalc, tracker, mon, foe, /* known */ false); + const foeBest = bestAttackingDamage( + foeCalc, myCalc, tracker, foe, mon, /* known */ true, + tracker.foeSide, tracker.mySide, + ); + const myBest = bestAttackingDamage( + myCalc, foeCalc, tracker, mon, foe, /* known */ false, + tracker.mySide, tracker.foeSide, + ); ``` </details> Also applies to: 209-217 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` around lines 86 - 102, bestAttackingDamage is using tracker.sides[tracker.mySide] as attackerSide and tracker.sides[tracker.foeSide] as defenderSide regardless of which Pokemon is attacking, so when evaluateMatchup calls bestAttackingDamage for the foe→us case the side roles are swapped and screens/tailwind apply to the wrong side; fix by adding explicit attackerSideId and defenderSideId parameters (or pass the correct side objects) through bestAttackingDamage into DamageCalc so attackerSide/defenderSide reflect the actual attacker and defender, update the two evaluateMatchup call sites that compute foeBest and myBest to pass the sides in the correct order (use tracker.sides[tracker.foeSide] as attacker when computing foeBest and tracker.sides[tracker.mySide] as attacker when computing myBest), and adjust DamageCalc usage to consume these side args for Reflect/Light Screen/Aurora Veil and Tailwind logic. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:d8985210-6c07-4d20-8bf3-b624e4aa1557 --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +1,148 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

Follow Me / Rage Powder penalty likely applied to the wrong slot.

Follow Me and Rage Powder redirect every single-target move on our side to the user, regardless of which foe slot we explicitly target — picking the other foe doesn't avoid the redirection. As written, lines 119–121 penalize the redirector, biasing us to target the non-redirector even though the actual hit will still land on the redirector. The net effect is "we lie to ourselves about the target" with no mechanical benefit.

If the intent is "don't waste a kill move on the support mon," that's the opposite signal of what this produces. If the intent is "approximate that we can ignore the support mon," the penalty should be applied to the other foes (so the redirector becomes the highest-scoring target, since the move ends up there). A reasonable rewrite:

🔧 Apply redirection penalty to non-redirector slots
-	if (foe.volatiles.has("followme") || foe.volatiles.has("ragepowder")) {
-		score -= 800;
-	}
+	const redirector = input.foeSlots.find(
+		f => f.volatiles.has("followme") || f.volatiles.has("ragepowder")
+	);
+	if (redirector && redirector.id !== foe.id) {
+		// Single-target moves will be redirected to the support mon
+		// regardless of which foe we point at; avoid optimizing for a
+		// slot we won't actually hit.
+		score -= 800;
+	}

Same comment applies to stormdrain/lightningrod on the foe side at lines 114–118 — those are full redirections too, not partial ones. Consider unifying the redirection model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts` around lines 105 - 121, The
code applies Follow Me / Rage Powder and partial Storm Drain/Lightning Rod
penalties to the redirector slot (foe) itself, which wrongly biases the picker
away from the actual eventual target; update TargetPicker logic so redirection
penalties are applied to the other foe slots instead of the redirector: when
checking volatiles ("followme"/"ragepowder") and abilities
("stormdrain"/"lightningrod") on input.foeSlots and input.allySlots, detect the
redirector and subtract the penalty from the non-redirect target entries (i.e.,
reduce score on other foes when a redirector exists, and treat foe-side
stormdrain/lightningrod as full redirections by penalizing the original intended
targets rather than the redirecting slot) so the redirector becomes the
effectively preferred target.

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ **Follow Me / Rage Powder penalty likely applied to the wrong slot.** Follow Me and Rage Powder redirect every single-target move on our side to the user, regardless of which foe slot we explicitly target — picking the other foe doesn't avoid the redirection. As written, lines 119–121 *penalize* the redirector, biasing us to target the non-redirector even though the actual hit will still land on the redirector. The net effect is "we lie to ourselves about the target" with no mechanical benefit. If the intent is "don't waste a kill move on the support mon," that's the opposite signal of what this produces. If the intent is "approximate that we can ignore the support mon," the penalty should be applied to the *other* foes (so the redirector becomes the highest-scoring target, since the move ends up there). A reasonable rewrite: <details> <summary>🔧 Apply redirection penalty to non-redirector slots</summary> ```diff - if (foe.volatiles.has("followme") || foe.volatiles.has("ragepowder")) { - score -= 800; - } + const redirector = input.foeSlots.find( + f => f.volatiles.has("followme") || f.volatiles.has("ragepowder") + ); + if (redirector && redirector.id !== foe.id) { + // Single-target moves will be redirected to the support mon + // regardless of which foe we point at; avoid optimizing for a + // slot we won't actually hit. + score -= 800; + } ``` </details> Same comment applies to `stormdrain`/`lightningrod` on the foe side at lines 114–118 — those are full redirections too, not partial ones. Consider unifying the redirection model. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts` around lines 105 - 121, The code applies Follow Me / Rage Powder and partial Storm Drain/Lightning Rod penalties to the redirector slot (foe) itself, which wrongly biases the picker away from the actual eventual target; update TargetPicker logic so redirection penalties are applied to the other foe slots instead of the redirector: when checking volatiles ("followme"/"ragepowder") and abilities ("stormdrain"/"lightningrod") on input.foeSlots and input.allySlots, detect the redirector and subtract the penalty from the non-redirect target entries (i.e., reduce score on other foes when a redirector exists, and treat foe-side stormdrain/lightningrod as full redirections by penalizing the original intended targets rather than the redirecting slot) so the redirector becomes the effectively preferred target. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:3bbc88f6-3d45-4af9-a9b6-ed20117c94ac --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +673,4 @@
mon.baseAbility = mon.ability;
}
return;
}
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

baseAbility is incorrectly inferred from Trace/Skill Swap/Imposter events.

The handler treats every |-ability| event as a base-ability reveal: if (!mon.baseAbility) mon.baseAbility = mon.ability; runs even when event.from is ability: Trace, ability: Imposter, move: Skill Swap, etc. The first time we observe such a mon, its baseAbility is locked to the acquired ability and persists across subsequent switches — which will mislead any consumer that uses baseAbility to reason about the foe's set (e.g. switch evaluators projecting hazard interactions on switch-in).

Proposed fix — only seed `baseAbility` on a non-derived reveal
 			case "ability": {
 				const mon = this.resolveByRef(event.pokemon);
 				mon.ability = toID(event.ability);
-				if (!mon.baseAbility) mon.baseAbility = mon.ability;
+				// Only treat as the *base* ability when the event isn't an
+				// effect-driven swap/copy (Trace, Imposter, Skill Swap, etc.).
+				if (!mon.baseAbility && !event.from) mon.baseAbility = mon.ability;
 				return;
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 636 - 641,
The "ability" event handler in BattleStateTracker incorrectly seeds
mon.baseAbility from derived sources; update the handler that uses
resolveByRef(event.pokemon) so it only sets mon.baseAbility when the reveal is
not from a derived source (e.g., ignore when event.from indicates "ability:
Trace" or "ability: Imposter", or when event.move === "Skill Swap" or other
ability-stealing moves); replace the unconditional if (!mon.baseAbility)
mon.baseAbility = mon.ability with a guarded check that computes isDerived
(using event.from and event.move/event.ability metadata) and only assigns
baseAbility when !mon.baseAbility && !isDerived so baseAbility reflects the
species' true ability, not a temporary copied one.
_⚠️ Potential issue_ | _🟠 Major_ **`baseAbility` is incorrectly inferred from Trace/Skill Swap/Imposter events.** The handler treats every `|-ability|` event as a base-ability reveal: `if (!mon.baseAbility) mon.baseAbility = mon.ability;` runs even when `event.from` is `ability: Trace`, `ability: Imposter`, `move: Skill Swap`, etc. The first time we observe such a mon, its `baseAbility` is locked to the acquired ability and persists across subsequent switches — which will mislead any consumer that uses `baseAbility` to reason about the foe's set (e.g. switch evaluators projecting hazard interactions on switch-in). <details> <summary>Proposed fix — only seed `baseAbility` on a non-derived reveal</summary> ```diff case "ability": { const mon = this.resolveByRef(event.pokemon); mon.ability = toID(event.ability); - if (!mon.baseAbility) mon.baseAbility = mon.ability; + // Only treat as the *base* ability when the event isn't an + // effect-driven swap/copy (Trace, Imposter, Skill Swap, etc.). + if (!mon.baseAbility && !event.from) mon.baseAbility = mon.ability; return; } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 636 - 641, The "ability" event handler in BattleStateTracker incorrectly seeds mon.baseAbility from derived sources; update the handler that uses resolveByRef(event.pokemon) so it only sets mon.baseAbility when the reveal is not from a derived source (e.g., ignore when event.from indicates "ability: Trace" or "ability: Imposter", or when event.move === "Skill Swap" or other ability-stealing moves); replace the unconditional if (!mon.baseAbility) mon.baseAbility = mon.ability with a guarded check that computes isDerived (using event.from and event.move/event.ability metadata) and only assigns baseAbility when !mon.baseAbility && !isDerived so baseAbility reflects the species' true ability, not a temporary copied one. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:53fcf297-4073-4fba-a844-dfadac59c185 --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit -->
@ -0,0 +823,4 @@
else if (ss.spikes >= 3) damage += 1 / 4;
}
return damage;
}
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🔴 Critical

hazardDamageFraction reimplements (incorrectly) the grounded check from isGrounded.

The inline grounded check on lines 779-781 only considers Flying type, Levitate, and Air Balloon. It misses every other relevant condition that isGrounded (lines 751-762) handles, plus a couple isGrounded itself omits:

  • Gravity — should ground every mon. Skipped here, so a Flying-type incoming under Gravity is incorrectly reported as Spikes-immune.
  • Iron Ball — forces grounding even on Flying/Levitate. Skipped, so an Iron-Ball Skarmory is incorrectly reported as Spikes-immune.
  • Smack Down / Ingrain — force grounding. Skipped.
  • Magnet Rise — lifts a non-Flying mon. Skipped, so a Magnet-Rise Heatran is incorrectly reported as taking Spikes damage.

Since this method drives switch evaluation (SwitchEvaluator per the AI summary), wrong hazard estimates will translate directly into bad switch decisions. Please extract a single mon-keyed groundedness helper and reuse it from both isGrounded and hazardDamageFraction.

Proposed extraction
 	isGrounded(side: SideId, position: number): boolean {
 		const mon = this.getActive(side, position);
 		if (!mon) return true;
-		if (this.field.gravity) return true;
-		if (mon.volatiles.has("smackdown") || mon.volatiles.has("ingrain")) return true;
-		if (mon.volatiles.has("magnetrise")) return false;
-		if (mon.item === "ironball") return true;
-		if (mon.item === "airballoon") return false;
-		if (mon.ability === "levitate") return false;
-		if (mon.types.includes("Flying")) return false;
-		return true;
+		return this.isMonGrounded(mon);
 	}
+
+	private isMonGrounded(mon: TrackedPokemon): boolean {
+		if (this.field.gravity) return true;
+		if (mon.volatiles.has("smackdown") || mon.volatiles.has("ingrain")) return true;
+		if (mon.volatiles.has("magnetrise") || mon.volatiles.has("telekinesis")) return false;
+		if (mon.item === "ironball") return true;
+		if (mon.item === "airballoon") return false;
+		if (mon.ability === "levitate") return false;
+		if (mon.types.includes("Flying")) return false;
+		return true;
+	}
@@
 	hazardDamageFraction(mon: TrackedPokemon, side: SideId): number {
 		if (mon.item === "heavydutyboots") return 0;
 		if (mon.ability === "magicguard") return 0;
 		const ss = this.sides[side];
 		let damage = 0;
 		if (ss.stealthRock) {
 			const eff = effectivenessLog(["Rock"], mon.types);
 			damage += 0.125 * 2 ** eff;
 		}
-		const grounded = !mon.types.includes("Flying") &&
-			mon.ability !== "levitate" &&
-			mon.item !== "airballoon";
-		if (grounded) {
+		if (this.isMonGrounded(mon)) {
 			if (ss.spikes === 1) damage += 1 / 8;
 			else if (ss.spikes === 2) damage += 1 / 6;
 			else if (ss.spikes >= 3) damage += 1 / 4;
 		}
 		return damage;
 	}

Also: the isGrounded docstring promises to handle Roost (lines 746-750) but the code does not check the roost volatile. Either drop the mention or add the check (Roost suppresses Flying type for the turn — for a mid-turn EQ it matters; for switch-in hazards it doesn't, hence harmless to omit from hazardDamageFraction).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 769 - 788,
The hazardDamageFraction method duplicates a simplified grounded check and
misses many conditions handled (or expected) by isGrounded; extract a single
helper (e.g., isMonGrounded or groundedForHazards) that encapsulates the full
grounded logic and reuse it in both isGrounded and hazardDamageFraction; ensure
the helper checks Gravity, Iron Ball, Smack Down, Ingrain, Magnet Rise (and the
original checks for Flying type, Levitate, Air Balloon,
abilities/items/volatiles used in isGrounded), and decide whether to include
Roost (either remove Roost from isGrounded doc or add the volatile check) so
hazardDamageFraction uses the canonical grounded result for Spikes/Stealth Rock
calculations.

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🔴 Critical_ **`hazardDamageFraction` reimplements (incorrectly) the grounded check from `isGrounded`.** The inline grounded check on lines 779-781 only considers Flying type, Levitate, and Air Balloon. It misses every other relevant condition that `isGrounded` (lines 751-762) handles, plus a couple `isGrounded` itself omits: - **Gravity** — should ground every mon. Skipped here, so a Flying-type incoming under Gravity is incorrectly reported as Spikes-immune. - **Iron Ball** — forces grounding even on Flying/Levitate. Skipped, so an Iron-Ball Skarmory is incorrectly reported as Spikes-immune. - **Smack Down / Ingrain** — force grounding. Skipped. - **Magnet Rise** — lifts a non-Flying mon. Skipped, so a Magnet-Rise Heatran is incorrectly reported as taking Spikes damage. Since this method drives switch evaluation (`SwitchEvaluator` per the AI summary), wrong hazard estimates will translate directly into bad switch decisions. Please extract a single mon-keyed groundedness helper and reuse it from both `isGrounded` and `hazardDamageFraction`. <details> <summary>Proposed extraction</summary> ```diff isGrounded(side: SideId, position: number): boolean { const mon = this.getActive(side, position); if (!mon) return true; - if (this.field.gravity) return true; - if (mon.volatiles.has("smackdown") || mon.volatiles.has("ingrain")) return true; - if (mon.volatiles.has("magnetrise")) return false; - if (mon.item === "ironball") return true; - if (mon.item === "airballoon") return false; - if (mon.ability === "levitate") return false; - if (mon.types.includes("Flying")) return false; - return true; + return this.isMonGrounded(mon); } + + private isMonGrounded(mon: TrackedPokemon): boolean { + if (this.field.gravity) return true; + if (mon.volatiles.has("smackdown") || mon.volatiles.has("ingrain")) return true; + if (mon.volatiles.has("magnetrise") || mon.volatiles.has("telekinesis")) return false; + if (mon.item === "ironball") return true; + if (mon.item === "airballoon") return false; + if (mon.ability === "levitate") return false; + if (mon.types.includes("Flying")) return false; + return true; + } @@ hazardDamageFraction(mon: TrackedPokemon, side: SideId): number { if (mon.item === "heavydutyboots") return 0; if (mon.ability === "magicguard") return 0; const ss = this.sides[side]; let damage = 0; if (ss.stealthRock) { const eff = effectivenessLog(["Rock"], mon.types); damage += 0.125 * 2 ** eff; } - const grounded = !mon.types.includes("Flying") && - mon.ability !== "levitate" && - mon.item !== "airballoon"; - if (grounded) { + if (this.isMonGrounded(mon)) { if (ss.spikes === 1) damage += 1 / 8; else if (ss.spikes === 2) damage += 1 / 6; else if (ss.spikes >= 3) damage += 1 / 4; } return damage; } ``` Also: the `isGrounded` docstring promises to handle Roost (lines 746-750) but the code does not check the `roost` volatile. Either drop the mention or add the check (Roost suppresses Flying type for the turn — for a mid-turn EQ it matters; for switch-in hazards it doesn't, hence harmless to omit from `hazardDamageFraction`). </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 769 - 788, The hazardDamageFraction method duplicates a simplified grounded check and misses many conditions handled (or expected) by isGrounded; extract a single helper (e.g., isMonGrounded or groundedForHazards) that encapsulates the full grounded logic and reuse it in both isGrounded and hazardDamageFraction; ensure the helper checks Gravity, Iron Ball, Smack Down, Ingrain, Magnet Rise (and the original checks for Flying type, Levitate, Air Balloon, abilities/items/volatiles used in isGrounded), and decide whether to include Roost (either remove Roost from isGrounded doc or add the volatile check) so hazardDamageFraction uses the canonical grounded result for Spikes/Stealth Rock calculations. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:53fcf297-4073-4fba-a844-dfadac59c185 --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +223,4 @@
dist.set("leftovers", leftoversScore * scale);
dist.set("assaultvest", avScore * scale);
dist.set("", rest);
return dist;
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🟠 Major

Item distribution can exceed 1.0; violates the Distribution invariant.

The Distribution type contract (line 39) says values sum to ~1.0, and downstream MCTS/search rollouts will treat this as a probability. But the heuristic mass added in inferItems is unbounded — e.g. an NFE foe that's been on the field through hazards while same-move-streaking once gets bootsScore 0.5 + evioliteScore 0.6 + choiceScore 0.6 + leftovers/av/sash 0.15 ≈ 1.85, and rest = max(0, 1 - totals) = 0 clamps the unknown bucket but does not renormalize. The map values are then written directly (lines 199–208), so the distribution sums to ~1.85.

Suggested fix: normalize before writing
-  // Normalise the heuristic mass and put the leftover on "unknown".
-  const totals = bootsScore + sashScore + balloonScore + evioliteScore + choiceScore + leftoversScore + avScore;
-  const rest = Math.max(0, 1 - totals);
-  dist.set("heavydutyboots", bootsScore);
-  dist.set("focussash", sashScore);
-  dist.set("airballoon", balloonScore);
-  dist.set("eviolite", evioliteScore);
-  dist.set("choicescarf", choiceScore * 0.45);
-  dist.set("choiceband", choiceScore * 0.35);
-  dist.set("choicespecs", choiceScore * 0.20);
-  dist.set("leftovers", leftoversScore);
-  dist.set("assaultvest", avScore);
-  dist.set("", rest);
+  const totals =
+    bootsScore + sashScore + balloonScore + evioliteScore +
+    choiceScore + leftoversScore + avScore;
+  // If heuristics over-allocate, scale them down so the whole map sums to 1.
+  const scale = totals > 1 ? 1 / totals : 1;
+  const rest = totals > 1 ? 0 : 1 - totals;
+  dist.set("heavydutyboots", bootsScore * scale);
+  dist.set("focussash", sashScore * scale);
+  dist.set("airballoon", balloonScore * scale);
+  dist.set("eviolite", evioliteScore * scale);
+  dist.set("choicescarf", choiceScore * 0.45 * scale);
+  dist.set("choiceband", choiceScore * 0.35 * scale);
+  dist.set("choicespecs", choiceScore * 0.20 * scale);
+  dist.set("leftovers", leftoversScore * scale);
+  dist.set("assaultvest", avScore * scale);
+  dist.set("", rest);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/OpponentInference.ts` around lines 160 - 209,
The distribution values can exceed 1 because the heuristic scores (bootsScore,
sashScore, balloonScore, evioliteScore, choiceScore, leftoversScore, avScore)
are written to dist without normalization; update the inferItems logic to
normalize these scores before calling dist.set: compute totals as the sum of the
raw scores, if totals > 0 then scale each individual score by (1 / totals) when
totals > 1 (or simply divide every score by totals to get a proper probability
mass), then compute rest = 1 - sum(scaled scores) and finally write the scaled
values via dist.set for "heavydutyboots", "focussash", "airballoon", "eviolite",
"choicescarf"/"choiceband"/"choicespecs" (apply scaled choiceScore split),
"leftovers", "assaultvest" and the unknown bucket ""; ensure you reference the
existing totals, rest, and dist.set sites when making the change.

✅ Addressed in commit ed4d2de

_⚠️ Potential issue_ | _🟠 Major_ **Item distribution can exceed 1.0; violates the `Distribution` invariant.** The `Distribution` type contract (line 39) says values sum to ~1.0, and downstream MCTS/search rollouts will treat this as a probability. But the heuristic mass added in `inferItems` is unbounded — e.g. an NFE foe that's been on the field through hazards while same-move-streaking once gets `bootsScore 0.5 + evioliteScore 0.6 + choiceScore 0.6 + leftovers/av/sash 0.15 ≈ 1.85`, and `rest = max(0, 1 - totals) = 0` clamps the unknown bucket but does not renormalize. The map values are then written directly (lines 199–208), so the distribution sums to ~1.85. <details> <summary>Suggested fix: normalize before writing</summary> ```diff - // Normalise the heuristic mass and put the leftover on "unknown". - const totals = bootsScore + sashScore + balloonScore + evioliteScore + choiceScore + leftoversScore + avScore; - const rest = Math.max(0, 1 - totals); - dist.set("heavydutyboots", bootsScore); - dist.set("focussash", sashScore); - dist.set("airballoon", balloonScore); - dist.set("eviolite", evioliteScore); - dist.set("choicescarf", choiceScore * 0.45); - dist.set("choiceband", choiceScore * 0.35); - dist.set("choicespecs", choiceScore * 0.20); - dist.set("leftovers", leftoversScore); - dist.set("assaultvest", avScore); - dist.set("", rest); + const totals = + bootsScore + sashScore + balloonScore + evioliteScore + + choiceScore + leftoversScore + avScore; + // If heuristics over-allocate, scale them down so the whole map sums to 1. + const scale = totals > 1 ? 1 / totals : 1; + const rest = totals > 1 ? 0 : 1 - totals; + dist.set("heavydutyboots", bootsScore * scale); + dist.set("focussash", sashScore * scale); + dist.set("airballoon", balloonScore * scale); + dist.set("eviolite", evioliteScore * scale); + dist.set("choicescarf", choiceScore * 0.45 * scale); + dist.set("choiceband", choiceScore * 0.35 * scale); + dist.set("choicespecs", choiceScore * 0.20 * scale); + dist.set("leftovers", leftoversScore * scale); + dist.set("assaultvest", avScore * scale); + dist.set("", rest); ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/OpponentInference.ts` around lines 160 - 209, The distribution values can exceed 1 because the heuristic scores (bootsScore, sashScore, balloonScore, evioliteScore, choiceScore, leftoversScore, avScore) are written to dist without normalization; update the inferItems logic to normalize these scores before calling dist.set: compute totals as the sum of the raw scores, if totals > 0 then scale each individual score by (1 / totals) when totals > 1 (or simply divide every score by totals to get a proper probability mass), then compute rest = 1 - sum(scaled scores) and finally write the scaled values via dist.set for "heavydutyboots", "focussash", "airballoon", "eviolite", "choicescarf"/"choiceband"/"choicespecs" (apply scaled choiceScore split), "leftovers", "assaultvest" and the unknown bucket ""; ensure you reference the existing totals, rest, and dist.set sites when making the change. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:d8985210-6c07-4d20-8bf3-b624e4aa1557 --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit ed4d2de
@ -0,0 +1,215 @@
'use strict';
coderabbitai[bot] (Migrated from github.com) commented 2026-04-26 18:03:45 +00:00

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect parseLine's |win| handling and any callers that pre-normalize the payload.
rg -nP --type=ts -C6 "kind:\s*['\"]win['\"]|case\s+['\"]win['\"]" sim/tools/strategic-ai
rg -nP --type=ts -C3 'parseLine\s*\(' sim/tools/strategic-ai

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 3260


🏁 Script executed:

#!/bin/bash
# Find all callers of parseLine in the codebase
rg -nP --type=ts "parseLine\s*\(" sim/tools/strategic-ai --no-heading -A2 -B2

# Check if BattleStateTracker or other components pre-process logs
fd -e ts -e js sim/tools/strategic-ai | xargs rg -l "BattleStateTracker|applyEvent" | head -5

# Look for any normalization or mapping of player names to side IDs
rg -nP --type=ts "p[1-4]|SideId|player.*name|win.*side" sim/tools/strategic-ai -C2 | head -60

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 5518


🏁 Script executed:

#!/bin/bash
# Find actual usages of parseLine to see what data feeds it
rg -nP --type=ts "parseLine\s*\(" -B3 -A3 sim/tools

# Look for BattleStateTracker and how it feeds data to parseLine
rg -nP --type=ts "BattleStateTracker" -B2 -A5 sim/tools

# Check for any player name to side ID mapping
rg -nP --type=ts "player.*name|Player.*Name" sim/tools/strategic-ai -C2

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 29261


🏁 Script executed:

#!/bin/bash
# Find the applyEvent implementation and its win handling
rg -nP --type=ts "applyEvent\s*\(" -A 200 sim/tools/strategic-ai/state/BattleStateTracker.ts | head -300

# Check if there are any real battle logs or Showdown protocol references
rg -nP "win.*player|player.*win|\|win\|" --type=ts --type=js sim/tools test | head -40

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 11389


🏁 Script executed:

#!/bin/bash
# Get the full applyEvent method to see all case handlers
rg -nP --type=ts "case \"win\"" -A 10 sim/tools/strategic-ai/state/BattleStateTracker.ts

# Also check if there's any fallthrough or default case
rg -nP --type=ts "applyEvent.*\{" -A 500 sim/tools/strategic-ai/state/BattleStateTracker.ts | tail -100

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 3527


🏁 Script executed:

#!/bin/bash
# Double-check: get the complete switch statement in applyEvent to see ALL cases
rg -nP --type=ts "applyEvent\(event: BattleEvent\):" -A 400 sim/tools/strategic-ai/state/BattleStateTracker.ts | grep -E "^\s*case|default:" | head -30

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 95


🏁 Script executed:

#!/bin/bash
# Get context around the default case and all case statements in applyEvent
rg -nP --type=ts "case \"" sim/tools/strategic-ai/state/BattleStateTracker.ts | grep -E "case \"(turn|gametype|gen|battlestart|move|switch|drag|faint|damage|heal|sethp|status|curestatus|boost|volatile|clearvolatile|ability|item|enditem|formechange|detailschange|fieldstart|fieldend|sidestart|sideend|swapsideconditions|weather|terrain|trickroom|magicroom|wonderroom|gravity|win|tie)"

# Get the default case to see what happens with unhandled events
sed -n '700,720p' sim/tools/strategic-ai/state/BattleStateTracker.ts

Repository: smell-of-curry/pokebedrock-showdown

Length of output: 1257


The test fixture parseLine('|win|p2') does not match the actual Showdown protocol, which emits |win|<player name>, and applyEvent has no handler for win events.

Real battle logs send |win|<player name> (e.g., |win|Bot 1), not |win|p2. The parser's regex /^p[1-4]$/ rejects player names, causing parseLine to return { kind: 'win', side: undefined }. Additionally, BattleStateTracker.applyEvent() contains no case handler for win or tie events, so even if the parser produced the correct format, the event would silently fall through to the default case and be discarded. This means real battle-end signals are lost during integration with BattleStateTracker. The test should either use the actual protocol format or be renamed to clarify it tests a synthetic subset.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sim/tools/strategic-ai/log-parser.js` around lines 61 - 64, The test
uses a synthetic `|win|p2` format but real logs emit `|win|<player name>` and
the code lacks handling for those events; update the parser and tracker or the
test accordingly: either change the test in parseLine to use a real protocol
string like `|win|Bot 1` (or rename it to indicate it's synthetic), or update
parseLine to accept player names (remove the `/^p[1-4]$/` restriction and
populate the event.side with the raw player name) and add case handlers for
'win' and 'tie' inside BattleStateTracker.applyEvent to process the
end-of-battle event (e.g., set a winner/ended flag or equivalent state). Ensure
references: parseLine, the offending regex, and BattleStateTracker.applyEvent
are updated so real `|win|<name>` logs are parsed and applied.
_⚠️ Potential issue_ | _🔴 Critical_ <details> <summary>🧩 Analysis chain</summary> 🏁 Script executed: ```shell #!/bin/bash # Inspect parseLine's |win| handling and any callers that pre-normalize the payload. rg -nP --type=ts -C6 "kind:\s*['\"]win['\"]|case\s+['\"]win['\"]" sim/tools/strategic-ai rg -nP --type=ts -C3 'parseLine\s*\(' sim/tools/strategic-ai ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 3260 --- 🏁 Script executed: ```shell #!/bin/bash # Find all callers of parseLine in the codebase rg -nP --type=ts "parseLine\s*\(" sim/tools/strategic-ai --no-heading -A2 -B2 # Check if BattleStateTracker or other components pre-process logs fd -e ts -e js sim/tools/strategic-ai | xargs rg -l "BattleStateTracker|applyEvent" | head -5 # Look for any normalization or mapping of player names to side IDs rg -nP --type=ts "p[1-4]|SideId|player.*name|win.*side" sim/tools/strategic-ai -C2 | head -60 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 5518 --- 🏁 Script executed: ```shell #!/bin/bash # Find actual usages of parseLine to see what data feeds it rg -nP --type=ts "parseLine\s*\(" -B3 -A3 sim/tools # Look for BattleStateTracker and how it feeds data to parseLine rg -nP --type=ts "BattleStateTracker" -B2 -A5 sim/tools # Check for any player name to side ID mapping rg -nP --type=ts "player.*name|Player.*Name" sim/tools/strategic-ai -C2 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 29261 --- 🏁 Script executed: ```shell #!/bin/bash # Find the applyEvent implementation and its win handling rg -nP --type=ts "applyEvent\s*\(" -A 200 sim/tools/strategic-ai/state/BattleStateTracker.ts | head -300 # Check if there are any real battle logs or Showdown protocol references rg -nP "win.*player|player.*win|\|win\|" --type=ts --type=js sim/tools test | head -40 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 11389 --- 🏁 Script executed: ```shell #!/bin/bash # Get the full applyEvent method to see all case handlers rg -nP --type=ts "case \"win\"" -A 10 sim/tools/strategic-ai/state/BattleStateTracker.ts # Also check if there's any fallthrough or default case rg -nP --type=ts "applyEvent.*\{" -A 500 sim/tools/strategic-ai/state/BattleStateTracker.ts | tail -100 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 3527 --- 🏁 Script executed: ```shell #!/bin/bash # Double-check: get the complete switch statement in applyEvent to see ALL cases rg -nP --type=ts "applyEvent\(event: BattleEvent\):" -A 400 sim/tools/strategic-ai/state/BattleStateTracker.ts | grep -E "^\s*case|default:" | head -30 ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 95 --- 🏁 Script executed: ```shell #!/bin/bash # Get context around the default case and all case statements in applyEvent rg -nP --type=ts "case \"" sim/tools/strategic-ai/state/BattleStateTracker.ts | grep -E "case \"(turn|gametype|gen|battlestart|move|switch|drag|faint|damage|heal|sethp|status|curestatus|boost|volatile|clearvolatile|ability|item|enditem|formechange|detailschange|fieldstart|fieldend|sidestart|sideend|swapsideconditions|weather|terrain|trickroom|magicroom|wonderroom|gravity|win|tie)" # Get the default case to see what happens with unhandled events sed -n '700,720p' sim/tools/strategic-ai/state/BattleStateTracker.ts ``` Repository: smell-of-curry/pokebedrock-showdown Length of output: 1257 --- </details> **The test fixture `parseLine('|win|p2')` does not match the actual Showdown protocol, which emits `|win|<player name>`, and applyEvent has no handler for win events.** Real battle logs send `|win|<player name>` (e.g., `|win|Bot 1`), not `|win|p2`. The parser's regex `/^p[1-4]$/` rejects player names, causing parseLine to return `{ kind: 'win', side: undefined }`. Additionally, `BattleStateTracker.applyEvent()` contains no case handler for `win` or `tie` events, so even if the parser produced the correct format, the event would silently fall through to the default case and be discarded. This means real battle-end signals are lost during integration with `BattleStateTracker`. The test should either use the actual protocol format or be renamed to clarify it tests a synthetic subset. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@test/sim/tools/strategic-ai/log-parser.js` around lines 61 - 64, The test uses a synthetic `|win|p2` format but real logs emit `|win|<player name>` and the code lacks handling for those events; update the parser and tracker or the test accordingly: either change the test in parseLine to use a real protocol string like `|win|Bot 1` (or rename it to indicate it's synthetic), or update parseLine to accept player names (remove the `/^p[1-4]$/` restriction and populate the event.side with the raw player name) and add case handlers for 'win' and 'tie' inside BattleStateTracker.applyEvent to process the end-of-battle event (e.g., set a winner/ended flag or equivalent state). Ensure references: parseLine, the offending regex, and BattleStateTracker.applyEvent are updated so real `|win|<name>` logs are parsed and applied. ``` </details> <!-- fingerprinting:phantom:medusa:nectarine:d76e4f01-498a-4841-947b-b702faa7574f --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai[bot] (Migrated from github.com) reviewed 2026-04-26 18:54:00 +00:00
coderabbitai[bot] (Migrated from github.com) left a comment

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
sim/side.ts (1)

557-599: ⚠️ Potential issue | 🟠 Major

Fix processBagItem to use 0-based indexing for positive targetLoc values.

The function receives 1-based slot numbers (1–6) from the case 'bag-item' handler via the regex /\s(?:-|\+)?[1-6]$/, but the current index formula breaks for those values:

const index = targetLoc ? targetLoc : -(this.getChoiceIndex() + 1);
const pokemon = this.pokemon[-index - 1];

With targetLoc = 3, this becomes this.pokemon[-4], which is undefined. The formula only works for the negative branch (when targetLoc is undefined).

Since other handlers like chooseSwitch follow the pattern slot = parseInt(input) - 1, apply the same conversion here:

const slot = targetLoc !== undefined ? targetLoc - 1 : this.getChoiceIndex();
const pokemon = this.pokemon[slot];

No test coverage currently exists for this code path. Verify that bag-item functionality targeting specific party slots works end-to-end before merging.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/side.ts` around lines 557 - 599, processBagItem is treating positive
targetLoc as if already 0-based which makes lookups like this.pokemon[-index-1]
fail; change the logic to compute a 0-based slot: if targetLoc !== undefined
then slot = targetLoc - 1 else slot = this.getChoiceIndex(), then look up the
Pokémon with this.pokemon[slot] and pass that to this.battle.useBagItem(pokemon,
itemName, moveName); keep the existing error throw if pokemon is falsy and
ensure you reference the same symbols (processBagItem, targetLoc,
getChoiceIndex, this.pokemon, this.battle.useBagItem, itemName, moveName).
🧹 Nitpick comments (3)
sim/side.ts (3)

1281-1287: used-ball silently ignores trailing data.

Other no-arg cases (pass/skip, shift) reject extra data with Unrecognized data after "...". used-ball accepts any trailing string and just emits a pass, which makes typos / protocol drift undetectable. Consider mirroring the existing pattern:

 				case 'used-ball':
+					if (data) return this.emitChoiceError(`Unrecognized data after "used-ball": ${data}`);
 					// Player used a ball, but it failed. We cant use 'pass' as it would fail.
 					this.choice.actions.push({
 						choice: 'pass',
 					} as ChosenAction);
 					break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/side.ts` around lines 1281 - 1287, The 'used-ball' case currently
swallows any trailing token and unconditionally pushes a pass via
this.choice.actions.push({ choice: 'pass' } as ChosenAction); change it to
mirror the other no-arg cases (like 'pass'/'skip' and 'shift') by rejecting
extra data: inspect the incoming tokens/arg for the 'used-ball' branch and if
any non-empty/trailing data exists throw or emit the same error used elsewhere
(e.g. "Unrecognized data after \"used-ball\""), otherwise push the pass action;
update the 'used-ball' switch branch to validate arguments before calling
this.choice.actions.push to ensure typos/protocol drift are caught.

1267-1280: Dead/copy-pasted parsing logic in bag-item.

A few cleanups in this branch:

  • bagTargetLoc !== undefined (line 1272) is unreachable: bagTargetLoc was just declared as undefined on line 1268 and there is no enclosing loop (unlike the move parser). The early return bagError() can never fire.
  • toID(data) !== 'conversion2' is copy-pasted from the move parser where it disambiguates "Conversion 2"; it has no semantic meaning for bag items and should be removed.
  • bagError's message ("regrex") has a typo and is unreachable per the first point.

Also worth considering: should processBagItem returning/throwing be observable here? Right now any failure inside processBagItem (e.g. the throw new Error(...) at line 588) escapes choose() rather than being surfaced via emitChoiceError, and the unconditional pass push afterward will not run on throw — fine, but if you ever convert it to return false, the trailing pass would still be pushed.

♻️ Suggested simplification
-				case 'bag-item':
-					let bagTargetLoc: number | undefined = undefined;
-					const bagError = () =>
-						this.emitChoiceError(`Data does not match regrex "data": ${data}`);
-					if (/\s(?:-|\+)?[1-6]$/.test(data) && toID(data) !== 'conversion2') {
-						if (bagTargetLoc !== undefined) return bagError();
-						bagTargetLoc = parseInt(data.slice(-2));
-						data = data.slice(0, -2).trim();
-					}
-					this.processBagItem(data, bagTargetLoc);
-					this.choice.actions.push({
-						choice: 'pass',
-					} as ChosenAction);
-					break;
+				case 'bag-item': {
+					let bagTargetLoc: number | undefined;
+					if (/\s(?:-|\+)?[1-6]$/.test(data)) {
+						bagTargetLoc = parseInt(data.slice(-2));
+						data = data.slice(0, -2).trim();
+					}
+					this.processBagItem(data, bagTargetLoc);
+					this.choice.actions.push({ choice: 'pass' } as ChosenAction);
+					break;
+				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/side.ts` around lines 1267 - 1280, The bag-item branch contains
dead/copy-pasted parsing: remove the unnecessary toID(data) !== 'conversion2'
check and the unreachable guard against bagTargetLoc !== undefined (since
bagTargetLoc is freshly declared), eliminate or correct the unused bagError (fix
"regrex"→"regex" if you keep it) and simplify parsing to only detect a trailing
space+[-+]?1-6 and set bagTargetLoc via parseInt(data.slice(-2)) before
trimming; also ensure behavior around processBagItem(choice) errors is
deliberate—either catch exceptions from processBagItem and call
emitChoiceError(...) or leave them to propagate—so adjust the code in the
bag-item case (symbols: bagTargetLoc, bagError, processBagItem, emitChoiceError,
toID) accordingly.

1191-1295: Wrap case bodies that declare let/const in blocks.

Biome flags noSwitchDeclarations at lines 1192–1195 (case 'move') and 1268–1270 (case 'bag-item'). Bare let original, let targetLoc, let event, const error, and let bagTargetLoc, const bagError in a case clause are scoped to the entire switch, so subsequent cases can erroneously access them and the linter rejects this. Wrap each affected case body in { … }.

♻️ Proposed fix
 			switch (choiceType) {
-				case 'move':
+				case 'move': {
 					const original = data;
 					const error = () => this.emitChoiceError(`Conflicting arguments for "move": ${original}`);
 					let targetLoc: number | undefined;
 					let event: 'mega' | 'megax' | 'megay' | 'zmove' | 'ultra' | 'dynamax' | 'terastallize' | '' = '';
 					while (true) {
 						// ...
 					}
 					if (!this.chooseMove(data, targetLoc, event)) return false;
 					break;
+				}
 				case 'switch':
 					this.chooseSwitch(data);
 					break;
 				// ...
-				case 'bag-item':
+				case 'bag-item': {
 					let bagTargetLoc: number | undefined = undefined;
 					const bagError = () =>
 						this.emitChoiceError(`Data does not match regrex "data": ${data}`);
 					if (/\s(?:-|\+)?[1-6]$/.test(data) && toID(data) !== 'conversion2') {
 						if (bagTargetLoc !== undefined) return bagError();
 						bagTargetLoc = parseInt(data.slice(-2));
 						data = data.slice(0, -2).trim();
 					}
 					this.processBagItem(data, bagTargetLoc);
 					this.choice.actions.push({
 						choice: 'pass',
 					} as ChosenAction);
 					break;
+				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/side.ts` around lines 1191 - 1295, The 'move' and 'bag-item' case blocks
declare variables (original, targetLoc, event, error, bagTargetLoc, bagError) at
switch scope which trips noSwitchDeclarations; fix by wrapping each case body
that declares those lets/consts in its own block { ... } so their scope is
limited to that case (i.e., wrap the entire case 'move' code before calling
this.chooseMove(data, targetLoc, event) and wrap the 'bag-item' body around the
parsing and this.processBagItem(data, bagTargetLoc) call), leaving calls to
chooseMove and processBagItem unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@sim/side.ts`:
- Around line 557-599: processBagItem is treating positive targetLoc as if
already 0-based which makes lookups like this.pokemon[-index-1] fail; change the
logic to compute a 0-based slot: if targetLoc !== undefined then slot =
targetLoc - 1 else slot = this.getChoiceIndex(), then look up the Pokémon with
this.pokemon[slot] and pass that to this.battle.useBagItem(pokemon, itemName,
moveName); keep the existing error throw if pokemon is falsy and ensure you
reference the same symbols (processBagItem, targetLoc, getChoiceIndex,
this.pokemon, this.battle.useBagItem, itemName, moveName).

---

Nitpick comments:
In `@sim/side.ts`:
- Around line 1281-1287: The 'used-ball' case currently swallows any trailing
token and unconditionally pushes a pass via this.choice.actions.push({ choice:
'pass' } as ChosenAction); change it to mirror the other no-arg cases (like
'pass'/'skip' and 'shift') by rejecting extra data: inspect the incoming
tokens/arg for the 'used-ball' branch and if any non-empty/trailing data exists
throw or emit the same error used elsewhere (e.g. "Unrecognized data after
\"used-ball\""), otherwise push the pass action; update the 'used-ball' switch
branch to validate arguments before calling this.choice.actions.push to ensure
typos/protocol drift are caught.
- Around line 1267-1280: The bag-item branch contains dead/copy-pasted parsing:
remove the unnecessary toID(data) !== 'conversion2' check and the unreachable
guard against bagTargetLoc !== undefined (since bagTargetLoc is freshly
declared), eliminate or correct the unused bagError (fix "regrex"→"regex" if you
keep it) and simplify parsing to only detect a trailing space+[-+]?1-6 and set
bagTargetLoc via parseInt(data.slice(-2)) before trimming; also ensure behavior
around processBagItem(choice) errors is deliberate—either catch exceptions from
processBagItem and call emitChoiceError(...) or leave them to propagate—so
adjust the code in the bag-item case (symbols: bagTargetLoc, bagError,
processBagItem, emitChoiceError, toID) accordingly.
- Around line 1191-1295: The 'move' and 'bag-item' case blocks declare variables
(original, targetLoc, event, error, bagTargetLoc, bagError) at switch scope
which trips noSwitchDeclarations; fix by wrapping each case body that declares
those lets/consts in its own block { ... } so their scope is limited to that
case (i.e., wrap the entire case 'move' code before calling
this.chooseMove(data, targetLoc, event) and wrap the 'bag-item' body around the
parsing and this.processBagItem(data, bagTargetLoc) call), leaving calls to
chooseMove and processBagItem unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7e05e7d-454e-44f0-8b88-6b7737b831db

📥 Commits

Reviewing files that changed from the base of the PR and between 457d8c7ba4 and 3f75be5180.

📒 Files selected for processing (1)
  • sim/side.ts
> [!CAUTION] > Some comments are outside the diff and can’t be posted inline due to platform limitations. > > > > <details> > <summary>⚠️ Outside diff range comments (1)</summary><blockquote> > > <details> > <summary>sim/side.ts (1)</summary><blockquote> > > `557-599`: _⚠️ Potential issue_ | _🟠 Major_ > > **Fix `processBagItem` to use 0-based indexing for positive `targetLoc` values.** > > The function receives 1-based slot numbers (1–6) from the `case 'bag-item'` handler via the regex `/\s(?:-|\+)?[1-6]$/`, but the current index formula breaks for those values: > > ```ts > const index = targetLoc ? targetLoc : -(this.getChoiceIndex() + 1); > const pokemon = this.pokemon[-index - 1]; > ``` > > With `targetLoc = 3`, this becomes `this.pokemon[-4]`, which is undefined. The formula only works for the negative branch (when `targetLoc` is undefined). > > Since other handlers like `chooseSwitch` follow the pattern `slot = parseInt(input) - 1`, apply the same conversion here: > > ```ts > const slot = targetLoc !== undefined ? targetLoc - 1 : this.getChoiceIndex(); > const pokemon = this.pokemon[slot]; > ``` > > No test coverage currently exists for this code path. Verify that bag-item functionality targeting specific party slots works end-to-end before merging. > > <details> > <summary>🤖 Prompt for AI Agents</summary> > > ``` > Verify each finding against the current code and only fix it if needed. > > In `@sim/side.ts` around lines 557 - 599, processBagItem is treating positive > targetLoc as if already 0-based which makes lookups like this.pokemon[-index-1] > fail; change the logic to compute a 0-based slot: if targetLoc !== undefined > then slot = targetLoc - 1 else slot = this.getChoiceIndex(), then look up the > Pokémon with this.pokemon[slot] and pass that to this.battle.useBagItem(pokemon, > itemName, moveName); keep the existing error throw if pokemon is falsy and > ensure you reference the same symbols (processBagItem, targetLoc, > getChoiceIndex, this.pokemon, this.battle.useBagItem, itemName, moveName). > ``` > > </details> > > </blockquote></details> > > </blockquote></details> <details> <summary>🧹 Nitpick comments (3)</summary><blockquote> <details> <summary>sim/side.ts (3)</summary><blockquote> `1281-1287`: **`used-ball` silently ignores trailing data.** Other no-arg cases (`pass`/`skip`, `shift`) reject extra data with `Unrecognized data after "..."`. `used-ball` accepts any trailing string and just emits a `pass`, which makes typos / protocol drift undetectable. Consider mirroring the existing pattern: ```diff case 'used-ball': + if (data) return this.emitChoiceError(`Unrecognized data after "used-ball": ${data}`); // Player used a ball, but it failed. We cant use 'pass' as it would fail. this.choice.actions.push({ choice: 'pass', } as ChosenAction); break; ``` <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/side.ts` around lines 1281 - 1287, The 'used-ball' case currently swallows any trailing token and unconditionally pushes a pass via this.choice.actions.push({ choice: 'pass' } as ChosenAction); change it to mirror the other no-arg cases (like 'pass'/'skip' and 'shift') by rejecting extra data: inspect the incoming tokens/arg for the 'used-ball' branch and if any non-empty/trailing data exists throw or emit the same error used elsewhere (e.g. "Unrecognized data after \"used-ball\""), otherwise push the pass action; update the 'used-ball' switch branch to validate arguments before calling this.choice.actions.push to ensure typos/protocol drift are caught. ``` </details> --- `1267-1280`: **Dead/copy-pasted parsing logic in `bag-item`.** A few cleanups in this branch: - `bagTargetLoc !== undefined` (line 1272) is unreachable: `bagTargetLoc` was just declared as `undefined` on line 1268 and there is no enclosing loop (unlike the `move` parser). The early `return bagError()` can never fire. - `toID(data) !== 'conversion2'` is copy-pasted from the `move` parser where it disambiguates "Conversion 2"; it has no semantic meaning for bag items and should be removed. - `bagError`'s message ("regrex") has a typo and is unreachable per the first point. Also worth considering: should `processBagItem` returning/throwing be observable here? Right now any failure inside `processBagItem` (e.g. the `throw new Error(...)` at line 588) escapes `choose()` rather than being surfaced via `emitChoiceError`, and the unconditional `pass` push afterward will not run on throw — fine, but if you ever convert it to return `false`, the trailing `pass` would still be pushed. <details> <summary>♻️ Suggested simplification</summary> ```diff - case 'bag-item': - let bagTargetLoc: number | undefined = undefined; - const bagError = () => - this.emitChoiceError(`Data does not match regrex "data": ${data}`); - if (/\s(?:-|\+)?[1-6]$/.test(data) && toID(data) !== 'conversion2') { - if (bagTargetLoc !== undefined) return bagError(); - bagTargetLoc = parseInt(data.slice(-2)); - data = data.slice(0, -2).trim(); - } - this.processBagItem(data, bagTargetLoc); - this.choice.actions.push({ - choice: 'pass', - } as ChosenAction); - break; + case 'bag-item': { + let bagTargetLoc: number | undefined; + if (/\s(?:-|\+)?[1-6]$/.test(data)) { + bagTargetLoc = parseInt(data.slice(-2)); + data = data.slice(0, -2).trim(); + } + this.processBagItem(data, bagTargetLoc); + this.choice.actions.push({ choice: 'pass' } as ChosenAction); + break; + } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/side.ts` around lines 1267 - 1280, The bag-item branch contains dead/copy-pasted parsing: remove the unnecessary toID(data) !== 'conversion2' check and the unreachable guard against bagTargetLoc !== undefined (since bagTargetLoc is freshly declared), eliminate or correct the unused bagError (fix "regrex"→"regex" if you keep it) and simplify parsing to only detect a trailing space+[-+]?1-6 and set bagTargetLoc via parseInt(data.slice(-2)) before trimming; also ensure behavior around processBagItem(choice) errors is deliberate—either catch exceptions from processBagItem and call emitChoiceError(...) or leave them to propagate—so adjust the code in the bag-item case (symbols: bagTargetLoc, bagError, processBagItem, emitChoiceError, toID) accordingly. ``` </details> --- `1191-1295`: **Wrap `case` bodies that declare `let`/`const` in blocks.** Biome flags `noSwitchDeclarations` at lines 1192–1195 (case `'move'`) and 1268–1270 (case `'bag-item'`). Bare `let original`, `let targetLoc`, `let event`, `const error`, and `let bagTargetLoc`, `const bagError` in a `case` clause are scoped to the entire `switch`, so subsequent cases can erroneously access them and the linter rejects this. Wrap each affected case body in `{ … }`. <details> <summary>♻️ Proposed fix</summary> ```diff switch (choiceType) { - case 'move': + case 'move': { const original = data; const error = () => this.emitChoiceError(`Conflicting arguments for "move": ${original}`); let targetLoc: number | undefined; let event: 'mega' | 'megax' | 'megay' | 'zmove' | 'ultra' | 'dynamax' | 'terastallize' | '' = ''; while (true) { // ... } if (!this.chooseMove(data, targetLoc, event)) return false; break; + } case 'switch': this.chooseSwitch(data); break; // ... - case 'bag-item': + case 'bag-item': { let bagTargetLoc: number | undefined = undefined; const bagError = () => this.emitChoiceError(`Data does not match regrex "data": ${data}`); if (/\s(?:-|\+)?[1-6]$/.test(data) && toID(data) !== 'conversion2') { if (bagTargetLoc !== undefined) return bagError(); bagTargetLoc = parseInt(data.slice(-2)); data = data.slice(0, -2).trim(); } this.processBagItem(data, bagTargetLoc); this.choice.actions.push({ choice: 'pass', } as ChosenAction); break; + } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/side.ts` around lines 1191 - 1295, The 'move' and 'bag-item' case blocks declare variables (original, targetLoc, event, error, bagTargetLoc, bagError) at switch scope which trips noSwitchDeclarations; fix by wrapping each case body that declares those lets/consts in its own block { ... } so their scope is limited to that case (i.e., wrap the entire case 'move' code before calling this.chooseMove(data, targetLoc, event) and wrap the 'bag-item' body around the parsing and this.processBagItem(data, bagTargetLoc) call), leaving calls to chooseMove and processBagItem unchanged. ``` </details> </blockquote></details> </blockquote></details> <details> <summary>🤖 Prompt for all review comments with AI agents</summary> ``` Verify each finding against the current code and only fix it if needed. Outside diff comments: In `@sim/side.ts`: - Around line 557-599: processBagItem is treating positive targetLoc as if already 0-based which makes lookups like this.pokemon[-index-1] fail; change the logic to compute a 0-based slot: if targetLoc !== undefined then slot = targetLoc - 1 else slot = this.getChoiceIndex(), then look up the Pokémon with this.pokemon[slot] and pass that to this.battle.useBagItem(pokemon, itemName, moveName); keep the existing error throw if pokemon is falsy and ensure you reference the same symbols (processBagItem, targetLoc, getChoiceIndex, this.pokemon, this.battle.useBagItem, itemName, moveName). --- Nitpick comments: In `@sim/side.ts`: - Around line 1281-1287: The 'used-ball' case currently swallows any trailing token and unconditionally pushes a pass via this.choice.actions.push({ choice: 'pass' } as ChosenAction); change it to mirror the other no-arg cases (like 'pass'/'skip' and 'shift') by rejecting extra data: inspect the incoming tokens/arg for the 'used-ball' branch and if any non-empty/trailing data exists throw or emit the same error used elsewhere (e.g. "Unrecognized data after \"used-ball\""), otherwise push the pass action; update the 'used-ball' switch branch to validate arguments before calling this.choice.actions.push to ensure typos/protocol drift are caught. - Around line 1267-1280: The bag-item branch contains dead/copy-pasted parsing: remove the unnecessary toID(data) !== 'conversion2' check and the unreachable guard against bagTargetLoc !== undefined (since bagTargetLoc is freshly declared), eliminate or correct the unused bagError (fix "regrex"→"regex" if you keep it) and simplify parsing to only detect a trailing space+[-+]?1-6 and set bagTargetLoc via parseInt(data.slice(-2)) before trimming; also ensure behavior around processBagItem(choice) errors is deliberate—either catch exceptions from processBagItem and call emitChoiceError(...) or leave them to propagate—so adjust the code in the bag-item case (symbols: bagTargetLoc, bagError, processBagItem, emitChoiceError, toID) accordingly. - Around line 1191-1295: The 'move' and 'bag-item' case blocks declare variables (original, targetLoc, event, error, bagTargetLoc, bagError) at switch scope which trips noSwitchDeclarations; fix by wrapping each case body that declares those lets/consts in its own block { ... } so their scope is limited to that case (i.e., wrap the entire case 'move' code before calling this.chooseMove(data, targetLoc, event) and wrap the 'bag-item' body around the parsing and this.processBagItem(data, bagTargetLoc) call), leaving calls to chooseMove and processBagItem unchanged. ``` </details> --- <details> <summary>ℹ️ Review info</summary> <details> <summary>⚙️ Run configuration</summary> **Configuration used**: defaults **Review profile**: CHILL **Plan**: Pro **Run ID**: `e7e05e7d-454e-44f0-8b88-6b7737b831db` </details> <details> <summary>📥 Commits</summary> Reviewing files that changed from the base of the PR and between 457d8c7ba43c2b459ec0158cea7783b5c95d49bc and 3f75be518040aa1e0a87c5f090a5ab8dea95bc2d. </details> <details> <summary>📒 Files selected for processing (1)</summary> * `sim/side.ts` </details> </details> <!-- This is an auto-generated comment by CodeRabbit for review status -->
smell-of-curry commented 2026-04-28 22:42:26 +00:00 (Migrated from github.com)

@cursor review

@cursor review
github-advanced-security[bot] (Migrated from github.com) reviewed 2026-04-28 22:43:03 +00:00
@ -0,0 +486,4 @@
isSideId(event.pokemon.side) &&
isValidActivePosition(event.pokemon.position)
) {
this.active[event.pokemon.side][event.pokemon.position] = mon.id;
github-advanced-security[bot] (Migrated from github.com) commented 2026-04-28 22:43:03 +00:00

CodeQL / Prototype-polluting assignment

This assignment may alter Object.prototype if a malicious 'proto' string is injected from library input.

Show more details

## CodeQL / Prototype-polluting assignment This assignment may alter Object.prototype if a malicious '__proto__' string is injected from [library input](1). [Show more details](https://github.com/smell-of-curry/pokebedrock-showdown/security/code-scanning/34)
smell-of-curry commented 2026-04-28 22:45:35 +00:00 (Migrated from github.com)

@bugbot review

@bugbot review
coderabbitai[bot] (Migrated from github.com) reviewed 2026-04-28 22:53:40 +00:00
coderabbitai[bot] (Migrated from github.com) left a comment

Actionable comments posted: 13

♻️ Duplicate comments (1)
sim/tools/strategic-ai/state/BattleStateTracker.ts (1)

525-536: ⚠️ Potential issue | 🟠 Major

Validate boost stat keys before writing into mon.boosts.

event.stat is log-derived input; writing directly to mon.boosts[event.stat] allows special keys like __proto__ and can cause prototype-polluting behavior.

Suggested fix
+const BOOST_STATS = new Set(["atk", "def", "spa", "spd", "spe", "accuracy", "evasion"]);
+
@@
 			case "boost":
 			case "unboost":
 			case "setboost": {
 				const mon = this.resolveByRef(event.pokemon);
-				const cur = mon.boosts[event.stat] || 0;
+				if (!BOOST_STATS.has(event.stat)) return;
+				const cur = mon.boosts[event.stat] || 0;

Also applies to: 554-558

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 525 - 536,
The code writes unvalidated event.stat into mon.boosts which allows prototype
pollution; before assigning in the boost/unboost/setboost handling inside
BattleStateTracker (the switch branch that calls resolveByRef and uses
clampStage), validate event.stat against the canonical stat keys (e.g., a
predefined set/list of allowed stats) and only perform mon.boosts[event.stat] =
... when event.stat is in that set; otherwise ignore or early-return/log the
invalid stat. Apply the same validation to the other boost-handling block
referenced (around lines 554-558) so both places use the same allowed-stats
check.
🧹 Nitpick comments (1)
sim/tools/strategic-ai/mechanics/TransformPolicy.ts (1)

92-95: Comment/logic mismatch in Tera “threat could KO” path.

The comment says this is contingent on the foe threatening a KO, but the code only checks myHp < 0.4 && offScore > 12. Either add an explicit threat check or reword the comment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts` around lines 92 - 95,
The comment in TransformPolicy.ts wrongly asserts a "threat could KO" condition
while the code only checks myHp and offScore; fix it by either (A) adding an
explicit threat check into the conditional (use the existing KO/threat detection
helper or compute a boolean like threatCouldKO and include it alongside myHp <
0.4 && offScore > 12) so the return only happens when the foe can actually KO,
or (B) if no threat check is desired, update the comment to reflect the actual
logic; reference the variables myHp and offScore and the surrounding return {
suffix: " terastallize", rationale: "tera-save-sweeper" } to locate the code to
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@sim/tools/player-ai.ts`:
- Around line 181-184: The regex that extracts the Pokémon name in the
disabled-move parser (currently /^([^']+)'s ([^ ]+(?: [^ ]+)*) is disabled$/)
fails for names with apostrophes like "Farfetch'd"; update the pattern to allow
apostrophes (or any character) in the owner group, e.g. use a non-greedy capture
like /^(.+?)'s ([^ ]+(?: [^ ]+)*) is disabled$/ so m2[1] (monName) correctly
includes names with apostrophes, then keep the existing trim and toID(move)
logic to update disabledMovesByMon.

In `@sim/tools/strategic-ai/engines/RandomEngine.ts`:
- Around line 149-158: The code is indexing team-order myPokemon[i] inside the
activeSlots.map callback which uses active-slot order and causes wrong-mon
checks; replace uses of myPokemon[i] (the const me) with the active-slot's
Pokémon data (use active or active.pokemon) and read
condition/commanding/transform flags from active (or active.pokemon) instead of
myPokemon[i]; if you must resolve to a team array entry, locate it by matching
the active slot/ident to myPokemon (e.g., find by slot/ident) so all checks
(condition, commanding,
canMegaEvo/canUltraBurst/canZMove/canDynamax/canTerastallize and transformsUsed)
refer to the correct Pokémon in the activeSlots.map callback.
- Around line 230-245: The move-target selection currently hardcodes choosing
"1" or "2" for foe targets in the finalMoves loop (choiceStr assembly), which
can point at empty/fainted slots; instead, compute the list of currently valid
foe target indices for the attacker (use request.active and the battle state to
filter out fainted/empty foe slots), pick one index from that list via
ctx.prng.random(), and append that chosen 1-based index to choiceStr; keep
existing special-case handling for adjacentAlly/adjacentAllyOrSelf and use i
where appropriate so the selection logic in the RandomEngine.ts loop (variables:
finalMoves, choiceStr, request.active, opt.target, ctx.prng, hasAlly, i) always
targets a live foe slot rather than hard-coded {1,2}.

In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts`:
- Around line 298-305: The tera-on-existing-STAB branch is inert because
fromTracked never sets attacker.originalTypes; update the tracker-path that
constructs attacker objects (the fromTracked routine and the analogous code
around the other occurrence) to set attacker.originalTypes =
attacker.types.slice() (or a shallow copy) before you overwrite attacker.types
with the teraType or any transform-induced types, and ensure subsequent code
uses that originalTypes field without mutating it so teraMatchesMove can detect
pre-Tera types for DamageCalc's teraOnOriginalStab logic.

In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts`:
- Around line 155-162: The generic recovery branch in MoveEvaluator.ts uses
isRecoveryMove(moveId, move) which currently includes "wish", so the dedicated
wish handling never runs; fix by special-casing wish before calling
isRecoveryMove (e.g., check if moveId === "wish" or move.name === "wish" and run
the custom wish logic that sets its own score/rationale) or alter isRecoveryMove
to exclude "wish" and keep the dedicated wish branch intact; update references
to moveId, myHp, attacker.status and ensure the wish branch returns the move
result so execution doesn't fall through to the generic recovery block.
- Around line 67-78: The hazard-removal bonus for rapidspin/mortalspin is never
reached because evaluateStatus is only used for Status moves; update
evaluateMove to explicitly handle hazard-removal for damaging moves by checking
the moveId (e.g., "rapidspin" and "mortalspin") and applying the same
hazard-removal scoring logic currently in evaluateStatus (also mirror the fix at
the other occurrence around lines 207-219). Locate evaluateMove and the
hazard-removal logic inside evaluateStatus, extract or duplicate the minimal
scoring adjustments for those moveIds so that damaging-removal moves get the
hazard bonus even when m.category !== "Status", and ensure moveId comparisons
use toID-normalized ids.

In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts`:
- Around line 244-259: scaledSpeed incorrectly always applies Swift Swim
doubling; update it to only double when the field weather is rain. Change
scaledSpeed(mon, tailwind) to accept the current weather (e.g., scaledSpeed(mon,
tailwind, weather)) and in computeSpeedDelta pass tracker.field.weather; inside
scaledSpeed only apply the swiftswim branch when toID(mon.ability) ===
"swiftswim" && (weather === "raindance" || weather === "primordialsea") (or your
codebase's rain identifiers). Keep all other adjustments (boosts, choice scarf,
tailwind, paralysis) unchanged.

In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts`:
- Around line 58-71: The target-class branch in TargetPicker.ts currently only
filters out foe/self/all targets and then proceeds to score foeSlots, causing
moves with target classes adjacentAlly and adjacentAllyOrSelf to be treated as
foe-targets; update the early target-class check (the block using move.target or
"normal") to also handle "adjacentAlly" and "adjacentAllyOrSelf" (and any other
ally-oriented targets) before any foe scoring so the function returns the
appropriate non-foe result (e.g., null or the proper negative-index handling
flow) instead of falling through to the foeSlots logic; ensure the change is
applied within the same function that references move.target and foeSlots so
ally/self-targeted moves like Helping Hand or Coaching don't produce illegal
positive foe indices.

In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts`:
- Around line 213-217: The current conditional in TransformPolicy.ts always
returns a zmove for status moves (the branch that returns { suffix: " zmove",
rationale: "z-status" }), which consumes the one-shot Z resource; change the
logic so status-category moves do NOT auto-consume Z: remove the "move.category
=== \"Status\"" check and only return the zmove for the case where move does not
exist (i.e., keep only !move?.exists), or add an explicit guard that requires
move.category !== "Status" before returning the zmove; update the conditional
around the z-status return accordingly so routine setup/status moves no longer
trigger suffix: " zmove".

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts`:
- Line 368: The update for item in BattleStateTracker incorrectly uses a
falsy-coalescing pattern (data.item || tracked.item) which discards legitimate
empty-string updates; in the block guarded by if (data.item !== undefined)
inside the code that updates tracked.item, assign tracked.item = data.item
directly (remove the "|| tracked.item" fallback) so explicit empty or falsy
values from data.item are preserved rather than ignored.

In `@sim/tools/strategic-ai/state/LogParser.ts`:
- Around line 162-179: The parser in LogParser.ts currently uses expressions
like parseInt(...) || 0 in the "turn" and "teamsize" cases which silently
converts malformed numbers (NaN) into 0 and violates the parser's contract;
change both to explicitly parse and validate the numeric value (e.g., const n =
parseInt(args[0]); if (Number.isNaN(n)) return null;) and only then return the
object with the validated number (use the validated n for turn and the validated
size for teamsize), while keeping the existing side presence check in the
teamsize branch.

In `@sim/tools/strategic-ai/state/OpponentInference.ts`:
- Around line 111-124: When unseen.length === 0 the function currently returns
dist with total mass revealedShare (<1); instead normalize the distribution to
sum to 1 before returning: compute the current total mass from dist (over
entries in learnset/revealed), if total > 0 scale every probability in dist by
1/total, and if total === 0 (defensive) assign a uniform distribution over
learnset; adjust logic around revealedShare/unseen and use the existing dist,
learnset, legalMoves, revealedShare, and unseen identifiers to locate and update
the normalization step.

---

Duplicate comments:
In `@sim/tools/strategic-ai/state/BattleStateTracker.ts`:
- Around line 525-536: The code writes unvalidated event.stat into mon.boosts
which allows prototype pollution; before assigning in the boost/unboost/setboost
handling inside BattleStateTracker (the switch branch that calls resolveByRef
and uses clampStage), validate event.stat against the canonical stat keys (e.g.,
a predefined set/list of allowed stats) and only perform mon.boosts[event.stat]
= ... when event.stat is in that set; otherwise ignore or early-return/log the
invalid stat. Apply the same validation to the other boost-handling block
referenced (around lines 554-558) so both places use the same allowed-stats
check.

---

Nitpick comments:
In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts`:
- Around line 92-95: The comment in TransformPolicy.ts wrongly asserts a "threat
could KO" condition while the code only checks myHp and offScore; fix it by
either (A) adding an explicit threat check into the conditional (use the
existing KO/threat detection helper or compute a boolean like threatCouldKO and
include it alongside myHp < 0.4 && offScore > 12) so the return only happens
when the foe can actually KO, or (B) if no threat check is desired, update the
comment to reflect the actual logic; reference the variables myHp and offScore
and the surrounding return { suffix: " terastallize", rationale:
"tera-save-sweeper" } to locate the code to change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 95dde378-8c60-49e1-a970-340d1e0bfaa1

📥 Commits

Reviewing files that changed from the base of the PR and between 3f75be5180 and ed4d2de93b.

📒 Files selected for processing (13)
  • sim/tools/player-ai.ts
  • sim/tools/strategic-ai/engines/LightHeuristicEngine.ts
  • sim/tools/strategic-ai/engines/MctsEngine.ts
  • sim/tools/strategic-ai/engines/RandomEngine.ts
  • sim/tools/strategic-ai/mechanics/DamageCalc.ts
  • sim/tools/strategic-ai/mechanics/MoveEvaluator.ts
  • sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts
  • sim/tools/strategic-ai/mechanics/TargetPicker.ts
  • sim/tools/strategic-ai/mechanics/TransformPolicy.ts
  • sim/tools/strategic-ai/state/BattleStateTracker.ts
  • sim/tools/strategic-ai/state/LogParser.ts
  • sim/tools/strategic-ai/state/OpponentInference.ts
  • test/sim/tools/strategic-ai/log-parser.js
**Actionable comments posted: 13** <details> <summary>♻️ Duplicate comments (1)</summary><blockquote> <details> <summary>sim/tools/strategic-ai/state/BattleStateTracker.ts (1)</summary><blockquote> `525-536`: _⚠️ Potential issue_ | _🟠 Major_ **Validate boost stat keys before writing into `mon.boosts`.** `event.stat` is log-derived input; writing directly to `mon.boosts[event.stat]` allows special keys like `__proto__` and can cause prototype-polluting behavior. <details> <summary>Suggested fix</summary> ```diff +const BOOST_STATS = new Set(["atk", "def", "spa", "spd", "spe", "accuracy", "evasion"]); + @@ case "boost": case "unboost": case "setboost": { const mon = this.resolveByRef(event.pokemon); - const cur = mon.boosts[event.stat] || 0; + if (!BOOST_STATS.has(event.stat)) return; + const cur = mon.boosts[event.stat] || 0; ``` </details> Also applies to: 554-558 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` around lines 525 - 536, The code writes unvalidated event.stat into mon.boosts which allows prototype pollution; before assigning in the boost/unboost/setboost handling inside BattleStateTracker (the switch branch that calls resolveByRef and uses clampStage), validate event.stat against the canonical stat keys (e.g., a predefined set/list of allowed stats) and only perform mon.boosts[event.stat] = ... when event.stat is in that set; otherwise ignore or early-return/log the invalid stat. Apply the same validation to the other boost-handling block referenced (around lines 554-558) so both places use the same allowed-stats check. ``` </details> </blockquote></details> </blockquote></details> <details> <summary>🧹 Nitpick comments (1)</summary><blockquote> <details> <summary>sim/tools/strategic-ai/mechanics/TransformPolicy.ts (1)</summary><blockquote> `92-95`: **Comment/logic mismatch in Tera “threat could KO” path.** The comment says this is contingent on the foe threatening a KO, but the code only checks `myHp < 0.4 && offScore > 12`. Either add an explicit threat check or reword the comment. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts` around lines 92 - 95, The comment in TransformPolicy.ts wrongly asserts a "threat could KO" condition while the code only checks myHp and offScore; fix it by either (A) adding an explicit threat check into the conditional (use the existing KO/threat detection helper or compute a boolean like threatCouldKO and include it alongside myHp < 0.4 && offScore > 12) so the return only happens when the foe can actually KO, or (B) if no threat check is desired, update the comment to reflect the actual logic; reference the variables myHp and offScore and the surrounding return { suffix: " terastallize", rationale: "tera-save-sweeper" } to locate the code to change. ``` </details> </blockquote></details> </blockquote></details> <details> <summary>🤖 Prompt for all review comments with AI agents</summary> ``` Verify each finding against the current code and only fix it if needed. Inline comments: In `@sim/tools/player-ai.ts`: - Around line 181-184: The regex that extracts the Pokémon name in the disabled-move parser (currently /^([^']+)'s ([^ ]+(?: [^ ]+)*) is disabled$/) fails for names with apostrophes like "Farfetch'd"; update the pattern to allow apostrophes (or any character) in the owner group, e.g. use a non-greedy capture like /^(.+?)'s ([^ ]+(?: [^ ]+)*) is disabled$/ so m2[1] (monName) correctly includes names with apostrophes, then keep the existing trim and toID(move) logic to update disabledMovesByMon. In `@sim/tools/strategic-ai/engines/RandomEngine.ts`: - Around line 149-158: The code is indexing team-order myPokemon[i] inside the activeSlots.map callback which uses active-slot order and causes wrong-mon checks; replace uses of myPokemon[i] (the const me) with the active-slot's Pokémon data (use active or active.pokemon) and read condition/commanding/transform flags from active (or active.pokemon) instead of myPokemon[i]; if you must resolve to a team array entry, locate it by matching the active slot/ident to myPokemon (e.g., find by slot/ident) so all checks (condition, commanding, canMegaEvo/canUltraBurst/canZMove/canDynamax/canTerastallize and transformsUsed) refer to the correct Pokémon in the activeSlots.map callback. - Around line 230-245: The move-target selection currently hardcodes choosing "1" or "2" for foe targets in the finalMoves loop (choiceStr assembly), which can point at empty/fainted slots; instead, compute the list of currently valid foe target indices for the attacker (use request.active and the battle state to filter out fainted/empty foe slots), pick one index from that list via ctx.prng.random(), and append that chosen 1-based index to choiceStr; keep existing special-case handling for adjacentAlly/adjacentAllyOrSelf and use i where appropriate so the selection logic in the RandomEngine.ts loop (variables: finalMoves, choiceStr, request.active, opt.target, ctx.prng, hasAlly, i) always targets a live foe slot rather than hard-coded {1,2}. In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts`: - Around line 298-305: The tera-on-existing-STAB branch is inert because fromTracked never sets attacker.originalTypes; update the tracker-path that constructs attacker objects (the fromTracked routine and the analogous code around the other occurrence) to set attacker.originalTypes = attacker.types.slice() (or a shallow copy) before you overwrite attacker.types with the teraType or any transform-induced types, and ensure subsequent code uses that originalTypes field without mutating it so teraMatchesMove can detect pre-Tera types for DamageCalc's teraOnOriginalStab logic. In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts`: - Around line 155-162: The generic recovery branch in MoveEvaluator.ts uses isRecoveryMove(moveId, move) which currently includes "wish", so the dedicated wish handling never runs; fix by special-casing wish before calling isRecoveryMove (e.g., check if moveId === "wish" or move.name === "wish" and run the custom wish logic that sets its own score/rationale) or alter isRecoveryMove to exclude "wish" and keep the dedicated wish branch intact; update references to moveId, myHp, attacker.status and ensure the wish branch returns the move result so execution doesn't fall through to the generic recovery block. - Around line 67-78: The hazard-removal bonus for rapidspin/mortalspin is never reached because evaluateStatus is only used for Status moves; update evaluateMove to explicitly handle hazard-removal for damaging moves by checking the moveId (e.g., "rapidspin" and "mortalspin") and applying the same hazard-removal scoring logic currently in evaluateStatus (also mirror the fix at the other occurrence around lines 207-219). Locate evaluateMove and the hazard-removal logic inside evaluateStatus, extract or duplicate the minimal scoring adjustments for those moveIds so that damaging-removal moves get the hazard bonus even when m.category !== "Status", and ensure moveId comparisons use toID-normalized ids. In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts`: - Around line 244-259: scaledSpeed incorrectly always applies Swift Swim doubling; update it to only double when the field weather is rain. Change scaledSpeed(mon, tailwind) to accept the current weather (e.g., scaledSpeed(mon, tailwind, weather)) and in computeSpeedDelta pass tracker.field.weather; inside scaledSpeed only apply the swiftswim branch when toID(mon.ability) === "swiftswim" && (weather === "raindance" || weather === "primordialsea") (or your codebase's rain identifiers). Keep all other adjustments (boosts, choice scarf, tailwind, paralysis) unchanged. In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts`: - Around line 58-71: The target-class branch in TargetPicker.ts currently only filters out foe/self/all targets and then proceeds to score foeSlots, causing moves with target classes adjacentAlly and adjacentAllyOrSelf to be treated as foe-targets; update the early target-class check (the block using move.target or "normal") to also handle "adjacentAlly" and "adjacentAllyOrSelf" (and any other ally-oriented targets) before any foe scoring so the function returns the appropriate non-foe result (e.g., null or the proper negative-index handling flow) instead of falling through to the foeSlots logic; ensure the change is applied within the same function that references move.target and foeSlots so ally/self-targeted moves like Helping Hand or Coaching don't produce illegal positive foe indices. In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts`: - Around line 213-217: The current conditional in TransformPolicy.ts always returns a zmove for status moves (the branch that returns { suffix: " zmove", rationale: "z-status" }), which consumes the one-shot Z resource; change the logic so status-category moves do NOT auto-consume Z: remove the "move.category === \"Status\"" check and only return the zmove for the case where move does not exist (i.e., keep only !move?.exists), or add an explicit guard that requires move.category !== "Status" before returning the zmove; update the conditional around the z-status return accordingly so routine setup/status moves no longer trigger suffix: " zmove". In `@sim/tools/strategic-ai/state/BattleStateTracker.ts`: - Line 368: The update for item in BattleStateTracker incorrectly uses a falsy-coalescing pattern (data.item || tracked.item) which discards legitimate empty-string updates; in the block guarded by if (data.item !== undefined) inside the code that updates tracked.item, assign tracked.item = data.item directly (remove the "|| tracked.item" fallback) so explicit empty or falsy values from data.item are preserved rather than ignored. In `@sim/tools/strategic-ai/state/LogParser.ts`: - Around line 162-179: The parser in LogParser.ts currently uses expressions like parseInt(...) || 0 in the "turn" and "teamsize" cases which silently converts malformed numbers (NaN) into 0 and violates the parser's contract; change both to explicitly parse and validate the numeric value (e.g., const n = parseInt(args[0]); if (Number.isNaN(n)) return null;) and only then return the object with the validated number (use the validated n for turn and the validated size for teamsize), while keeping the existing side presence check in the teamsize branch. In `@sim/tools/strategic-ai/state/OpponentInference.ts`: - Around line 111-124: When unseen.length === 0 the function currently returns dist with total mass revealedShare (<1); instead normalize the distribution to sum to 1 before returning: compute the current total mass from dist (over entries in learnset/revealed), if total > 0 scale every probability in dist by 1/total, and if total === 0 (defensive) assign a uniform distribution over learnset; adjust logic around revealedShare/unseen and use the existing dist, learnset, legalMoves, revealedShare, and unseen identifiers to locate and update the normalization step. --- Duplicate comments: In `@sim/tools/strategic-ai/state/BattleStateTracker.ts`: - Around line 525-536: The code writes unvalidated event.stat into mon.boosts which allows prototype pollution; before assigning in the boost/unboost/setboost handling inside BattleStateTracker (the switch branch that calls resolveByRef and uses clampStage), validate event.stat against the canonical stat keys (e.g., a predefined set/list of allowed stats) and only perform mon.boosts[event.stat] = ... when event.stat is in that set; otherwise ignore or early-return/log the invalid stat. Apply the same validation to the other boost-handling block referenced (around lines 554-558) so both places use the same allowed-stats check. --- Nitpick comments: In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts`: - Around line 92-95: The comment in TransformPolicy.ts wrongly asserts a "threat could KO" condition while the code only checks myHp and offScore; fix it by either (A) adding an explicit threat check into the conditional (use the existing KO/threat detection helper or compute a boolean like threatCouldKO and include it alongside myHp < 0.4 && offScore > 12) so the return only happens when the foe can actually KO, or (B) if no threat check is desired, update the comment to reflect the actual logic; reference the variables myHp and offScore and the surrounding return { suffix: " terastallize", rationale: "tera-save-sweeper" } to locate the code to change. ``` </details> <details> <summary>🪄 Autofix (Beta)</summary> Fix all unresolved CodeRabbit comments on this PR: - [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended) - [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes </details> --- <details> <summary>ℹ️ Review info</summary> <details> <summary>⚙️ Run configuration</summary> **Configuration used**: defaults **Review profile**: CHILL **Plan**: Pro **Run ID**: `95dde378-8c60-49e1-a970-340d1e0bfaa1` </details> <details> <summary>📥 Commits</summary> Reviewing files that changed from the base of the PR and between 3f75be518040aa1e0a87c5f090a5ab8dea95bc2d and ed4d2de93b22958b39bb74eae19c0faa4268ce39. </details> <details> <summary>📒 Files selected for processing (13)</summary> * `sim/tools/player-ai.ts` * `sim/tools/strategic-ai/engines/LightHeuristicEngine.ts` * `sim/tools/strategic-ai/engines/MctsEngine.ts` * `sim/tools/strategic-ai/engines/RandomEngine.ts` * `sim/tools/strategic-ai/mechanics/DamageCalc.ts` * `sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` * `sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` * `sim/tools/strategic-ai/mechanics/TargetPicker.ts` * `sim/tools/strategic-ai/mechanics/TransformPolicy.ts` * `sim/tools/strategic-ai/state/BattleStateTracker.ts` * `sim/tools/strategic-ai/state/LogParser.ts` * `sim/tools/strategic-ai/state/OpponentInference.ts` * `test/sim/tools/strategic-ai/log-parser.js` </details> </details> <!-- This is an auto-generated comment by CodeRabbit for review status -->
@ -0,0 +1,234 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:37 +00:00

⚠️ Potential issue | 🟡 Minor

Disabled-move parsing fails for Pokémon names containing apostrophes.

Line 181 uses ^([^']+)'s ..., which breaks on names like Farfetch'd, so disabledMovesByMon may not be updated.

Suggested fix
-		const m2 = /^([^']+)'s ([^ ]+(?: [^ ]+)*) is disabled$/.exec(message);
+		const m2 = /^(.*)'s (.+) is disabled$/.exec(message);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

		const m2 = /^(.*)'s (.+) is disabled$/.exec(message);
		if (!m2) return;
		const monName = m2[1].trim();
		const moveId = toID(m2[2]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/player-ai.ts` around lines 181 - 184, The regex that extracts the
Pokémon name in the disabled-move parser (currently /^([^']+)'s ([^ ]+(?: [^
]+)*) is disabled$/) fails for names with apostrophes like "Farfetch'd"; update
the pattern to allow apostrophes (or any character) in the owner group, e.g. use
a non-greedy capture like /^(.+?)'s ([^ ]+(?: [^ ]+)*) is disabled$/ so m2[1]
(monName) correctly includes names with apostrophes, then keep the existing trim
and toID(move) logic to update disabledMovesByMon.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟡 Minor_ **Disabled-move parsing fails for Pokémon names containing apostrophes.** Line 181 uses `^([^']+)'s ...`, which breaks on names like `Farfetch'd`, so `disabledMovesByMon` may not be updated. <details> <summary>Suggested fix</summary> ```diff - const m2 = /^([^']+)'s ([^ ]+(?: [^ ]+)*) is disabled$/.exec(message); + const m2 = /^(.*)'s (.+) is disabled$/.exec(message); ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion const m2 = /^(.*)'s (.+) is disabled$/.exec(message); if (!m2) return; const monName = m2[1].trim(); const moveId = toID(m2[2]); ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/player-ai.ts` around lines 181 - 184, The regex that extracts the Pokémon name in the disabled-move parser (currently /^([^']+)'s ([^ ]+(?: [^ ]+)*) is disabled$/) fails for names with apostrophes like "Farfetch'd"; update the pattern to allow apostrophes (or any character) in the owner group, e.g. use a non-greedy capture like /^(.+?)'s ([^ ]+(?: [^ ]+)*) is disabled$/ so m2[1] (monName) correctly includes names with apostrophes, then keep the existing trim and toID(move) logic to update disabledMovesByMon. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper:4bf30613-1bcd-40b5-90bc-2f583221155d --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +202,4 @@
}
return target ? `move ${best.idx} ${target}` : `move ${best.idx}`;
}
case "adjacentAlly":
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:37 +00:00

⚠️ Potential issue | 🔴 Critical

Don't hard-code foe target 1 in doubles.

For targetable foe moves, this always emits move N 1. If the only live foe is in slot 2, or slot 1 is empty after a KO, the command is illegal and the turn fails. The engine can stay "light" and still choose the first live foe slot instead of a fixed constant.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🔴 Critical_ **Don't hard-code foe target `1` in doubles.** For targetable foe moves, this always emits `move N 1`. If the only live foe is in slot `2`, or slot `1` is empty after a KO, the command is illegal and the turn fails. The engine can stay "light" and still choose the first live foe slot instead of a fixed constant. <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +155,4 @@
const canUltraBurst = !!active.canUltraBurst && !transformsUsed.ultra;
let canZMove = !!active.canZMove;
const canDynamax = !!active.canDynamax && !transformsUsed.dynamax;
const canTerastallize = !!active.canTerastallize && !transformsUsed.tera;
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🔴 Critical

Don't look up the acting mon by team order here.

This reintroduces the indexing bug the force-switch path already avoids: request.active[i] is active-slot order, but side.pokemon[i] is team order. When the active mons are not the first N entries, Lines 150-158 read the wrong Pokémon's condition / commanding / transform flags and can emit pass or a move for the wrong slot.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 149 - 158, The
code is indexing team-order myPokemon[i] inside the activeSlots.map callback
which uses active-slot order and causes wrong-mon checks; replace uses of
myPokemon[i] (the const me) with the active-slot's Pokémon data (use active or
active.pokemon) and read condition/commanding/transform flags from active (or
active.pokemon) instead of myPokemon[i]; if you must resolve to a team array
entry, locate it by matching the active slot/ident to myPokemon (e.g., find by
slot/ident) so all checks (condition, commanding,
canMegaEvo/canUltraBurst/canZMove/canDynamax/canTerastallize and transformsUsed)
refer to the correct Pokémon in the activeSlots.map callback.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🔴 Critical_ **Don't look up the acting mon by team order here.** This reintroduces the indexing bug the force-switch path already avoids: `request.active[i]` is active-slot order, but `side.pokemon[i]` is team order. When the active mons are not the first `N` entries, Lines 150-158 read the wrong Pokémon's `condition` / `commanding` / transform flags and can emit `pass` or a move for the wrong slot. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 149 - 158, The code is indexing team-order myPokemon[i] inside the activeSlots.map callback which uses active-slot order and causes wrong-mon checks; replace uses of myPokemon[i] (the const me) with the active-slot's Pokémon data (use active or active.pokemon) and read condition/commanding/transform flags from active (or active.pokemon) instead of myPokemon[i]; if you must resolve to a team array entry, locate it by matching the active slot/ident to myPokemon (e.g., find by slot/ident) so all checks (condition, commanding, canMegaEvo/canUltraBurst/canZMove/canDynamax/canTerastallize and transformsUsed) refer to the correct Pokémon in the activeSlots.map callback. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- 4e71b3a2 --> <!-- This is an auto-generated reply by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +258,4 @@
choiceStr += ` -${i + 1}`;
}
}
}
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🔴 Critical

Pick from live foe slots instead of hard-coding {1, 2}.

For normal / any / adjacentFoe moves, Line 234 chooses 1 or 2 blindly. After a KO, that can point at an empty or fainted foe slot, so the simulator rejects an otherwise legal move.

Suggested fix
 			for (const opt of finalMoves) {
 				let choiceStr = `move ${opt.slot}`;
 				if (request.active && request.active.length > 1) {
 					if (["normal", "any", "adjacentFoe"].includes(opt.target ?? "")) {
-						choiceStr += ` ${1 + Math.floor(ctx.prng.random() * 2)}`;
+						const liveFoeTargets = (request.side.foePokemon ?? [])
+							.map((p, idx) => ({ p, target: (idx + 1) as 1 | 2 | 3 }))
+							.filter(({ p }) => p.active && !p.condition.endsWith(" fnt"));
+						const chosenTarget = ctx.prng.sample(liveFoeTargets).target;
+						choiceStr += ` ${chosenTarget}`;
 					}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 230 - 245, The
move-target selection currently hardcodes choosing "1" or "2" for foe targets in
the finalMoves loop (choiceStr assembly), which can point at empty/fainted
slots; instead, compute the list of currently valid foe target indices for the
attacker (use request.active and the battle state to filter out fainted/empty
foe slots), pick one index from that list via ctx.prng.random(), and append that
chosen 1-based index to choiceStr; keep existing special-case handling for
adjacentAlly/adjacentAllyOrSelf and use i where appropriate so the selection
logic in the RandomEngine.ts loop (variables: finalMoves, choiceStr,
request.active, opt.target, ctx.prng, hasAlly, i) always targets a live foe slot
rather than hard-coded {1,2}.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🔴 Critical_ **Pick from live foe slots instead of hard-coding `{1, 2}`.** For `normal` / `any` / `adjacentFoe` moves, Line 234 chooses `1` or `2` blindly. After a KO, that can point at an empty or fainted foe slot, so the simulator rejects an otherwise legal move. <details> <summary>Suggested fix</summary> ```diff for (const opt of finalMoves) { let choiceStr = `move ${opt.slot}`; if (request.active && request.active.length > 1) { if (["normal", "any", "adjacentFoe"].includes(opt.target ?? "")) { - choiceStr += ` ${1 + Math.floor(ctx.prng.random() * 2)}`; + const liveFoeTargets = (request.side.foePokemon ?? []) + .map((p, idx) => ({ p, target: (idx + 1) as 1 | 2 | 3 })) + .filter(({ p }) => p.active && !p.condition.endsWith(" fnt")); + const chosenTarget = ctx.prng.sample(liveFoeTargets).target; + choiceStr += ` ${chosenTarget}`; } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/engines/RandomEngine.ts` around lines 230 - 245, The move-target selection currently hardcodes choosing "1" or "2" for foe targets in the finalMoves loop (choiceStr assembly), which can point at empty/fainted slots; instead, compute the list of currently valid foe target indices for the attacker (use request.active and the battle state to filter out fainted/empty foe slots), pick one index from that list via ctx.prng.random(), and append that chosen 1-based index to choiceStr; keep existing special-case handling for adjacentAlly/adjacentAllyOrSelf and use i where appropriate so the selection logic in the RandomEngine.ts loop (variables: finalMoves, choiceStr, request.active, opt.target, ctx.prng, hasAlly, i) always targets a live foe slot rather than hard-coded {1,2}. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +302,4 @@
// overwriting `types` with `[teraType]`.
const teraOnOriginalStab = teraMatchesMove && !!attacker.originalTypes &&
attacker.originalTypes.includes(moveType);
if (teraOnOriginalStab) {
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟠 Major

fromTracked doesn’t populate originalTypes, so tera-on-existing-STAB logic is usually inert.

The STAB branch depends on attacker.originalTypes (Lines 303-305), but fromTracked never sets it, so tracker-based evaluations miss the boosted Tera-existing-STAB case.

Suggested fix
 export function fromTracked(mon: TrackedPokemon): CalcPokemon {
+	const species = Dex.species.get(toID(mon.species));
+	const originalTypes = mon.terastallized ? [...(species.types || mon.types)] : undefined;
 	return {
 		species: toID(mon.species),
 		types: mon.types,
+		originalTypes,
 		teraType: mon.teraType,
 		terastallized: mon.terastallized,

Also applies to: 916-930

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts` around lines 298 - 305, The
tera-on-existing-STAB branch is inert because fromTracked never sets
attacker.originalTypes; update the tracker-path that constructs attacker objects
(the fromTracked routine and the analogous code around the other occurrence) to
set attacker.originalTypes = attacker.types.slice() (or a shallow copy) before
you overwrite attacker.types with the teraType or any transform-induced types,
and ensure subsequent code uses that originalTypes field without mutating it so
teraMatchesMove can detect pre-Tera types for DamageCalc's teraOnOriginalStab
logic.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟠 Major_ **`fromTracked` doesn’t populate `originalTypes`, so tera-on-existing-STAB logic is usually inert.** The STAB branch depends on `attacker.originalTypes` (Lines 303-305), but `fromTracked` never sets it, so tracker-based evaluations miss the boosted Tera-existing-STAB case. <details> <summary>Suggested fix</summary> ```diff export function fromTracked(mon: TrackedPokemon): CalcPokemon { + const species = Dex.species.get(toID(mon.species)); + const originalTypes = mon.terastallized ? [...(species.types || mon.types)] : undefined; return { species: toID(mon.species), types: mon.types, + originalTypes, teraType: mon.teraType, terastallized: mon.terastallized, ``` </details> Also applies to: 916-930 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/DamageCalc.ts` around lines 298 - 305, The tera-on-existing-STAB branch is inert because fromTracked never sets attacker.originalTypes; update the tracker-path that constructs attacker objects (the fromTracked routine and the analogous code around the other occurrence) to set attacker.originalTypes = attacker.types.slice() (or a shallow copy) before you overwrite attacker.types with the teraType or any transform-induced types, and ensure subsequent code uses that originalTypes field without mutating it so teraMatchesMove can detect pre-Tera types for DamageCalc's teraOnOriginalStab logic. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper:4bf30613-1bcd-40b5-90bc-2f583221155d --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +75,4 @@
}
if (m.category === "Status") {
return evaluateStatus(m, moveId, ctx);
}
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟠 Major

rapidspin/mortalspin hazard-removal scoring is unreachable in current control flow.

evaluateStatus is only called for Status-category moves (Line 76), but rapidspin and mortalspin are damaging moves, so their hazard-removal bonus here never executes.

Suggested fix
 export function evaluateMove(
 	move: Move | string,
 	ctx: MoveEvalContext
 ): MoveEvaluation {
@@
 	let score = damageScore * calc.hitChance;
 	let rationale = "damage";
+
+	// Hazard-removal utility for damaging spinners.
+	if (moveId === "rapidspin" || moveId === "mortalspin") {
+		const mySideState = tracker.sides[ctx.mySide];
+		const myHazards =
+			(mySideState.stealthRock ? 1 : 0) +
+			mySideState.spikes +
+			mySideState.toxicSpikes +
+			(mySideState.stickyWeb ? 1 : 0);
+		score += myHazards * 12;
+		if (myHazards > 0) rationale = "hazardRemoval";
+	}

Also applies to: 207-219

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 67 - 78, The
hazard-removal bonus for rapidspin/mortalspin is never reached because
evaluateStatus is only used for Status moves; update evaluateMove to explicitly
handle hazard-removal for damaging moves by checking the moveId (e.g.,
"rapidspin" and "mortalspin") and applying the same hazard-removal scoring logic
currently in evaluateStatus (also mirror the fix at the other occurrence around
lines 207-219). Locate evaluateMove and the hazard-removal logic inside
evaluateStatus, extract or duplicate the minimal scoring adjustments for those
moveIds so that damaging-removal moves get the hazard bonus even when m.category
!== "Status", and ensure moveId comparisons use toID-normalized ids.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟠 Major_ **`rapidspin`/`mortalspin` hazard-removal scoring is unreachable in current control flow.** `evaluateStatus` is only called for Status-category moves (Line 76), but `rapidspin` and `mortalspin` are damaging moves, so their hazard-removal bonus here never executes. <details> <summary>Suggested fix</summary> ```diff export function evaluateMove( move: Move | string, ctx: MoveEvalContext ): MoveEvaluation { @@ let score = damageScore * calc.hitChance; let rationale = "damage"; + + // Hazard-removal utility for damaging spinners. + if (moveId === "rapidspin" || moveId === "mortalspin") { + const mySideState = tracker.sides[ctx.mySide]; + const myHazards = + (mySideState.stealthRock ? 1 : 0) + + mySideState.spikes + + mySideState.toxicSpikes + + (mySideState.stickyWeb ? 1 : 0); + score += myHazards * 12; + if (myHazards > 0) rationale = "hazardRemoval"; + } ``` </details> Also applies to: 207-219 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 67 - 78, The hazard-removal bonus for rapidspin/mortalspin is never reached because evaluateStatus is only used for Status moves; update evaluateMove to explicitly handle hazard-removal for damaging moves by checking the moveId (e.g., "rapidspin" and "mortalspin") and applying the same hazard-removal scoring logic currently in evaluateStatus (also mirror the fix at the other occurrence around lines 207-219). Locate evaluateMove and the hazard-removal logic inside evaluateStatus, extract or duplicate the minimal scoring adjustments for those moveIds so that damaging-removal moves get the hazard bonus even when m.category !== "Status", and ensure moveId comparisons use toID-normalized ids. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper:4bf30613-1bcd-40b5-90bc-2f583221155d --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +175,4 @@
if (attacker.status === "tox" || attacker.status === "psn") score -= 5;
rationale = "recover";
return { moveId, score, rationale };
}
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟠 Major

wish is currently scored by the generic recovery path, making the dedicated wish branch dead.

Because isRecoveryMove includes "wish" (Line 324), execution returns at Line 161 and never reaches the custom wish logic at Lines 284-287.

Suggested fix
 function isRecoveryMove(moveId: string, move: Move): boolean {
 	if (move.heal) return true;
 	switch (moveId) {
@@
-		case "wish":
 		case "healorder":
 		case "lifedew":
 			return true;
 	}
 	return false;
 }

Also applies to: 283-287, 311-325

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 155 - 162,
The generic recovery branch in MoveEvaluator.ts uses isRecoveryMove(moveId,
move) which currently includes "wish", so the dedicated wish handling never
runs; fix by special-casing wish before calling isRecoveryMove (e.g., check if
moveId === "wish" or move.name === "wish" and run the custom wish logic that
sets its own score/rationale) or alter isRecoveryMove to exclude "wish" and keep
the dedicated wish branch intact; update references to moveId, myHp,
attacker.status and ensure the wish branch returns the move result so execution
doesn't fall through to the generic recovery block.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟠 Major_ **`wish` is currently scored by the generic recovery path, making the dedicated `wish` branch dead.** Because `isRecoveryMove` includes `"wish"` (Line 324), execution returns at Line 161 and never reaches the custom `wish` logic at Lines 284-287. <details> <summary>Suggested fix</summary> ```diff function isRecoveryMove(moveId: string, move: Move): boolean { if (move.heal) return true; switch (moveId) { @@ - case "wish": case "healorder": case "lifedew": return true; } return false; } ``` </details> Also applies to: 283-287, 311-325 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/MoveEvaluator.ts` around lines 155 - 162, The generic recovery branch in MoveEvaluator.ts uses isRecoveryMove(moveId, move) which currently includes "wish", so the dedicated wish handling never runs; fix by special-casing wish before calling isRecoveryMove (e.g., check if moveId === "wish" or move.name === "wish" and run the custom wish logic that sets its own score/rationale) or alter isRecoveryMove to exclude "wish" and keep the dedicated wish branch intact; update references to moveId, myHp, attacker.status and ensure the wish branch returns the move result so execution doesn't fall through to the generic recovery block. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper:4bf30613-1bcd-40b5-90bc-2f583221155d --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +1,280 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟠 Major

Gate Swift Swim on rain before doubling Speed.

scaledSpeed() currently doubles Speed for every swiftswim mon, even in clear weather. That skews computeSpeedDelta() and can easily push the switch heuristic toward the wrong candidate for the whole battle.

Suggested fix
 function computeSpeedDelta(
 	mon: TrackedPokemon,
 	foe: TrackedPokemon,
 	tracker: BattleStateTracker
 ): number {
-	const mySpe = scaledSpeed(mon, tracker.sides[tracker.mySide].tailwindTurns > 0);
-	const foeSpe = scaledSpeed(foe, tracker.sides[tracker.foeSide].tailwindTurns > 0);
+	const weather = tracker.field.weather;
+	const mySpe = scaledSpeed(mon, tracker.sides[tracker.mySide].tailwindTurns > 0, weather);
+	const foeSpe = scaledSpeed(foe, tracker.sides[tracker.foeSide].tailwindTurns > 0, weather);
 	let delta = mySpe - foeSpe;
 	if (tracker.field.trickRoom) delta = -delta;
 	return delta;
 }
 
-function scaledSpeed(mon: TrackedPokemon, tailwind: boolean): number {
+function scaledSpeed(mon: TrackedPokemon, tailwind: boolean, weather: string): number {
 	let spe = mon.stats?.spe ?? approximateSpeed(mon);
 	const stage = mon.boosts.spe || 0;
 	if (stage > 0) spe = Math.floor(spe * (2 + stage) / 2);
 	else if (stage < 0) spe = Math.floor(spe * 2 / (2 - stage));
 	if (toID(mon.item) === "choicescarf") spe = Math.floor(spe * 1.5);
-	if (toID(mon.ability) === "swiftswim") spe *= 2;
+	if (toID(mon.ability) === "swiftswim" &&
+		(weather === "raindance" || weather === "primordialsea")) {
+		spe *= 2;
+	}
 	if (tailwind) spe *= 2;
 	if (mon.status === "par") spe = Math.floor(spe / 2);
 	return spe;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

function computeSpeedDelta(
	mon: TrackedPokemon,
	foe: TrackedPokemon,
	tracker: BattleStateTracker
): number {
	const weather = tracker.field.weather;
	const mySpe = scaledSpeed(mon, tracker.sides[tracker.mySide].tailwindTurns > 0, weather);
	const foeSpe = scaledSpeed(foe, tracker.sides[tracker.foeSide].tailwindTurns > 0, weather);
	let delta = mySpe - foeSpe;
	if (tracker.field.trickroom) delta = -delta;
	return delta;
}

function scaledSpeed(mon: TrackedPokemon, tailwind: boolean, weather: string): number {
	let spe = mon.stats?.spe ?? approximateSpeed(mon);
	const stage = mon.boosts.spe || 0;
	if (stage > 0) spe = Math.floor(spe * (2 + stage) / 2);
	else if (stage < 0) spe = Math.floor(spe * 2 / (2 - stage));
	if (toID(mon.item) === "choicescarf") spe = Math.floor(spe * 1.5);
	if (toID(mon.ability) === "swiftswim" &&
		(weather === "raindance" || weather === "primordialsea")) {
		spe *= 2;
	}
	if (tailwind) spe *= 2;
	if (mon.status === "par") spe = Math.floor(spe / 2);
	return spe;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` around lines 244 - 259,
scaledSpeed incorrectly always applies Swift Swim doubling; update it to only
double when the field weather is rain. Change scaledSpeed(mon, tailwind) to
accept the current weather (e.g., scaledSpeed(mon, tailwind, weather)) and in
computeSpeedDelta pass tracker.field.weather; inside scaledSpeed only apply the
swiftswim branch when toID(mon.ability) === "swiftswim" && (weather ===
"raindance" || weather === "primordialsea") (or your codebase's rain
identifiers). Keep all other adjustments (boosts, choice scarf, tailwind,
paralysis) unchanged.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟠 Major_ **Gate Swift Swim on rain before doubling Speed.** `scaledSpeed()` currently doubles Speed for every `swiftswim` mon, even in clear weather. That skews `computeSpeedDelta()` and can easily push the switch heuristic toward the wrong candidate for the whole battle. <details> <summary>Suggested fix</summary> ```diff function computeSpeedDelta( mon: TrackedPokemon, foe: TrackedPokemon, tracker: BattleStateTracker ): number { - const mySpe = scaledSpeed(mon, tracker.sides[tracker.mySide].tailwindTurns > 0); - const foeSpe = scaledSpeed(foe, tracker.sides[tracker.foeSide].tailwindTurns > 0); + const weather = tracker.field.weather; + const mySpe = scaledSpeed(mon, tracker.sides[tracker.mySide].tailwindTurns > 0, weather); + const foeSpe = scaledSpeed(foe, tracker.sides[tracker.foeSide].tailwindTurns > 0, weather); let delta = mySpe - foeSpe; if (tracker.field.trickRoom) delta = -delta; return delta; } -function scaledSpeed(mon: TrackedPokemon, tailwind: boolean): number { +function scaledSpeed(mon: TrackedPokemon, tailwind: boolean, weather: string): number { let spe = mon.stats?.spe ?? approximateSpeed(mon); const stage = mon.boosts.spe || 0; if (stage > 0) spe = Math.floor(spe * (2 + stage) / 2); else if (stage < 0) spe = Math.floor(spe * 2 / (2 - stage)); if (toID(mon.item) === "choicescarf") spe = Math.floor(spe * 1.5); - if (toID(mon.ability) === "swiftswim") spe *= 2; + if (toID(mon.ability) === "swiftswim" && + (weather === "raindance" || weather === "primordialsea")) { + spe *= 2; + } if (tailwind) spe *= 2; if (mon.status === "par") spe = Math.floor(spe / 2); return spe; } ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion function computeSpeedDelta( mon: TrackedPokemon, foe: TrackedPokemon, tracker: BattleStateTracker ): number { const weather = tracker.field.weather; const mySpe = scaledSpeed(mon, tracker.sides[tracker.mySide].tailwindTurns > 0, weather); const foeSpe = scaledSpeed(foe, tracker.sides[tracker.foeSide].tailwindTurns > 0, weather); let delta = mySpe - foeSpe; if (tracker.field.trickroom) delta = -delta; return delta; } function scaledSpeed(mon: TrackedPokemon, tailwind: boolean, weather: string): number { let spe = mon.stats?.spe ?? approximateSpeed(mon); const stage = mon.boosts.spe || 0; if (stage > 0) spe = Math.floor(spe * (2 + stage) / 2); else if (stage < 0) spe = Math.floor(spe * 2 / (2 - stage)); if (toID(mon.item) === "choicescarf") spe = Math.floor(spe * 1.5); if (toID(mon.ability) === "swiftswim" && (weather === "raindance" || weather === "primordialsea")) { spe *= 2; } if (tailwind) spe *= 2; if (mon.status === "par") spe = Math.floor(spe / 2); return spe; } ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/SwitchEvaluator.ts` around lines 244 - 259, scaledSpeed incorrectly always applies Swift Swim doubling; update it to only double when the field weather is rain. Change scaledSpeed(mon, tailwind) to accept the current weather (e.g., scaledSpeed(mon, tailwind, weather)) and in computeSpeedDelta pass tracker.field.weather; inside scaledSpeed only apply the swiftswim branch when toID(mon.ability) === "swiftswim" && (weather === "raindance" || weather === "primordialsea") (or your codebase's rain identifiers). Keep all other adjustments (boosts, choice scarf, tailwind, paralysis) unchanged. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +76,4 @@
) {
return null;
}
if (foeSlots.length === 0) return null;
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🔴 Critical

Handle ally/self target classes before scoring foe slots.

adjacentAlly and adjacentAllyOrSelf fall through into the foe-target path, but this function only ever returns positive foe indices. In doubles that yields illegal commands for ally-targeted moves like Helping Hand or Coaching instead of the negative indices that TargetIndex already models.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts` around lines 58 - 71, The
target-class branch in TargetPicker.ts currently only filters out foe/self/all
targets and then proceeds to score foeSlots, causing moves with target classes
adjacentAlly and adjacentAllyOrSelf to be treated as foe-targets; update the
early target-class check (the block using move.target or "normal") to also
handle "adjacentAlly" and "adjacentAllyOrSelf" (and any other ally-oriented
targets) before any foe scoring so the function returns the appropriate non-foe
result (e.g., null or the proper negative-index handling flow) instead of
falling through to the foeSlots logic; ensure the change is applied within the
same function that references move.target and foeSlots so ally/self-targeted
moves like Helping Hand or Coaching don't produce illegal positive foe indices.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🔴 Critical_ **Handle ally/self target classes before scoring foe slots.** `adjacentAlly` and `adjacentAllyOrSelf` fall through into the foe-target path, but this function only ever returns positive foe indices. In doubles that yields illegal commands for ally-targeted moves like Helping Hand or Coaching instead of the negative indices that `TargetIndex` already models. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/TargetPicker.ts` around lines 58 - 71, The target-class branch in TargetPicker.ts currently only filters out foe/self/all targets and then proceeds to score foeSlots, causing moves with target classes adjacentAlly and adjacentAllyOrSelf to be treated as foe-targets; update the early target-class check (the block using move.target or "normal") to also handle "adjacentAlly" and "adjacentAllyOrSelf" (and any other ally-oriented targets) before any foe scoring so the function returns the appropriate non-foe result (e.g., null or the proper negative-index handling flow) instead of falling through to the foeSlots logic; ensure the change is applied within the same function that references move.target and foeSlots so ally/self-targeted moves like Helping Hand or Coaching don't produce illegal positive foe indices. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +1,303 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟠 Major

Avoid auto-consuming Z on status moves.

This branch always returns zmove for status moves once a matching Z slot exists, which can burn the one-shot resource on routine setup turns and preempt Mega/Dynamax by priority ordering.

Suggested minimal fix
-	if (!move?.exists || move.category === "Status") {
-		// Z-status: useful, but weak; only fire when nothing better is
-		// happening. We approximate via a flat 8 score.
-		return { suffix: " zmove", rationale: "z-status" };
-	}
+	if (!move?.exists) return null;
+	if (move.category === "Status") return null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts` around lines 213 - 217,
The current conditional in TransformPolicy.ts always returns a zmove for status
moves (the branch that returns { suffix: " zmove", rationale: "z-status" }),
which consumes the one-shot Z resource; change the logic so status-category
moves do NOT auto-consume Z: remove the "move.category === \"Status\"" check and
only return the zmove for the case where move does not exist (i.e., keep only
!move?.exists), or add an explicit guard that requires move.category !==
"Status" before returning the zmove; update the conditional around the z-status
return accordingly so routine setup/status moves no longer trigger suffix: "
zmove".

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟠 Major_ **Avoid auto-consuming Z on status moves.** This branch always returns `zmove` for status moves once a matching Z slot exists, which can burn the one-shot resource on routine setup turns and preempt Mega/Dynamax by priority ordering. <details> <summary>Suggested minimal fix</summary> ```diff - if (!move?.exists || move.category === "Status") { - // Z-status: useful, but weak; only fire when nothing better is - // happening. We approximate via a flat 8 score. - return { suffix: " zmove", rationale: "z-status" }; - } + if (!move?.exists) return null; + if (move.category === "Status") return null; ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/mechanics/TransformPolicy.ts` around lines 213 - 217, The current conditional in TransformPolicy.ts always returns a zmove for status moves (the branch that returns { suffix: " zmove", rationale: "z-status" }), which consumes the one-shot Z resource; change the logic so status-category moves do NOT auto-consume Z: remove the "move.category === \"Status\"" check and only return the zmove for the case where move does not exist (i.e., keep only !move?.exists), or add an explicit guard that requires move.category !== "Status" before returning the zmove; update the conditional around the z-status return accordingly so routine setup/status moves no longer trigger suffix: " zmove". ``` </details> <!-- fingerprinting:phantom:medusa:ocelot:39b0a628-5d84-4b66-95fc-91324240feda --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +1,929 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟠 Major

Item updates can remain stale when the request reports an empty item.

Line 368 (data.item || tracked.item) drops valid empty-string updates, so removed/consumed items can persist incorrectly in tracker state.

Suggested fix
-			if (data.item !== undefined) tracked.item = data.item || tracked.item;
+			if (data.item !== undefined) tracked.item = data.item;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

			if (data.item !== undefined) tracked.item = data.item;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` at line 368, The update
for item in BattleStateTracker incorrectly uses a falsy-coalescing pattern
(data.item || tracked.item) which discards legitimate empty-string updates; in
the block guarded by if (data.item !== undefined) inside the code that updates
tracked.item, assign tracked.item = data.item directly (remove the "||
tracked.item" fallback) so explicit empty or falsy values from data.item are
preserved rather than ignored.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟠 Major_ **Item updates can remain stale when the request reports an empty item.** Line 368 (`data.item || tracked.item`) drops valid empty-string updates, so removed/consumed items can persist incorrectly in tracker state. <details> <summary>Suggested fix</summary> ```diff - if (data.item !== undefined) tracked.item = data.item || tracked.item; + if (data.item !== undefined) tracked.item = data.item; ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion if (data.item !== undefined) tracked.item = data.item; ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/BattleStateTracker.ts` at line 368, The update for item in BattleStateTracker incorrectly uses a falsy-coalescing pattern (data.item || tracked.item) which discards legitimate empty-string updates; in the block guarded by if (data.item !== undefined) inside the code that updates tracked.item, assign tracked.item = data.item directly (remove the "|| tracked.item" fallback) so explicit empty or falsy values from data.item are preserved rather than ignored. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper:4bf30613-1bcd-40b5-90bc-2f583221155d --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +1,478 @@
/**
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟡 Minor

Don't coerce malformed numeric fields into valid state.

|turn|foo and |teamsize|p1|foo currently become {kind: 'turn', turn: 0} / {kind: 'teamsize', size: 0} because parseInt(...) || 0 treats NaN as falsy. That fabricates tracker state instead of returning null, which is the contract documented at the top of this parser.

Suggested fix
 		case "turn":
-			return { kind: "turn", turn: parseInt(args[0]) || 0 };
+		{
+			const turn = Number.parseInt(args[0] ?? "", 10);
+			if (Number.isNaN(turn)) return null;
+			return { kind: "turn", turn };
+		}
 		case "gametype":
 			return { kind: "gametype", gametype: args[0] || "singles" };
@@
 		case "teamsize": {
-			const side = args[0] as SideId;
-			const size = parseInt(args[1]) || 0;
-			if (!side) return null;
-			return { kind: "teamsize", side, size };
+			const sideMatch = /^(p[1-4])$/.exec((args[0] || "").trim());
+			const size = Number.parseInt(args[1] ?? "", 10);
+			if (!sideMatch || Number.isNaN(size)) return null;
+			return { kind: "teamsize", side: sideMatch[1] as SideId, size };
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/LogParser.ts` around lines 162 - 179, The parser
in LogParser.ts currently uses expressions like parseInt(...) || 0 in the "turn"
and "teamsize" cases which silently converts malformed numbers (NaN) into 0 and
violates the parser's contract; change both to explicitly parse and validate the
numeric value (e.g., const n = parseInt(args[0]); if (Number.isNaN(n)) return
null;) and only then return the object with the validated number (use the
validated n for turn and the validated size for teamsize), while keeping the
existing side presence check in the teamsize branch.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟡 Minor_ **Don't coerce malformed numeric fields into valid state.** `|turn|foo` and `|teamsize|p1|foo` currently become `{kind: 'turn', turn: 0}` / `{kind: 'teamsize', size: 0}` because `parseInt(...) || 0` treats `NaN` as falsy. That fabricates tracker state instead of returning `null`, which is the contract documented at the top of this parser. <details> <summary>Suggested fix</summary> ```diff case "turn": - return { kind: "turn", turn: parseInt(args[0]) || 0 }; + { + const turn = Number.parseInt(args[0] ?? "", 10); + if (Number.isNaN(turn)) return null; + return { kind: "turn", turn }; + } case "gametype": return { kind: "gametype", gametype: args[0] || "singles" }; @@ case "teamsize": { - const side = args[0] as SideId; - const size = parseInt(args[1]) || 0; - if (!side) return null; - return { kind: "teamsize", side, size }; + const sideMatch = /^(p[1-4])$/.exec((args[0] || "").trim()); + const size = Number.parseInt(args[1] ?? "", 10); + if (!sideMatch || Number.isNaN(size)) return null; + return { kind: "teamsize", side: sideMatch[1] as SideId, size }; } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/LogParser.ts` around lines 162 - 179, The parser in LogParser.ts currently uses expressions like parseInt(...) || 0 in the "turn" and "teamsize" cases which silently converts malformed numbers (NaN) into 0 and violates the parser's contract; change both to explicitly parse and validate the numeric value (e.g., const n = parseInt(args[0]); if (Number.isNaN(n)) return null;) and only then return the object with the validated number (use the validated n for turn and the validated size for teamsize), while keeping the existing side presence check in the teamsize branch. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
@ -0,0 +133,4 @@
for (const id of unseen) {
dist.set(id, (dist.get(id) || 0) + per);
}
return dist;
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 22:53:38 +00:00

⚠️ Potential issue | 🟠 Major

Normalize the move distribution when no unseen moves remain.

If legalMoves() returns nothing, or every legal move is already revealed, this returns a distribution whose total mass is revealedShare instead of 1. That breaks the Distribution contract and underweights fully-known move sets for any consumer that treats these values as probabilities.

Suggested fix
 	const learnset = legalMoves(mon.species);
 	const unseen = learnset.filter(m => !revealed.includes(m));
-	if (unseen.length === 0) return dist;
+	if (unseen.length === 0) {
+		const total = Array.from(dist.values()).reduce((a, b) => a + b, 0);
+		if (total > 0) {
+			for (const [id, value] of Array.from(dist.entries())) {
+				dist.set(id, value / total);
+			}
+		} else {
+			dist.set("", 1);
+		}
+		return dist;
+	}
 	const per = remaining / unseen.length;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

	const revealedShare = Math.min(0.85, revealed.length * 0.25);
	const remaining = 1 - revealedShare;
	for (const id of revealed) {
		dist.set(id, revealedShare / Math.max(1, revealed.length));
	}
	// Spread `remaining` over the species' legal moves we haven't seen yet.
	const learnset = legalMoves(mon.species);
	const unseen = learnset.filter(m => !revealed.includes(m));
	if (unseen.length === 0) {
		const total = Array.from(dist.values()).reduce((a, b) => a + b, 0);
		if (total > 0) {
			for (const [id, value] of Array.from(dist.entries())) {
				dist.set(id, value / total);
			}
		} else {
			dist.set("", 1);
		}
		return dist;
	}
	const per = remaining / unseen.length;
	for (const id of unseen) {
		dist.set(id, (dist.get(id) || 0) + per);
	}
	return dist;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sim/tools/strategic-ai/state/OpponentInference.ts` around lines 111 - 124,
When unseen.length === 0 the function currently returns dist with total mass
revealedShare (<1); instead normalize the distribution to sum to 1 before
returning: compute the current total mass from dist (over entries in
learnset/revealed), if total > 0 scale every probability in dist by 1/total, and
if total === 0 (defensive) assign a uniform distribution over learnset; adjust
logic around revealedShare/unseen and use the existing dist, learnset,
legalMoves, revealedShare, and unseen identifiers to locate and update the
normalization step.

✅ Addressed in commits 7929f94 to 45f24bd

_⚠️ Potential issue_ | _🟠 Major_ **Normalize the move distribution when no unseen moves remain.** If `legalMoves()` returns nothing, or every legal move is already revealed, this returns a distribution whose total mass is `revealedShare` instead of `1`. That breaks the `Distribution` contract and underweights fully-known move sets for any consumer that treats these values as probabilities. <details> <summary>Suggested fix</summary> ```diff const learnset = legalMoves(mon.species); const unseen = learnset.filter(m => !revealed.includes(m)); - if (unseen.length === 0) return dist; + if (unseen.length === 0) { + const total = Array.from(dist.values()).reduce((a, b) => a + b, 0); + if (total > 0) { + for (const [id, value] of Array.from(dist.entries())) { + dist.set(id, value / total); + } + } else { + dist.set("", 1); + } + return dist; + } const per = remaining / unseen.length; ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion const revealedShare = Math.min(0.85, revealed.length * 0.25); const remaining = 1 - revealedShare; for (const id of revealed) { dist.set(id, revealedShare / Math.max(1, revealed.length)); } // Spread `remaining` over the species' legal moves we haven't seen yet. const learnset = legalMoves(mon.species); const unseen = learnset.filter(m => !revealed.includes(m)); if (unseen.length === 0) { const total = Array.from(dist.values()).reduce((a, b) => a + b, 0); if (total > 0) { for (const [id, value] of Array.from(dist.entries())) { dist.set(id, value / total); } } else { dist.set("", 1); } return dist; } const per = remaining / unseen.length; for (const id of unseen) { dist.set(id, (dist.get(id) || 0) + per); } return dist; ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against the current code and only fix it if needed. In `@sim/tools/strategic-ai/state/OpponentInference.ts` around lines 111 - 124, When unseen.length === 0 the function currently returns dist with total mass revealedShare (<1); instead normalize the distribution to sum to 1 before returning: compute the current total mass from dist (over entries in learnset/revealed), if total > 0 scale every probability in dist by 1/total, and if total === 0 (defensive) assign a uniform distribution over learnset; adjust logic around revealedShare/unseen and use the existing dist, learnset, legalMoves, revealedShare, and unseen identifiers to locate and update the normalization step. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- 4e71b3a2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits 7929f94 to 45f24bd
smell-of-curry (Migrated from github.com) reviewed 2026-04-28 23:02:20 +00:00
@ -0,0 +155,4 @@
const canUltraBurst = !!active.canUltraBurst && !transformsUsed.ultra;
let canZMove = !!active.canZMove;
const canDynamax = !!active.canDynamax && !transformsUsed.dynamax;
const canTerastallize = !!active.canTerastallize && !transformsUsed.tera;
smell-of-curry (Migrated from github.com) commented 2026-04-28 23:02:20 +00:00

Skipping this fix: Showdown maintains the invariant that side.pokemon keeps the active Pokemon at the first N indices. See Battle#swap (sim/battle.ts:1561-1562) which mirrors swaps into side.pokemon. The request data is built directly from side.pokemon (sim/side.ts:355-357), so myPokemon[i] for i < activeSlots.length is the same Pokemon as request.active[i]. The legacy RandomPlayerAI has relied on this invariant in production. Resolving.

Skipping this fix: Showdown maintains the invariant that `side.pokemon` keeps the active Pokemon at the first N indices. See `Battle#swap` (`sim/battle.ts`:1561-1562) which mirrors swaps into `side.pokemon`. The request data is built directly from `side.pokemon` (`sim/side.ts`:355-357), so `myPokemon[i]` for `i < activeSlots.length` is the same Pokemon as `request.active[i]`. The legacy `RandomPlayerAI` has relied on this invariant in production. Resolving.
coderabbitai[bot] (Migrated from github.com) reviewed 2026-04-28 23:03:09 +00:00
@ -0,0 +155,4 @@
const canUltraBurst = !!active.canUltraBurst && !transformsUsed.ultra;
let canZMove = !!active.canZMove;
const canDynamax = !!active.canDynamax && !transformsUsed.dynamax;
const canTerastallize = !!active.canTerastallize && !transformsUsed.tera;
coderabbitai[bot] (Migrated from github.com) commented 2026-04-28 23:03:09 +00:00
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Battle#swap around line 1561 in sim/battle.ts
sed -n '1555,1575p' sim/battle.ts

# Check sim/side.ts around line 355-357
sed -n '350,362p' sim/side.ts

Length of output: 1158


@smell-of-curry, confirmed — the source code backs up your explanation. Battle#swap keeps side.pokemon and side.active in sync (lines 1561–1564 of sim/battle.ts), and the request serializer in sim/side.ts iterates this.pokemon directly in that same order. So myPokemon[i] for i < activeSlots.length is guaranteed to be the same object as request.active[i]. My original concern was unfounded. Thanks for the detailed pointer!


✏️ Learnings added
Learnt from: smell-of-curry
Repo: smell-of-curry/pokebedrock-showdown PR: 10
File: sim/tools/strategic-ai/engines/RandomEngine.ts:149-158
Timestamp: 2026-04-28T23:02:52.579Z
Learning: In Pokémon Showdown's simulator (`sim/battle.ts`), `Battle#swap` always mirrors position changes into both `side.active` and `side.pokemon` (lines 1561-1564), maintaining the invariant that active Pokémon occupy the first N indices of `side.pokemon`. The request serializer in `sim/side.ts` iterates `this.pokemon` directly, so `side.pokemon[i]` for `i < activeSlots.length` is the same object as `request.active[i]`. Code that indexes `myPokemon[i]` inside an `activeSlots.map` callback is correct and does not suffer from a team-order vs active-slot-order mismatch.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

<details> <summary>🧩 Analysis chain</summary> 🏁 Script executed: ``` #!/bin/bash # Check Battle#swap around line 1561 in sim/battle.ts sed -n '1555,1575p' sim/battle.ts # Check sim/side.ts around line 355-357 sed -n '350,362p' sim/side.ts ``` Length of output: 1158 --- </details> `@smell-of-curry`, confirmed — the source code backs up your explanation. `Battle#swap` keeps `side.pokemon` and `side.active` in sync (lines 1561–1564 of `sim/battle.ts`), and the request serializer in `sim/side.ts` iterates `this.pokemon` directly in that same order. So `myPokemon[i]` for `i < activeSlots.length` is guaranteed to be the same object as `request.active[i]`. My original concern was unfounded. Thanks for the detailed pointer! --- <details> <summary>✏️ Learnings added</summary> ``` Learnt from: smell-of-curry Repo: smell-of-curry/pokebedrock-showdown PR: 10 File: sim/tools/strategic-ai/engines/RandomEngine.ts:149-158 Timestamp: 2026-04-28T23:02:52.579Z Learning: In Pokémon Showdown's simulator (`sim/battle.ts`), `Battle#swap` always mirrors position changes into both `side.active` and `side.pokemon` (lines 1561-1564), maintaining the invariant that active Pokémon occupy the first N indices of `side.pokemon`. The request serializer in `sim/side.ts` iterates `this.pokemon` directly, so `side.pokemon[i]` for `i < activeSlots.length` is the same object as `request.active[i]`. Code that indexes `myPokemon[i]` inside an `activeSlots.map` callback is correct and does not suffer from a team-order vs active-slot-order mismatch. ``` > Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings). </details> <!-- This is an auto-generated reply by CodeRabbit -->
github-advanced-security[bot] (Migrated from github.com) reviewed 2026-04-28 23:04:51 +00:00
@ -0,0 +181,4 @@
// Lazy `(.+?)` for the owner (allows apostrophes in names like
// `Farfetch'd`), anchored end + literal ` is disabled` keep the
// match bounded so this doesn't backtrack pathologically.
const m2 = /^(.+?)'s (.+) is disabled$/.exec(message);
github-advanced-security[bot] (Migrated from github.com) commented 2026-04-28 23:04:51 +00:00

CodeQL / Polynomial regular expression used on uncontrolled data

This regular expression that depends on library input may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'.
This regular expression that depends on library input may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'.
This regular expression that depends on library input may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'.

Show more details

## CodeQL / Polynomial regular expression used on uncontrolled data This [regular expression](1) that depends on [library input](2) may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'. This [regular expression](1) that depends on [library input](3) may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'. This [regular expression](1) that depends on [library input](4) may run slow on strings starting with 'a's ' and with many repetitions of 'a's a'. [Show more details](https://github.com/smell-of-curry/pokebedrock-showdown/security/code-scanning/35)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
pokebedrock/pokebedrock-showdown!10
No description provided.