diff --git a/.claude/skills/add-game-content/SKILL.md b/.claude/skills/add-game-content/SKILL.md new file mode 100644 index 00000000..a6562843 --- /dev/null +++ b/.claude/skills/add-game-content/SKILL.md @@ -0,0 +1,94 @@ +--- +name: add-game-content +description: Workflow for adding a new mon, move, ability, or effect to chomp — CSV data, contract patterns, and validation steps. Use when adding/creating a new mon, move, ability, or status/battlefield effect. +--- + +### Adding a New Mon + +1. Add mon stats to `drool/mons.csv` (HP, Attack, Defense, SpAtk, SpDef, Speed, Types) +2. Add 4 moves to `drool/moves.csv` (Name, Mon, Power, Stamina, Accuracy, Priority, Type, Class, etc.) +3. Add ability to `drool/abilities.csv` (Name, Mon, Effect description) +4. Create directory `src/mons//` (lowercase, e.g., `src/mons/aurox/`) +5. Implement 4 move contracts as `PascalCase.sol` files (see "Move Implementation Patterns" below) +6. Implement 1 ability contract as `PascalCase.sol` (see "Ability Patterns" below) +7. Run `python processing/validateMoves.py` to validate contracts match CSV data +8. Run `python processing/generateSolidity.py` to regenerate `SetupMons.s.sol` +9. Add tests in `test/mons/Test.sol` extending `BattleHelper` + +### Move Implementation Patterns + +Choose the simplest pattern that fits the move's behavior: + +**1. Pure `StandardAttack`** — constructor-only, no `move()` override. For straightforward damaging moves or simple effect-applying moves. Pass `EFFECT` + `EFFECT_ACCURACY` for probabilistic status application (e.g., 30% chance to burn): + +```solidity +contract Blow is StandardAttack { + constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) + StandardAttack(msg.sender, _ENGINE, _TYPE_CALCULATOR, ATTACK_PARAMS({ + BASE_POWER: 70, STAMINA_COST: 2, ACCURACY: DEFAULT_ACCURACY, + PRIORITY: DEFAULT_PRIORITY, MOVE_TYPE: Type.Air, + EFFECT_ACCURACY: 0, MOVE_CLASS: MoveClass.Physical, + CRIT_RATE: DEFAULT_CRIT_RATE, VOLATILITY: DEFAULT_VOL, + NAME: "Blow", EFFECT: IEffect(address(0)) + })) + {} +} +``` + +**2. `StandardAttack` + `move()` override** — for moves with side effects after damage (recoil, self-switch, self-status, multi-hit). Override the full `move(...)` signature and call `_move()`, which returns `(int32 damage, bytes32 eventType)`, then add custom logic: + +```solidity +function move( + IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex, + uint256 targetBits, uint256 activesPacked, uint16 extraData, uint256 rng +) public override { + (int32 damage,) = _move(engine, battleKey, attackerPlayerIndex, targetBits, rng); + if (damage > 0) { + engine.dealDamage(attackerPlayerIndex, attackerMonIndex, selfDamage); // recoil + } +} +``` + +A move's target domain is client-facing metadata in `moves.csv`'s `TargetSpec` column (→ TS `Move.targetSpec`), not a Solidity declaration; `validateMoves.py` checks each move's `targetBits` behavior against it. The player's chosen target arrives as `targetBits` (resolved against `activesPacked` via `TargetLib`); the old `targetSpec()` / `extraDataType()` / `ExtraDataType` selector layers have been removed. + +**3. Custom `IMoveSet`** — for complex conditional moves (variable power, healing, stat manipulation, reading opponent state). Implement the `IMoveSet` functions directly (`move` + metadata getters + `getMeta`). Deal damage via `engine.dispatchStandardAttack` / `dispatchCustomAttack` (passing `targetBits`); `AttackCalculator._calculateDamage()` now just delegates to `dispatchCustomAttack`. Store dependencies as `immutable`. + +**4. `IMoveSet` + `BasicEffect` hybrid** — for moves that persist as effects across turns (traps, delayed damage, per-turn modifiers). Implement both interfaces in one contract. The `move()` function calls `ENGINE.addEffect(playerIndex, monIndex, IEffect(address(this)), ...)`. + +**Shared libraries** — when multiple moves in the same mon share logic, extract into a `library` contract in the same directory (e.g., `NineNineNineLib.sol`, `HeatBeaconLib.sol`). + +### Ability Patterns + +Each mon has exactly one ability, implemented in its own `PascalCase.sol` file within the mon directory. + +**Pure `IAbility`** — for one-time switch-in actions (deal damage, apply a stat boost). Implement `name()` and `activateOnSwitch()`. No persistent state. (e.g., `PreemptiveShock`, `SaviorComplex`) + +**`IAbility` + `BasicEffect`** (most common) — for abilities with ongoing lifecycle effects. `activateOnSwitch()` registers `address(this)` as an effect on the mon, then hooks into effect lifecycle via `getStepsBitmap()`. Must override `name()` with `override(IAbility, BasicEffect)`. Uses an idempotency guard to prevent duplicate registration: + +```solidity +function activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external { + (EffectInstance[] memory effects,) = ENGINE.getEffects(battleKey, playerIndex, monIndex); + for (uint256 i; i < effects.length; i++) { + if (address(effects[i].effect) == address(this)) return; + } + ENGINE.addEffect(playerIndex, monIndex, IEffect(address(this)), bytes32(0)); +} +``` + +For once-per-battle abilities (e.g., `RiseFromTheGrave`), use a `globalKV` flag instead of the effect-list check. + +### Adding a New Effect + +Effects fall into several categories depending on scope: + +- **Status effects** (`src/effects/status/`): Extend the `StatusEffect` marker base. One-status-per-mon is **Engine-native**: each status folds a unique class id into `getStepsBitmap()` bits 10-13 (an internal `STATUS_CLASS` constant; deployable ids 1-14, 15 reserved for test mocks), and the Engine tracks it in a per-mon lane (`BattleConfig.monStatusLanes`, slot 3) — gating adds before any external call, setting the lane on apply, clearing it on removal. Readers use `engine.getMonStatusClass` (identity/existence) and `engine.clearMonStatus(player, mon, expectedClass)` (remove; 0 = any class); reader contracts cache the class id as a constructor `immutable` derived from the injected status's `getStepsBitmap()`. Escalating statuses (Burn) set `HAS_REAPPLY_BIT` (bit 14) and implement `IStatusEffect.onReapply`, which the Engine calls on a same-class re-apply; without the bit a re-apply is a zero-call no-op. Extra apply conditions beyond exclusivity (Sleep's single-sleeper rule) live in a `shouldApply` override (no `ALWAYS_APPLIES_BIT`). Shared across mons — deployed once, injected into moves via constructor parameters. (e.g., `BurnStatus`, `FrostbiteStatus`, `SleepStatus`) +- **Battlefield effects** (`src/effects/battlefield/`): Extend `BasicEffect`, use `targetIndex=2` for global scope. (e.g., `Overclock`) +- **Shared utility effects** (`src/effects/`): Deployed once, used by many contracts. (e.g., `StaminaRegen` for per-turn recovery). NOTE: stat modifiers are **not** an effect — they are inlined Engine functions (`addStatBoost`/`removeStatBoost`/…); see "Stat Boosts" in the root CLAUDE.md. +- **Mon-local effects** (`src/mons//`): Abilities or move-effect hybrids that only apply to one mon. These live in the mon's directory, not in `src/effects/`. + +To implement a new effect: +1. Extend `BasicEffect` (or `StatusEffect` for status conditions) +2. Override the relevant lifecycle hooks (`onRoundEnd`, `onAfterDamage`, `onPreDamage`, …) — each receives `activesPacked` for local slot resolution via `TargetLib` +3. Return a bitmap from `getStepsBitmap()` indicating which hooks to call. Before adding callback getters, opt into a supported fresh context by mirroring the applicable low lifecycle bit into bits 16-31 as described in the root CLAUDE.md. If the effect doesn't override `shouldApply()` (i.e. it relies on `BasicEffect`'s default, which always returns `true`), OR the low bitmap with `ALWAYS_APPLIES_BIT` (`src/Constants.sol`) so the Engine skips the external `shouldApply()` call on `addEffect`. Effects that override `shouldApply()` with real gating logic (e.g. `SleepStatus`'s single-sleeper rule) must NOT set this bit. Statuses additionally carry their class id in bits 10-13 and `HAS_REAPPLY_BIT` (bit 14) iff they implement `onReapply`. `python processing/validateEffectBitmaps.py` checks all of these invariants (bit pairing, class range 1-14, cross-src uniqueness) and is run as part of `deploy.py`. +4. Return `(updatedExtraData, removeAfterRun)` from hooks — use `extraData` (bytes32) to carry state between turns (counters, degrees, flags) +5. Shared effects are injected into moves/abilities via constructor parameters at deploy time — there is no runtime effect registry diff --git a/.claude/skills/deploy-chomp/SKILL.md b/.claude/skills/deploy-chomp/SKILL.md new file mode 100644 index 00000000..896eb48b --- /dev/null +++ b/.claude/skills/deploy-chomp/SKILL.md @@ -0,0 +1,73 @@ +--- +name: deploy-chomp +description: Build and deploy pipeline for chomp — processing scripts, the Solidity-to-TS/Rust transpiler, deployment order, and CI/CD reference. Use when running codegen/validation scripts, transpiling, or deploying to testnet/mainnet. +--- + +### Processing Scripts (Python 3.11+) + +```bash +python processing/validateMoves.py # Validate contracts vs CSV +python processing/validateEffectBitmaps.py # Validate ALWAYS_APPLIES_BIT usage on IEffect contracts +python processing/generateSolidity.py # Generate SetupMons.s.sol +python processing/generateSetupCPU.py # Generate SetupCPU.s.sol from cpu-teams.json +python processing/deploy.py --testnet # Full deployment (forge scripts + codegen) +python processing/deploy.py --mainnet # Production deployment +``` + +Python dependencies: `numpy`, `pexpect`, `pillow` (managed via `uv`, see `pyproject.toml`) + +### Transpiler (Solidity to TypeScript / Rust) + +Converts Solidity contracts to TypeScript for local battle simulation: + +```bash +# Transpile all contracts +python3 -m transpiler src/ -o transpiler/ts-output -d src --emit-metadata + +# Run transpiler unit tests (the TS runtime/integration tests moved to munch with the codegen sync) +python3 transpiler/test_transpiler.py +``` + +**Rust target (sim performance).** The same front-end also drives a Rust +backend (`codegen_rs/`, native ints instead of bigint — see +`transpiler/codegen_rs/README.md`): + +```bash +python3 -m transpiler src/ --target rust # emit transpiler/rs-output (cargo workspace) +cd transpiler/rs-output && cargo build --release # engine + runtime + strategies (+ its bins) +CHOMP_ROOT=$(pwd)/../.. ./target/release/arena --games 6000 # whole-game batches, win-rate table +CHOMP_ROOT=$(pwd)/../.. ./target/release/trace --games 6000 --narrate 0 # trace analysis + greedy counterfactual +``` + +The full engine, all mons/effects, and the three CPU strategies (hard, +greedy, override) are transpiled/ported. The arena is now a **standalone, +pure-Rust binary** (`chomp-strategies`' `arena`/`trace` bins): it loads the +roster from `drool/*.csv` + `src/mons/*.json` at runtime and runs whole games +natively (~2,000 games/s at 8 threads, deterministic across thread counts). +The stacks are DECOUPLED — the Rust side was ported move-for-move against TS +and then cut loose; it may diverge freely (including its rng stream and CPU +decisions) as the prototyping substrate, and port-backs to the TS game carry +no bit-identicality requirement. Emission is allowlisted in +`transpiler-config-rust.json`. `rs-output/` is regenerated (gitignored); +hand-written crates live in `transpiler/{runtime-rs,strategies-rs}`. The +former bun↔Rust FFI seam (`chomp_run_games`, the `ffi` crate, and +`scripts/batch_benchmark.ts`) was removed once the pure-Rust arena replaced +it; the verification-era machinery (golden-vector suites, replay fixtures, +the drive-mode adapter, lockstep gates) lives in git history if parity ever +needs re-proving. The TS arena was deleted once the pure-Rust one replaced +it; strategies that ship are hand-ported into munch and gated there against +`sim-tests/arena/cpu-reference.json`. + +**Known limitation — TODO when a second codebase needs it.** The transpiler hardcodes three TS namespace names (`Enums`, `Structs`, `Constants`) for type-only Solidity files. File-type detection is content-based (a file with only enums maps to the `Enums` namespace regardless of filename), but the *namespace name itself* is fixed, and only structs are tracked per source path. A codebase that splits types across multiple files (e.g. `PoolStructs.sol` + `OrderStructs.sol`, or `Errors.sol`) would produce colliding imports. To generalize: add `enum_paths` / `constant_paths` to `transpiler/type_system/registry.py` mirroring `struct_paths`, derive the namespace name from each source file's basename, and have `imports.py:_generate_module_imports` emit one `import * as ` per actually-referenced source module instead of three blanket imports. + +### Deployment Order + +1. `EngineAndPeriphery.s.sol` - Engine, type calculator, `SignedCommitManager`, `GachaTeamRegistry`, `SignedMatchmaker`, CPU host, `SimplePM`, shared effects +2. `SetupMons.s.sol` - All mon contracts (moves, abilities) +3. `SetupCPU.s.sol` - CPU players + +### CI/CD + +GitHub Actions runs on pull requests (`.github/workflows/main.yml`): +- `forge build` +- `forge test -vvv` diff --git a/.gitignore b/.gitignore index bf75d77c..fad6d74d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Compiler files cache/ out/ +cache-fast/ +out-fast/ # Ignores development broadcast logs !/broadcast @@ -54,7 +56,12 @@ transpiler/ts-output/ .vscode/ .pytest_cache/ .venv/ -.claude/ + +# Ignore everything in .claude except shared skills +.claude/* +!.claude/skills +!.claude/skills/** + node_modules/ .deploys/ diff --git a/BENCH.md b/BENCH.md new file mode 100644 index 00000000..c71a9e3e --- /dev/null +++ b/BENCH.md @@ -0,0 +1,165 @@ +# chomp bot benchmark + +Write a bot that plays Stomp. + +Score it against a ladder of built-in opponents! + +## Quick start + +```bash +./bench.sh setup # generate the engine + build (~35s on 8 cores) +./bench.sh run --bot greedy # score a built-in bot +./bench.sh run --bot example # the worked example you'll copy +``` + +Requires `python3` (3.11+) and a Rust toolchain. The engine is generated from the Solidity +source rather than committed, which is why there's a build step. + +## Writing a bot + +Copy [`transpiler/strategies-rs/src/example_bot.rs`](transpiler/strategies-rs/src/example_bot.rs). +It is a complete, registered, working bot in about 60 lines — one-ply search over the +forward model — and it beats `random` 96% and `heuristic` 54%. + +The interface is one method: + +```rust +pub trait Bot: Send { + fn name(&self) -> &'static str; + fn modes(&self) -> &'static [BattleMode] { &[BattleMode::Singles] } + fn reset(&mut self) {} + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action; +} +``` + +`ctx` gives you: + +| field | what it is | +|---|---| +| `ctx.sim` | the live battle — fork it and play hypotheticals forward | +| `ctx.seat` | which side you're piloting | +| `ctx.mode` | `Singles` or `Doubles` | +| `ctx.view` | position snapshot (singles only; `None` in doubles — read `ctx.sim`) | +| `ctx.peek` | the opponent's move, or `None` in blind mode | +| `ctx.rng` | your private random stream | + +To register: add `pub mod my_bot;` to `src/lib.rs`, then a name constant and one `build` arm +in `src/bots.rs`. Then `./bench.sh run --bot my-bot`. + +`./bench.sh test` runs a smoke test that plays one game with every registered bot. If your +bot is in the registry, it is covered. + +### Guidelines + +**Draw only from `ctx.rng`.** Salts are drawn from a +separate one derived from the seed alone, so however much you draw, the battle itself is +unchanged. + +**Read the opponent's move only through `ctx.peek`.** In blind mode it is `None`, so a bot +written against the interface cannot accidentally depend on seeing a reveal. + +Beyond that, do what you like. Search deep, cache, hand-tune tables, hard-code matchups. + +Every game is an **independent 4v4 draft**, redrawn from the mon roster. + +## Existing Ladder + + Your row from `./bench.sh run --bot yours` is directly comparable + +**Singles**, blind mode, seed `0xbeefcafe`. Intervals ±0.5% to ±3.0%. + +| bot | drafts | vs `random` | vs `greedy` | vs `heuristic` | vs `override` | vs `nopeek` | vs `search-d1` | +|---|---|---|---|---|---|---|---| +| `random` | 4000 | 50.7% *(self)* | 4.5% | 5.9% | 4.3% | 3.2% | 5.5% | +| `example` | 4000 | 95.4% | 46.7% | 55.3% | 50.6% | 44.9% | 37.5% | +| `greedy` | 4000 | 96.1% | 50.2% *(self)* | 57.3% | 54.4% | 45.9% | 39.3% | +| `heuristic` | 4000 | 94.1% | 40.4% | 49.4% *(self)* | 44.5% | 39.2% | 34.0% | +| `override` | 4000 | 96.2% | 44.5% | 55.0% | 50.8% *(self)* | 41.4% | 37.9% | +| `nopeek` | 4000 | 96.6% | 53.8% | 59.6% | 56.3% | 50.3% *(self)* | 33.1% | +| `nopeek-wc` | 4000 | 95.6% | 63.7% | 67.4% | 62.8% | 67.7% | 51.9% | +| `search-d1` | 4000 | 94.6% | 61.3% | 66.4% | 61.2% | 66.5% | 50.1% *(self)* | +| `search-d2` | 1000 | 97.0% | 72.2% | 73.8% | 71.1% | 74.6% | 62.3% | + +**Doubles**, seed `0xbeefcafe`. Intervals ±0.8% to ±6.8%. + +| bot | drafts | vs `doubles-easy` | vs `doubles-medium` | vs `doubles-hard` | vs `doubles-search-d1` | +|---|---|---|---|---|---| +| `doubles-easy` | 4000 | 49.9% *(self)* | 34.1% | 17.3% | 7.8% | +| `doubles-medium` | 4000 | 66.7% | 49.7% *(self)* | 30.6% | 15.6% | +| `doubles-hard` | 4000 | 83.5% | 69.5% | 49.3% *(self)* | 27.6% | +| `doubles-search-d1` | 2000 | 93.3% | 84.6% | 69.9% | 50.2% *(self)* | +| `doubles-search-d2` | 200 | 93.0% | 84.5% | 76.0% | 61.0% | + +Check the `drafts` column before comparing rows — `search-d2` costs ~25× `greedy` per +decision, so it gets fewer, and `doubles-search-d2` (200 drafts, ±6.8%) is indicative only. + +### Information modes + +- `--info blind` (default) — genuinely simultaneous. Neither side sees the other's move. + This is what production play looks like. +- `--info rotate` — one side sees the opponent's move, alternating each game so both seats + peek equally often. This is for beating up human players :> + +### Doubles + +```bash +./bench.sh run --bot doubles-hard --mode doubles +``` + +Two active mons per side; your bot returns `Action::Slots([move, move])` and declares +`modes()` as `Doubles`. Doubles is the mode the shipped CPU actually plays, so improvements +there transfer most directly. + +## Research directions + +Some ideas for you to try out: + +### Archetype pilots + +The bots above all evaluate positions the same way and differ only in search. None of them +plays a **strategy** in the way a human does. + +- **Stall.** Stamina is a real resource and resting is a legal action — win by exhausting + the opponent rather than out-damaging them. The search only learned to rest at all after a + bug fix, and it gained ~12 points when it did, so this axis is under-explored. +- **Setup sweep.** Stat boosts are multiplicative per source. Find the turn where a boost + pays for the tempo it costs, then commit. + +`override_cpu.rs` is the substrate — it already runs scripted per-mon rules (preferred move, +switch-in move, setup move) with a heuristic fallback. + +### Synergy-aware play + +`./bench.sh` aside, the tree has an exhaustive team analyser: all C(13,4) = 715 teams +evaluated against the random-draft field, with synergy read as the *interaction* beyond each +mon's main effect (`src/bin/teams.rs`). Overclock lifting slower attackers shows up there. + +No bot currently reads its own roster and plays to it. A bot that recognises it drafted a +speed-control core, or a specific two-mon combo, and sequences toward it, is open ground. + +### Wall / check exploitation + +`src/bin/matrix.rs` computes the static damage-to-KO matrix — who *checks* whom (2HKO and +out-damages) and who *walls* whom (needs 3+ and out-damaged). `src/bin/faceoff.rs` finds the +behavioural version, including walls that never show up as KOs at all: sleep-and-drain and +stamina denial. + +Both are offline analyses. A bot that consults that structure mid-game — pivoting to its +check instead of clicking damage — doesn't exist yet. + +### Bots humans enjoy losing to + +Strength and fun are different targets, and the current difficulty tiers conflate them: easy +and medium are just `hard` taking a random legal action some of the time, so they feel +*erratic* rather than *beatable*. A weak bot that plays a coherent but narrow plan is far +more satisfying to beat than one that flails. + +- **Unexploitable rather than strong.** Nash mixing over the per-turn matrix game already + exists (`--mixed`, regret matching). Measured against fixed bots it *loses* to plain + maximin — but that is the point: it cannot be pattern-read, which matters against a human + who plays fifty games and not one. +- **Personality bots.** A pilot that always leads the same mon, always sets up first, or + always preserves its last mon. Predictable in a way a human can discover and counter, + which is a better difficulty knob than injecting randomness. +- **Yomi-aware.** `src/bin/yomi.rs` scores how much a position's outcome hinges on guessing + right. Bluff where it matters, play straight where it doesn't. diff --git a/CLAUDE.md b/CLAUDE.md index 6367e134..fdaaf82d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,10 +20,22 @@ ```bash forge install # Install dependencies (forge-std) forge build # Compile contracts -forge test # Run all tests -forge test -vvv # Run tests with verbose output +FOUNDRY_PROFILE=fast forge test # Run all tests +FOUNDRY_PROFILE=fast forge test -vvv # Run tests with verbose output ``` +Engine edits invalidate most of the production-profile dependency graph. For correctness-only +iteration, use the separate fast profile and a narrow path; it keeps via-IR (required by Engine) +but disables optimization/build-info, enables sparse compilation, and uses a separate cache: + +```bash +FOUNDRY_PROFILE=fast forge test --match-path test/EngineTest.sol +``` + +Always use `profile.fast` for correctness, regression, and full-suite test gates. Use the default +profile only for explicit gas comparisons and snapshots; the fast profile disables snapshot +emission so an accidental gas-test run cannot overwrite production-profile numbers. + ## Repository Structure ``` @@ -52,7 +64,7 @@ chomp/ │ │ ├── IEffect.sol # Effect interface with lifecycle hooks │ │ ├── BasicEffect.sol │ │ ├── StaminaRegen.sol -│ │ ├── status/ # Status effects (Blessed, Burn, Frostbite, Panic, Sleep, Zap) + StatusEffect(Lib) +│ │ ├── status/ # Status effects (Blessed, Burn, Frostbite, Panic, Sleep, Zap) + StatusEffect marker base + IStatusEffect (onReapply) │ │ ├── battlefield/ # Battlefield effects (Overclock) │ ├── game-layer/ # Team / mon registry, gacha, exp, facets, quests, gifts │ │ ├── GachaTeamRegistry.sol # Concrete leaf: composes the abstracts below @@ -110,6 +122,7 @@ chomp/ │ ├── generateSolidity.py # Generate SetupMons.s.sol from CSV data │ ├── generateSetupCPU.py # Generate SetupCPU.s.sol team config from cpu-teams.json │ ├── validateMoves.py # Validate move contracts match CSV data +│ ├── validateEffectBitmaps.py # Validate IEffect contracts set ALWAYS_APPLIES_BIT correctly │ ├── deploy.py # Full deployment pipeline orchestrator │ ├── deploy_addresses.py # Inline ability/move address packing for the deploy pipeline │ ├── buildTypeChart.py # Build type effectiveness chart @@ -123,15 +136,18 @@ chomp/ │ ├── packMoves.py # Pack move data for distribution │ ├── inputToEnv.py # Parse forge output to .env │ └── removeUnusedImports.py # Clean up unused Solidity imports -├── transpiler/ # Solidity-to-TypeScript/Rust transpiler (Python) -│ ├── sol2ts.py # Main entry point +├── transpiler/ # Solidity-to-TypeScript/Rust transpiler (Python; run via `python3 -m transpiler`) +│ ├── sol2ts.py # TypeScript backend driver (not directly runnable — use `-m transpiler`) +│ ├── sol2rs.py # Rust backend driver (`--target rust`) │ ├── lexer/ # Tokenizer │ ├── parser/ # AST construction │ ├── type_system/ # Type registry │ ├── codegen/ # TypeScript code generation +│ ├── codegen_rs/ # Rust code generation │ ├── runtime/ # TypeScript runtime library +│ ├── runtime-rs/, strategies-rs/ # Hand-written Rust crates (rsynced into rs-output/) │ ├── dependency_resolver/ # Dependency resolution -│ └── test/ # TypeScript integration tests (vitest) +│ └── test_transpiler.py # Transpiler unit tests (the TS integration tests live in munch) ├── drool/ # Game data (CSV) and frontend assets │ ├── mons.csv # Mon stats (HP, attack, speed, types, etc.) │ ├── moves.csv # Move definitions (power, stamina, accuracy, etc.) @@ -289,6 +305,48 @@ into the Engine. Effects can be per-mon (local; `targetIndex` = the owning player index) or global (battlefield-wide; side index `2`). The `StaminaRegen` effect is a global default that regenerates 1 stamina per turn. +#### Opt-in fresh hook context (gas path) + +Before adding an Engine getter inside a lifecycle hook, check whether the value is already available +in that hook's packed context. `getStepsBitmap()` returns `uint32`: the low 16 bits are lifecycle +subscriptions and the corresponding high 16 bits request fresh context. For example, an +`AfterMove` listener (`0x80`) requests context with `0x0080_0000`, so a typical bitmap is +`0x0080_8080` (`AfterMove` context + `ALWAYS_APPLIES_BIT` + `AfterMove`). Legacy effects that only +return low bits remain compatible. + +Supported layouts, decoded through `TargetLib`: + +- `OnApply`: the target mon's max HP via `hookMonMaxHp(context)`. This request is consumed before + installation and is not persisted as a recurring context capability. +- `AfterMove`: current 24-bit move words via `hookMoveWordAt(context, absSlot)` and acted bits via + `hookSlotActed(context, absSlot)`. +- `AfterDamage`: the damaged mon's finalized KO state via `hookMonIsKnockedOut(context)`. +- `PreDamage`: current signed in-flight damage via `hookPreDamage(context)`. +- `RoundEnd`: current status classes via `hookStatusClass(context, side, monIndex)`. +- `OnUpdateMonState`: the changed mon's max HP and normalized signed HP delta via + `hookMonMaxHp(context)` / `hookMonHpDelta(context)`. + +Snapshots are built immediately before each requesting callback, after earlier effects in the same +pass, so hooks must not cache them across calls. Context replaces read-only callbacks; mutations +still go through Engine functions. Prefer it when a hook would otherwise call an Engine getter, and +add a mutation-order regression when an earlier hook can change the observed value. + +The stored effect header currently collapses any nonzero high-half request to one capability bit. +Consequently, a multi-hook effect receives every context supported for each hook it subscribes to, +even if it requested only one step. Benchmark multi-hook migrations: an unused builder at a frequent +hook can outweigh the removed callback. Ordinary `IMoveSet.move()` execution does not yet receive +these lifecycle layouts; only moves/abilities that also register as effects can use them in their +effect hooks. Ordinary tagged deployed moves may separately opt into supported move context through +metadata capabilities: `@move-context: status-lanes` makes the deployment generator set +`MOVE_CONTEXT_STATUS_LANES`, and `move()` can then use `TargetLib.hookStatusClass`. Keep these +capabilities generic; do not add address-specific Engine dispatch. + +For ordinary moves/effects outside a supported packed hook context, use the narrowest typed bundled +getter matching the data shape. In particular, overheal clamps should call +`getMonHpState(battleKey, player, mon)` for `(maxHp, hpDelta)` rather than making separate +`getMonValueForBattle(Hp)` and `getMonStateForBattle(Hp)` external calls. Do not add raw-slot or +large generic snapshot APIs; add a new bundle only when multiple mechanics share the exact shape. + ### Stat Boosts (inlined into the Engine) Stat modifiers are **not** a separate effect contract — they are native Engine functions: @@ -300,11 +358,13 @@ The packing/aggregation math lives in `src/lib/StatBoostLib.sol`. (Historically round-trips.) How it works: -- Each boost **source** is one packed entry stored in the mon's normal effect mapping under the - `STAT_BOOST_ADDRESS` sentinel (steps bitmap `STAT_BOOST_STEPS` = `OnMonSwitchOut | ALWAYS_APPLIES`). +- Each boost **source** is one packed word in a dedicated per-mon boost store + (`p0BoostWords`/`p1BoostWords`; 4-bit counts in `p0/p1BoostCounts`, aggregation cache in + `statBoostAcc`) — NOT an effect-list entry, so effect passes never iterate boost sources. Sources are keyed by `msg.sender`, so each move/ability/effect stacks independently and can remove its own boost. Boosts are **multiplicative** per source; `Temp` - boosts are dropped automatically on switch-out (`_inlineStatBoostSwitchOut`), `Perm` ones persist. + boosts are dropped on switch-out by a direct `_inlineStatBoostSwitchOut` call in `_handleSwitch`, + `Perm` ones persist. - Boosts apply only to the 5 stat deltas: `Speed`, `Attack`, `Defense`, `SpecialAttack`, `SpecialDefense`. There is **no globalKV snapshot** — the Engine telescopes off the live `monState` delta (`new boosted − base − currentDelta`) and fires `OnUpdateMonState` like any other delta write. @@ -319,82 +379,13 @@ How it works: ### Gacha & Progression System -`GachaTeamRegistry` is the concrete leaf — it's an `IEngineHook` (subscribes to `OnBattleEnd`) and composes seven abstract bases that each own one slice of state and behavior: - -| Abstract | Owns | -|---|---| -| `MonOwnership` | `monsOwned` per-player set; ownership view + bulk-check helpers (`_isOwnerBatch`, `_validateOwnership`). Satisfies the `_isFacetMonOwned` hook on `Facets`. | -| `MonRegistry` | Owner-managed mon catalog (`monStats`, `monMoves`, `monAbilities`, `monMetadata`, sequential `monIds` set). Inherits `ITeamRegistry` so its `createMon`/`modifyMon`/batched-getters bind directly. Backs the `_getMonStatsForFacets` hook used by facet delta computation. | -| `PlayerProfile` | Packed `playerData[address]` (one uint256 per player) + the owner-managed `isAssigner` allowlist. Exposes `pointsBalance`, `isWhitelistedOpponent`, the bulk admin flag setters, and `assignPoints` (IGachaPointsAssigner). | -| `PackedTeamStore` | Bit-packed team CRUD: `teamGroupsPacked` (4 teams × 64 bits per slot), `teamOrderPacked` (16-slot live bitmap + display order). Constructor takes `MONS_PER_TEAM` / `MOVES_PER_MON` immutables. Subclass hooks: `_packedTeamValidateOwnership`, `_packedTeamIsCpuOpponent`, `_packedTeamGetMonData`. | -| `Facets` | 12-facet ±5% stat tradeoff system (see below). Pure helpers, packed per-mon facet data, `assignFacets`. | -| `MonExp` | Packed per-mon exp (`packedExpForMon`), level curve, level-up facet draws (`_processLevelUps`), public `getExp` / `getLevel` / `getExpAndLevelsFor*`, assigner-gated `assignExp`. Inherits `Facets` + `PackedTeamStore` because level-ups draw facets and team views need the lane helpers. Subclass hooks: `_assertExpAssigner`, `_monRegistrySize`. | -| `Quests` | Daily quest pool + packed predicate evaluator. Subclass hook: `_extract` (opcode dispatch implemented by the leaf since it sees Engine + all sub-systems). | - -The leaf adds: the `onBattleEnd` orchestration (streak / quest / points / exp loop, plus a `_applyExpAndFacetDraws` walk that mirrors `assignExp` but with KO bitmap + streak + event packing), gacha rolling (`firstRoll`/`roll`/`_rollInto`), the per-(user, opponent) CPU phantom facet config, the `getTeams` variant that folds facet deltas in, the `_extract` quest opcode dispatch, and the wiring overrides for every subclass hook. With this split the leaf is ~750 LOC of integration and event-shape concerns; each base is independently auditable. - -**Rolling.** Mon ids are sequential starting at 0 (`createMon` enforces `monId == monIds.length()`). Ids `[0, NUM_STARTERS)` (= 3) are *starter* mons. - -- `firstRoll(uint256 starterId)` — one-shot per player. Caller picks `starterId ∈ {0,1,2}`; the contract guarantees that mon at slot 0 of the result and rolls `INITIAL_ROLLS - 1` (= 3) more uniformly from `[NUM_STARTERS, numMons)`. Free. -- `roll(uint256 numRolls)` — paid (`ROLL_COST` per roll, default 16 points). Uniform across the entire pool. Reverts `NoMoreStock` once the caller owns every mon. -- Linear-probing dedup keeps draws inside their window so `firstRoll`'s 3 random picks never land on a starter. - -**Points / exp / facets storage.** All packed for gas: - -``` -playerData[address] (1 slot per player): - bit 255 bonusAwarded (first-game-ever bonus claimed) - bit 254 isWhitelistedAsOpponent (admin-set; replaces a separate mapping) - bit 253 (reserved; formerly isHardCpu) - bits 250-252 streakDay (1..STREAK_FLAT_BONUS_MAX; 0 = no streak yet) - bits 224-249 (reserved) - bits 192-223 lastQuestCompletedDay (uint32 calendar day) - bits 160-191 lastSeenTimestamp (uint32 seconds; last battle of ANY kind — drives streak grace/reset) - bits 128-159 lastFirstGameTimestamp (uint32 seconds; last streak-bonus game — gates the 24h cooldown) - bits 0-127 pointsBalance (uint128) - -packedExpForMon[player][monId / 16]: 16 mons × 16 bits each, capped at 65535. -facetData[player][monId / 16]: 16 mons × 16 bits each - (bits 0-11 unlockedBitmap, bits 12-15 assignedFacetId). -``` - -Streak is timestamp-driven (not calendar-day): a battle qualifies for the streak bonus -when ≥24h have passed since `lastFirstGameTimestamp` (the last *bonus-earning* game). On a -qualifying battle the ratchet-vs-reset decision is measured from `lastSeenTimestamp` (the -last battle of *any* kind, advanced every battle): a gap >36h (`STREAK_GRACE_WINDOW`) of -genuine inactivity resets `streakDay` to 1, otherwise it ratchets up toward the cap of -`STREAK_FLAT_BONUS_MAX` (= 5). Splitting the two anchors is deliberate — measuring the -reset from the bonus anchor instead would strand players who play slightly more often than -once per 24h (their sub-24h plays advance no anchor, so the next day reads a phantom ~46h -gap and resets the streak forever). - -Both per-mon mappings share the same 16-mon bucketing so `_applyExpAndFacetDraws` walks the team in one pass and coalesces SSTOREs by bucket. - -**Battle rewards (`onBattleEnd`).** CPU side is short-circuited (no SSTOREs, no event). For each human side: - -- Base points: `POINTS_PER_WIN` (2) on win, `POINTS_PER_LOSS` (1) otherwise. -- Streak flat: `streakDay` (1..5) added inside the parenthetical of both the points and per-mon-exp formulas. Only granted when the battle qualifies as "first of day" (≥24h since the last grant). -- Points formula: `(basePts + streakFlat) × gachaMult + firstGameEverBonus`. `gachaMult` is `×QUEST_REWARD_MULT` (= 2) when the active daily quest completes (winner-only, one-shot per day), else 1. `firstGameEverBonus` is `+FIRST_GAME_EVER_BONUS` (= 16), one-shot ever, applied *after* the multiplier. -- Per-mon exp formula: `(baseExp + streakFlat) × expMult`, capped at 65535. `baseExp` is `EXP_PER_SURVIVING_MON` (2) for alive slots, `EXP_PER_KOD_MON` (1) for KO'd slots. -- `expMult` stack: a flat `GAME_EXP_MULT` (×2) on **every** battle regardless of game type (PvP, CPU, hard or not), times `QUEST_REWARD_MULT` (×2) on quest completion. Max stack = ×4. (There is no longer a PvP-vs-CPU or hard-CPU exp distinction — the old client-set "hard CPU" flag let any user self-grant the bonus, so it was removed.) -- Level-ups (12-tier curve, capped at level 12 to match `TOTAL_FACETS`) trigger one facet draw per level crossed. - -**Facets.** 12 systematically-derived stat tradeoffs across 4 stat groups (`HP`, `Atk`, `Def`, `Speed`). `_facetDef(facetId)` is pure — no constant table. Magnitudes are **boost-indexed**: the boost stat determines both the boost% and the cost% paid on the nerfed stat. HP/Atk/Def boosts are symmetric (+5% / -5%); speed boosts pay a heavier cost (+5% / -10%) so they can't cheaply break speed ties. The percentages live as `BOOST_PCT_*` / `COST_PCT_*` constants in `Facets.sol`. Unlocks are persistent per-mon; `assignFacets(monIds, facetIds)` is a free bulk re-assign that requires the caller to own every listed mon and the facet to be in the unlocked bitmap (`facetId == 0` clears). `GachaTeamRegistry.getTeams()` folds the active facet's delta into each mon's stats before returning, so by the time the Engine stores teams in `BattleConfig` they already reflect the boost/cost. `validateMon` no longer checks stat equality (the round-trip would always fail with facets applied) — moves and ability membership are still enforced. - -**CPU opponent facets.** When fighting a whitelisted opponent (CPU), the human caller picks the CPU's team *and* its facet config in one call: `setOpponentTeam(opponent, monIndices, facetIds)`. Per-user-per-CPU storage (`opponentTeamFacetsPacked[opponent][phantomKey]`) keyed by the same `uint16(uint160(msg.sender))` phantom slot as the team. No ownership/unlock checks — any facet 0..12 is allowed. `getTeamsWithDeltas` short-circuits to this slot-indexed config when a side is `isWhitelistedOpponent`, so per-user CPU facet configurations stay isolated even when many users fight the same CPU. - -**Quests.** Owner-managed `questPool`. One quest is active per UTC day, derived statelessly as `keccak256(day) % poolLength` (day = `block.timestamp / 1 days` + an owner-settable `_dayOffset`) — no rotation SSTORE and no race between concurrent battles. Each quest has up to `MAX_PREDICATES_PER_QUEST` (6) AND-composed predicates packed into one storage slot (41 bits each: `op` 5b, `cmp` 3b, `negate` 1b, `arg` 16b, `operand` 16b — total 246b + 3b count). Opcodes cover battle context (`TURNS`, `ALIVE_COUNT`, `ACTIVE_SLOT_INDEX`, `MON_KO_AT_SLOT`), team composition (`HAS_MON_ID`), per-mon progression (`MON_LEVEL`, `MON_FACET`), and live battle state (`MON_STATE` via `Engine.getMonStateForBattle`). - -**Events.** - -- `Roll(address indexed player, uint256[] monIds, uint256 pointsSpent)` — fires on both `firstRoll` (spend = 0) and paid `roll`. -- `GachaEvent(bytes32 indexed battleKey, uint256 p0Packed, uint256 p1Packed)` — one per battle, carrying both sides' packed payloads (CPU side is 0). Layout sized for `MONS_PER_TEAM` up to 8: points (bits 0-15), per-mon exp gain (bits 16-79, 8 lanes × 8b), per-mon facets unlocked this battle (bits 80-175, 8 lanes × 12-bit bitmap = 1 bit per facet id), `BONUS_*` flags (bits 176-183: `FIRST_ROLL` (bit 0) | `FIRST_GAME` (bit 1) | bit 2 reserved/formerly `HARD_CPU` | `QUEST` (bit 3)), combined exp multiplier (bits 184-191), outcome (bits 192-199: 0=loss, 1=win, 2=draw), `streakDay` (bits 200-202). Lanes saturate so a future tuning blow-up can't bleed into neighbouring fields. +`GachaTeamRegistry` (points, exp, facets, quests, rolling) is documented in `src/game-layer/CLAUDE.md`, which loads automatically when working under that directory. ### Storage Architecture - `BattleData` and `BattleConfig` are stored per battle key (derived from player addresses) - `MonState` tracks deltas from base stats (hpDelta, staminaDelta, etc.). The 5 stat deltas are written only by the inlined stat-boost path (see "Stat Boosts"); other deltas via `updateMonState`. -- Effects stored in per-mon mappings with stride-based indexing (64 slots per mon). Stat-boost sources reuse these same mappings under the `STAT_BOOST_ADDRESS` sentinel. +- Effects stored in per-mon mappings with stride-based indexing (64 slots per mon). Stat-boost sources live in dedicated per-mon boost-word mappings (`p0/p1BoostWords` + packed counts), not the effect mappings. - Heavy use of bit packing for gas efficiency (KO bitmaps, effect counts, active mon indices) - Battle mode is a `uint8` in `BattleConfig.battleMode` (mirrored to `BattleData.isTwoSlotMode`/`isMultiMode`). Slot-0 active lanes pack into `BattleData.activeMonIndex` (side 0 = low byte, side 1 = high byte); 2-slot modes add slot-1 lanes in `activeMonExt`. Multi's extra seats (p2/p3) live in `multiSeats[battleKey]` (`MultiSeatData`), written only when `battleMode == MULTI`. - Transient storage used for per-transaction state (`battleKeyForWrite`, `tempRNG`, in-flight `PreDamage`) @@ -441,164 +432,13 @@ When implementing new features or refactors, follow a test-first approach: 3. Implement the changes 4. Run to verify the tests pass -### Adding a New Mon - -1. Add mon stats to `drool/mons.csv` (HP, Attack, Defense, SpAtk, SpDef, Speed, Types) -2. Add 4 moves to `drool/moves.csv` (Name, Mon, Power, Stamina, Accuracy, Priority, Type, Class, etc.) -3. Add ability to `drool/abilities.csv` (Name, Mon, Effect description) -4. Create directory `src/mons//` (lowercase, e.g., `src/mons/aurox/`) -5. Implement 4 move contracts as `PascalCase.sol` files (see "Move Implementation Patterns" below) -6. Implement 1 ability contract as `PascalCase.sol` (see "Ability Patterns" below) -7. Run `python processing/validateMoves.py` to validate contracts match CSV data -8. Run `python processing/generateSolidity.py` to regenerate `SetupMons.s.sol` -9. Add tests in `test/mons/Test.sol` extending `BattleHelper` - -### Move Implementation Patterns - -Choose the simplest pattern that fits the move's behavior: - -**1. Pure `StandardAttack`** — constructor-only, no `move()` override. For straightforward damaging moves or simple effect-applying moves. Pass `EFFECT` + `EFFECT_ACCURACY` for probabilistic status application (e.g., 30% chance to burn): - -```solidity -contract Blow is StandardAttack { - constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR) - StandardAttack(msg.sender, _ENGINE, _TYPE_CALCULATOR, ATTACK_PARAMS({ - BASE_POWER: 70, STAMINA_COST: 2, ACCURACY: DEFAULT_ACCURACY, - PRIORITY: DEFAULT_PRIORITY, MOVE_TYPE: Type.Air, - EFFECT_ACCURACY: 0, MOVE_CLASS: MoveClass.Physical, - CRIT_RATE: DEFAULT_CRIT_RATE, VOLATILITY: DEFAULT_VOL, - NAME: "Blow", EFFECT: IEffect(address(0)) - })) - {} -} -``` - -**2. `StandardAttack` + `move()` override** — for moves with side effects after damage (recoil, self-switch, self-status, multi-hit). Override the full `move(...)` signature and call `_move()`, which returns `(int32 damage, bytes32 eventType)`, then add custom logic: - -```solidity -function move( - IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex, - uint256 targetBits, uint256 activesPacked, uint16 extraData, uint256 rng -) public override { - (int32 damage,) = _move(engine, battleKey, attackerPlayerIndex, targetBits, rng); - if (damage > 0) { - engine.dealDamage(attackerPlayerIndex, attackerMonIndex, selfDamage); // recoil - } -} -``` - -A move's target domain is client-facing metadata in `moves.csv`'s `TargetSpec` column (→ TS `Move.targetSpec`), not a Solidity declaration; `validateMoves.py` checks each move's `targetBits` behavior against it. The player's chosen target arrives as `targetBits` (resolved against `activesPacked` via `TargetLib`); the old `targetSpec()` / `extraDataType()` / `ExtraDataType` selector layers have been removed. - -**3. Custom `IMoveSet`** — for complex conditional moves (variable power, healing, stat manipulation, reading opponent state). Implement the `IMoveSet` functions directly (`move` + metadata getters + `getMeta`). Deal damage via `engine.dispatchStandardAttack` / `dispatchCustomAttack` (passing `targetBits`); `AttackCalculator._calculateDamage()` now just delegates to `dispatchCustomAttack`. Store dependencies as `immutable`. - -**4. `IMoveSet` + `BasicEffect` hybrid** — for moves that persist as effects across turns (traps, delayed damage, per-turn modifiers). Implement both interfaces in one contract. The `move()` function calls `ENGINE.addEffect(playerIndex, monIndex, IEffect(address(this)), ...)`. - -**Shared libraries** — when multiple moves in the same mon share logic, extract into a `library` contract in the same directory (e.g., `NineNineNineLib.sol`, `HeatBeaconLib.sol`). - -### Ability Patterns - -Each mon has exactly one ability, implemented in its own `PascalCase.sol` file within the mon directory. - -**Pure `IAbility`** — for one-time switch-in actions (deal damage, apply a stat boost). Implement `name()` and `activateOnSwitch()`. No persistent state. (e.g., `PreemptiveShock`, `SaviorComplex`) - -**`IAbility` + `BasicEffect`** (most common) — for abilities with ongoing lifecycle effects. `activateOnSwitch()` registers `address(this)` as an effect on the mon, then hooks into effect lifecycle via `getStepsBitmap()`. Must override `name()` with `override(IAbility, BasicEffect)`. Uses an idempotency guard to prevent duplicate registration: - -```solidity -function activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external { - (EffectInstance[] memory effects,) = ENGINE.getEffects(battleKey, playerIndex, monIndex); - for (uint256 i; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) return; - } - ENGINE.addEffect(playerIndex, monIndex, IEffect(address(this)), bytes32(0)); -} -``` - -For once-per-battle abilities (e.g., `RiseFromTheGrave`), use a `globalKV` flag instead of the effect-list check. - -### Adding a New Effect - -Effects fall into several categories depending on scope: +### Adding a New Mon, Move, Ability, or Effect -- **Status effects** (`src/effects/status/`): Extend `StatusEffect` which enforces one-status-per-mon via a KV flag. Shared across mons — deployed once, injected into moves via constructor parameters. (e.g., `BurnStatus`, `FrostbiteStatus`, `SleepStatus`) -- **Battlefield effects** (`src/effects/battlefield/`): Extend `BasicEffect`, use `targetIndex=2` for global scope. (e.g., `Overclock`) -- **Shared utility effects** (`src/effects/`): Deployed once, used by many contracts. (e.g., `StaminaRegen` for per-turn recovery). NOTE: stat modifiers are **not** an effect — they are inlined Engine functions (`addStatBoost`/`removeStatBoost`/…); see "Stat Boosts" above. -- **Mon-local effects** (`src/mons//`): Abilities or move-effect hybrids that only apply to one mon. These live in the mon's directory, not in `src/effects/`. - -To implement a new effect: -1. Extend `BasicEffect` (or `StatusEffect` for status conditions) -2. Override the relevant lifecycle hooks (`onRoundEnd`, `onAfterDamage`, `onPreDamage`, …) — each receives `activesPacked` for local slot resolution via `TargetLib` -3. Return a bitmap from `getStepsBitmap()` indicating which hooks to call -4. Return `(updatedExtraData, removeAfterRun)` from hooks — use `extraData` (bytes32) to carry state between turns (counters, degrees, flags) -5. Shared effects are injected into moves/abilities via constructor parameters at deploy time — there is no runtime effect registry +The step-by-step workflow (CSV data, which move/ability contract pattern to choose, effect bitmap rules) is in the `add-game-content` skill (`.claude/skills/add-game-content/SKILL.md`) — invoke it when adding new game content. ## Build & Deploy Pipeline -### Processing Scripts (Python 3.11+) - -```bash -python processing/validateMoves.py # Validate contracts vs CSV -python processing/generateSolidity.py # Generate SetupMons.s.sol -python processing/generateSetupCPU.py # Generate SetupCPU.s.sol from cpu-teams.json -python processing/deploy.py --testnet # Full deployment (forge scripts + codegen) -python processing/deploy.py --mainnet # Production deployment -``` - -Python dependencies: `numpy`, `pexpect`, `pillow` (managed via `uv`, see `pyproject.toml`) - -### Transpiler (Solidity to TypeScript / Rust) - -Converts Solidity contracts to TypeScript for local battle simulation: - -```bash -# Transpile all contracts -python3 transpiler/sol2ts.py src/ -o transpiler/ts-output -d src --emit-metadata - -# Run transpiler tests -cd transpiler && npm install && npx vitest run -``` - -**Rust target (sim performance).** The same front-end also drives a Rust -backend (`codegen_rs/`, native ints instead of bigint — see -`transpiler/codegen_rs/README.md`): - -```bash -python3 -m transpiler src/ --target rust # emit transpiler/rs-output (cargo workspace) -cd transpiler/rs-output && cargo build --release # engine + runtime + strategies (+ its bins) -CHOMP_ROOT=$(pwd)/../.. ./target/release/arena --games 6000 # whole-game batches, win-rate table -CHOMP_ROOT=$(pwd)/../.. ./target/release/trace --games 6000 --narrate 0 # trace analysis + greedy counterfactual -``` - -The full engine, all mons/effects, and the three CPU strategies (hard, -greedy, override) are transpiled/ported. The arena is now a **standalone, -pure-Rust binary** (`chomp-strategies`' `arena`/`trace` bins): it loads the -roster from `drool/*.csv` + `src/mons/*.json` at runtime and runs whole games -natively (~2,000 games/s at 8 threads, deterministic across thread counts). -The stacks are DECOUPLED — the Rust side was ported move-for-move against TS -and then cut loose; it may diverge freely (including its rng stream and CPU -decisions) as the prototyping substrate, and port-backs to the TS game carry -no bit-identicality requirement. Emission is allowlisted in -`transpiler-config-rust.json`. `rs-output/` is regenerated (gitignored); -hand-written crates live in `transpiler/{runtime-rs,strategies-rs}`. The -former bun↔Rust FFI seam (`chomp_run_games`, the `ffi` crate, and -`scripts/batch_benchmark.ts`) was removed once the pure-Rust arena replaced -it; the verification-era machinery (golden-vector suites, replay fixtures, -the drive-mode adapter, lockstep gates) lives in git history if parity ever -needs re-proving. The TS arena driver (`sims/src/arena/game.ts`) remains as -the port-back reference. - -**Known limitation — TODO when a second codebase needs it.** The transpiler hardcodes three TS namespace names (`Enums`, `Structs`, `Constants`) for type-only Solidity files. File-type detection is content-based (a file with only enums maps to the `Enums` namespace regardless of filename), but the *namespace name itself* is fixed, and only structs are tracked per source path. A codebase that splits types across multiple files (e.g. `PoolStructs.sol` + `OrderStructs.sol`, or `Errors.sol`) would produce colliding imports. To generalize: add `enum_paths` / `constant_paths` to `transpiler/type_system/registry.py` mirroring `struct_paths`, derive the namespace name from each source file's basename, and have `imports.py:_generate_module_imports` emit one `import * as ` per actually-referenced source module instead of three blanket imports. - -### Deployment Order - -1. `EngineAndPeriphery.s.sol` - Engine, type calculator, `SignedCommitManager`, `GachaTeamRegistry`, `SignedMatchmaker`, CPU host, `SimplePM`, shared effects -2. `SetupMons.s.sol` - All mon contracts (moves, abilities) -3. `SetupCPU.s.sol` - CPU players - -### CI/CD - -GitHub Actions runs on pull requests (`.github/workflows/main.yml`): -- `forge build` -- `forge test -vvv` +Processing scripts, the Solidity-to-TS/Rust transpiler, deployment order, and CI/CD are documented in the `deploy-chomp` skill (`.claude/skills/deploy-chomp/SKILL.md`) — invoke it for codegen, validation, transpiling, or deployment tasks. ## Key Data Files diff --git a/README.md b/README.md index b6017891..6e6a5d1f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ your own effects! your own hooks! +your own CPU! — see **[BENCH.md](BENCH.md)**: write a bot, score it against a ladder of +built-in opponents, with the real battle engine as your forward model. + general flow of the game is: - each turn, players simultaneously choose a move on their active mon. - moves can alter stats, do damage, or generally mutate game state in some way. diff --git a/bench.sh b/bench.sh new file mode 100755 index 00000000..9693c659 --- /dev/null +++ b/bench.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# chomp bot benchmark — setup and scoring. +# +# ./bench.sh setup generate the engine and build (first time, slow) +# ./bench.sh run --bot greedy score a bot against the ladder +# ./bench.sh test run the bot smoke tests +# +# See BENCH.md for how to write a bot. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="$ROOT/transpiler/rs-output" +export CHOMP_ROOT="$ROOT" + +need() { + command -v "$1" >/dev/null 2>&1 || { echo "bench.sh: '$1' is required but not on PATH" >&2; exit 1; } +} + +setup() { + need python3 + need cargo + echo "==> generating the Rust engine from the Solidity source" + python3 -m transpiler src/ --target rust + echo "==> building (first run compiles the engine and its deps; ~35s on 8 cores, plus crate downloads)" + cargo build --release --manifest-path "$OUT/Cargo.toml" -p chomp-strategies + echo "==> ready. Try: ./bench.sh run --bot greedy" +} + +# The engine is generated, not committed, so a fresh checkout has to build once. +require_build() { + if [ ! -x "$OUT/target/release/bench" ]; then + echo "bench.sh: not built yet — running setup first." >&2 + setup + fi +} + +cmd="${1:-}" +shift || true + +case "$cmd" in + setup) setup ;; + run) + require_build + exec "$OUT/target/release/bench" "$@" + ;; + arena) + require_build + exec "$OUT/target/release/arena" "$@" + ;; + test) + need cargo + exec cargo test --release --manifest-path "$OUT/Cargo.toml" -p chomp-strategies "$@" + ;; + *) + sed -n '2,9p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 2 + ;; +esac diff --git a/docs/stomp_2.md b/docs/stomp_2.md index e4c6f26a..3939e4ec 100644 --- a/docs/stomp_2.md +++ b/docs/stomp_2.md @@ -1,24 +1,88 @@ -Remember when we dreamed of fully on-chain battles with collectibles. +ONCHAIN IS FUN NOW -It's live! +Prior reading: Make Onchain Fun Again -Play against CPU, instant feedback, settle onchain. Full expressiveness, weird mechanics. And an entire game costs less than $0.01 in gas! +TLDR: Stomp.gg is fully live, and there is a A LOT of content, you should try it out, and it all costs under $0.01 in ETH. -Okay, but what if you want to play against your friends? +I genuinely believe Stomp is one of the most interesting, complex, and fun experiences you can have onchain today. If you want to see what's possible with ultra fast blocks, ultra low fees, and ultra insane overengineering, I urge you to try it out. Now, the more perceptive of you in the audience may worry that I may not be entirely biased. Not to worry. -It's live! Play live against another friend, moves settle onchain, everyone else can spectate, hook into the events. +Here is what is ALREADY LIVE TODAY, THAT YOU CAN TRY OUT, ENTIRELY ONCHAIN: -Okay, but what if you're a huge friend group and want to play 2v2? +First, the core game loop. What even **is** Stomp? -It's live! Even more strategy and depth. +Stomp is a turn-based RPG game. It borrows heavy inspiration from Pokemon. Each turn, you choose a move, your opponent chooses a move, and then they resolve. You might do some damage, you might heal some damage, you might change which monster you have out. -Okay, but what if you want unlockables and more customization? +SCREENSHOT HERE -It's live! 13 mons to choose from, all wholly unique, unlockable stat upgrades, new moves, it's all there! +You can play a full game across a variety of CPU difficulties, with basically zero perceived latency. The game engine run both locally and onchain so you get instant feedback. There's a rich and varied set of mechanics, and it all costs less than $0.01 in ETH to get set up and play! *1 -Okay, but what if you only care if you and your friend can bet $10 on the outcome? +If you've played JRPGs or turn-based games, a lot of the mechanics may be familiar to you. There's a resource system, various types of damage (damage over time, delayed damage, elemental charts), and also a few unique ideas (some mons can resurrect, others can target any mon, etc. etc.) -It's live! Wagers are shipped, and anyone can participate. +INSERT GAME GIF + +GETTING STARTED + +To make it as easy as possible to get started trying out Stomp, I've snapshotted a list of Twitter mutuals to give you FREE GAS to start. + +If you're on the list here (LINK), just link your Twitter during onboarding, and it'll automatically faucet you up! + +Otherwise, you only need around 0.00001 ETH or less to get started. + +Once you're set up, you can pick your initial starting mon: + +STARTER IMAGES + +- Ghouliath allows you to spam various status effects, and it has a powerful ability to resurrect itself. +- Inutia is a supporter that also boosts up the rest of your team. +- Malalien is a glass cannon that will flip your opponents off. + +Then, you'll immediately get to roll the gacha for the remaining 3 mons. Once you have a team of 4, you're ready to start battling! + +GACHA IMAGES + +For your first match, Battle CPU will let you play immediately. If you want a challenge, you can select higher tiers, but Easy CPU is best if you haven't played before. + +During your turn, you'll first decide which mon to send out. Then, you have a variety of moves to choose from, which can affect the battle in different ways. Hovering over will give you a preview of what the effects will be. All moves have a stamina cost, similar to mana in other RPGs. So unlike the PP system in Pokemon, a shared resource system means you decide when to use your strongest moves. If you're out of stamina, you can take a Rest action for your turn to regain some more. + +You win if you can KO all of the opposing team's mons. Once you finish, your mons will gain some experience, and you'll earn enough gacha points for a another roll! + +BATTLE RESULTS SCREEN + +Okay, but playing against bots is boring. What if you want to play against a friend? + +We got you covered! Stomp lets you run 1v1 games against anyone! Play live against another friend, moves settle onchain, and everyone else can spectate! + +INSERT 1v1 GAME GIF + +Okay, but you're on mobile. What if you want to play on the go? + +We got you covered! Stomp is mobile-friendly, and you can play every mode everywhere. + +INSERT MOBILE GAME + +Okay, but you've already played 1v1 games a lot. What if you want more strategy and depth? What if you want to battle your opponents 2 at a time? + +We got you covered! Stomp supports both singles and doubles game modes! With doubles, you have even more depth and strategies, and probably lots of busted combos. Help me break the game! + +INSERT DOUBLES GIF + +Okay, but games are only fun if they have something **at stake**. What if you want to wager on the outcome with your friends to both earn their glory and their riches? + +We got you covered! Anyone can participate on wagering for PVP battles. Grab your friends and run your own tournament brackets! + +INSERT WAGERS GIF + +Okay, but the real depth is the progression. What if you want unlockables and customization to theorycraft all sorts of combos? + +We got you covered! There's 13 mons live today (with more coming soon!), each with their own completely unique mechanics, unlockable moves, stat upgrades, quests, ranked ladder, it's all there! + +FOR THE NERDS + +How do we keep Stomp fast and cheap? What actually happens under the hood? + +*1 (around 2-4 million gas, for the nerds) + +------- Okay, but what if you want to make your own CPU, run agents and have it all happen automatically? diff --git a/drool/abilities.csv b/drool/abilities.csv index df29cf2c..8950a531 100644 --- a/drool/abilities.csv +++ b/drool/abilities.csv @@ -3,8 +3,8 @@ Rise From The Grave,Ghouliath,"Once per game, after being KO'ed, Ghouliath reviv Tinderclaws,Embursa,"After every move, Embursa has a 33% chance of burning itself. If burned, gain a 50% SpATK boost. When resting, Embursa heals from burn." Actus Reus,Malalien,"If Malalien KO's an opposing mon, they gain an Indictment. Upon KO, if they have an Indictment, Malalien cripples their murderer." Baselight,Iblivion,"Whenever Iblivion takes damage, it gains a Baselight point. Points can be used to empower moves." -Carrot Harvest,Sofabbi,"At the end of every turn, Sofabbi has a 50% chance of regaining 1 stamina. This can bank stamina past the maximum." -Post-Workout,Pengym,"On swap out, if Pengym has a status condition, heal it (any status). If healed, Pengym regains 1 stamina, which can exceed the maximum." +Carrot Harvest,Sofabbi,"At the end of every turn, Sofabbi has a 50% chance of regaining 1 stamina." +Post-Workout,Pengym,"On swap out, if Pengym has a status condition, heal it (any status). If healed, Pengym regains 1 stamina." Angery,Gorillax,"Each time Gorillax takes damage, they get Angerier. At 3 stacks, they heal for 16.6% of max HP." Preemptive Shock,Volthare,"When Volthare swaps in, they deal a small amount of damage to the opposing mon." Interweaving,Inutia,"When Inutia swaps in, the opposing mon's ATK decreases 15%. When Inutia swaps out, the opposing mon's SpATK decreases 15%." diff --git a/drool/imgs/attacks/attack_spritesheet.json b/drool/imgs/attacks/attack_spritesheet.json index ddf009c8..edbc4c44 100644 --- a/drool/imgs/attacks/attack_spritesheet.json +++ b/drool/imgs/attacks/attack_spritesheet.json @@ -3,102 +3,204 @@ "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[0, 0], [96, 0], [192, 0], [288, 0], [384, 0], [480, 0], [576, 0], [672, 0], [768, 0], [864, 0], [960, 0], [1056, 0], [1152, 0], [1248, 0], [1344, 0], [1440, 0], [1536, 0], [1632, 0], [1728, 0], [1824, 0], [0, 96], [96, 96], [192, 96], [288, 96]] + "frames": [[0, 0], [96, 0], [192, 0], [288, 0], [384, 0], [480, 0], [576, 0], [672, 0], [768, 0], [864, 0], [960, 0], [1056, 0], [1152, 0], [1248, 0], [1344, 0], [1440, 0], [1536, 0], [1632, 0], [1728, 0], [1824, 0], [1920, 0], [2016, 0], [2112, 0], [2208, 0]] }, "bubble_bop": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[384, 96], [480, 96], [576, 96], [672, 96], [768, 96], [864, 96], [960, 96], [1056, 96], [1152, 96], [1248, 96], [1344, 96], [1440, 96], [1536, 96], [1632, 96], [1728, 96], [1824, 96], [0, 192], [96, 192], [192, 192], [288, 192], [384, 192], [480, 192], [576, 192], [672, 192]] + "frames": [[2304, 0], [2400, 0], [0, 96], [96, 96], [192, 96], [288, 96], [384, 96], [480, 96], [576, 96], [672, 96], [768, 96], [864, 96], [960, 96], [1056, 96], [1152, 96], [1248, 96], [1344, 96], [1440, 96], [1536, 96], [1632, 96], [1728, 96], [1824, 96], [1920, 96], [2016, 96]] + }, + "chill_out": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[2112, 96], [2208, 96], [2304, 96], [2400, 96], [0, 192], [96, 192], [192, 192], [288, 192], [384, 192], [480, 192], [576, 192], [672, 192], [768, 192], [864, 192], [960, 192], [1056, 192], [1152, 192], [1248, 192]] }, "contagious_slumber": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[768, 192], [864, 192], [960, 192], [1056, 192], [1152, 192], [1248, 192], [1344, 192], [1440, 192], [1536, 192], [1632, 192], [1728, 192], [1824, 192], [0, 288], [96, 288], [192, 288], [288, 288], [384, 288], [480, 288], [576, 288], [672, 288], [768, 288], [864, 288], [960, 288], [1056, 288]] + "frames": [[1344, 192], [1440, 192], [1536, 192], [1632, 192], [1728, 192], [1824, 192], [1920, 192], [2016, 192], [2112, 192], [2208, 192], [2304, 192], [2400, 192], [0, 288], [96, 288], [192, 288], [288, 288], [384, 288], [480, 288], [576, 288], [672, 288], [768, 288], [864, 288], [960, 288], [1056, 288]] }, "deep_freeze": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[1152, 288], [1248, 288], [1344, 288], [1440, 288], [1536, 288], [1632, 288], [1728, 288], [1824, 288], [0, 384], [96, 384], [192, 384], [288, 384], [384, 384], [480, 384], [576, 384], [672, 384], [768, 384], [864, 384], [960, 384], [1056, 384], [1152, 384], [1248, 384], [1344, 384], [1440, 384]] + "frames": [[1152, 288], [1248, 288], [1344, 288], [1440, 288], [1536, 288], [1632, 288], [1728, 288], [1824, 288], [1920, 288], [2016, 288], [2112, 288], [2208, 288], [2304, 288], [2400, 288], [0, 384], [96, 384], [192, 384], [288, 384], [384, 384], [480, 384], [576, 384], [672, 384], [768, 384], [864, 384]] + }, + "dual_shock": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[960, 384], [1056, 384], [1152, 384], [1248, 384], [1344, 384], [1440, 384], [1536, 384], [1632, 384], [1728, 384], [1824, 384], [1920, 384], [2016, 384]] + }, + "electrocute": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[2112, 384], [2208, 384], [2304, 384], [2400, 384], [0, 480], [96, 480], [192, 480], [288, 480], [384, 480], [480, 480], [576, 480], [672, 480], [768, 480], [864, 480], [960, 480], [1056, 480]] + }, + "federal_investigation": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[1152, 480], [1248, 480], [1344, 480], [1440, 480], [1536, 480], [1632, 480], [1728, 480], [1824, 480], [1920, 480], [2016, 480], [2112, 480], [2208, 480], [2304, 480], [2400, 480], [0, 576], [96, 576], [192, 576], [288, 576]] }, "infernal_flame": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[1536, 384], [1632, 384], [1728, 384], [1824, 384], [0, 480], [96, 480], [192, 480], [288, 480], [384, 480], [480, 480], [576, 480], [672, 480], [768, 480], [864, 480], [960, 480], [1056, 480], [1152, 480], [1248, 480], [1344, 480], [1440, 480], [1536, 480], [1632, 480], [1728, 480], [1824, 480], [0, 576]] + "frames": [[384, 576], [480, 576], [576, 576], [672, 576], [768, 576], [864, 576], [960, 576], [1056, 576], [1152, 576], [1248, 576], [1344, 576], [1440, 576], [1536, 576], [1632, 576], [1728, 576], [1824, 576], [1920, 576], [2016, 576], [2112, 576], [2208, 576], [2304, 576], [2400, 576], [0, 672], [96, 672], [192, 672]] }, "infinite_love": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[96, 576], [192, 576], [288, 576], [384, 576], [480, 576], [576, 576], [672, 576], [768, 576], [864, 576], [960, 576], [1056, 576], [1152, 576], [1248, 576], [1344, 576], [1440, 576], [1536, 576], [1632, 576], [1728, 576], [1824, 576], [0, 672], [96, 672], [192, 672], [288, 672], [384, 672], [480, 672]] + "frames": [[288, 672], [384, 672], [480, 672], [576, 672], [672, 672], [768, 672], [864, 672], [960, 672], [1056, 672], [1152, 672], [1248, 672], [1344, 672], [1440, 672], [1536, 672], [1632, 672], [1728, 672], [1824, 672], [1920, 672], [2016, 672], [2112, 672], [2208, 672], [2304, 672], [2400, 672], [0, 768], [96, 768]] + }, + "loop": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[192, 768], [288, 768], [384, 768], [480, 768], [576, 768], [672, 768], [768, 768], [864, 768], [960, 768], [1056, 768], [1152, 768], [1248, 768], [1344, 768], [1440, 768], [1536, 768], [1632, 768], [1728, 768], [1824, 768]] }, "mega_star_blast": { "msPerFrame": 50, "width": 96, "height": 96, - "frames": [[576, 672], [672, 672], [768, 672], [864, 672], [960, 672], [1056, 672], [1152, 672], [1248, 672], [1344, 672], [1440, 672], [1536, 672], [1632, 672], [1728, 672], [1824, 672], [0, 768], [96, 768], [192, 768], [288, 768], [384, 768], [480, 768], [576, 768], [672, 768], [768, 768], [864, 768], [960, 768], [1056, 768], [1152, 768], [1248, 768], [1344, 768], [1440, 768], [1536, 768], [1632, 768], [1728, 768], [1824, 768], [0, 864], [96, 864], [192, 864], [288, 864], [384, 864], [480, 864]] + "frames": [[1920, 768], [2016, 768], [2112, 768], [2208, 768], [2304, 768], [2400, 768], [0, 864], [96, 864], [192, 864], [288, 864], [384, 864], [480, 864], [576, 864], [672, 864], [768, 864], [864, 864], [960, 864], [1056, 864], [1152, 864], [1248, 864], [1344, 864], [1440, 864], [1536, 864], [1632, 864], [1728, 864], [1824, 864], [1920, 864], [2016, 864], [2112, 864], [2208, 864], [2304, 864], [2400, 864], [0, 960], [96, 960], [192, 960], [288, 960], [384, 960], [480, 960], [576, 960], [672, 960]] + }, + "modal_bolt_fire": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[768, 960], [864, 960], [960, 960], [1056, 960], [1152, 960], [1248, 960], [1344, 960], [1440, 960], [1536, 960], [1632, 960], [1728, 960], [1824, 960], [1920, 960], [2016, 960], [2112, 960], [2208, 960]] + }, + "modal_bolt_ice": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[2304, 960], [2400, 960], [0, 1056], [96, 1056], [192, 1056], [288, 1056], [384, 1056], [480, 1056], [576, 1056], [672, 1056], [768, 1056], [864, 1056], [960, 1056], [1056, 1056], [1152, 1056], [1248, 1056]] + }, + "modal_bolt_lightning": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[1344, 1056], [1440, 1056], [1536, 1056], [1632, 1056], [1728, 1056], [1824, 1056], [1920, 1056], [2016, 1056], [2112, 1056], [2208, 1056], [2304, 1056], [2400, 1056], [0, 1152], [96, 1152], [192, 1152], [288, 1152]] + }, + "negative_thoughts": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[384, 1152], [480, 1152], [576, 1152], [672, 1152], [768, 1152], [864, 1152], [960, 1152], [1056, 1152], [1152, 1152], [1248, 1152], [1344, 1152], [1440, 1152], [1536, 1152], [1632, 1152], [1728, 1152], [1824, 1152], [1920, 1152], [2016, 1152]] }, "pound_ground": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[576, 864], [672, 864], [768, 864], [864, 864], [960, 864], [1056, 864], [1152, 864], [1248, 864], [1344, 864], [1440, 864], [1536, 864], [1632, 864], [1728, 864], [1824, 864], [0, 960], [96, 960], [192, 960], [288, 960], [384, 960], [480, 960], [576, 960]] + "frames": [[2112, 1152], [2208, 1152], [2304, 1152], [2400, 1152], [0, 1248], [96, 1248], [192, 1248], [288, 1248], [384, 1248], [480, 1248], [576, 1248], [672, 1248], [768, 1248], [864, 1248], [960, 1248], [1056, 1248], [1152, 1248], [1248, 1248], [1344, 1248], [1440, 1248], [1536, 1248]] }, "q5": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[672, 960], [768, 960], [864, 960], [960, 960], [1056, 960], [1152, 960], [1248, 960], [1344, 960], [1440, 960], [1536, 960], [1632, 960], [1728, 960], [1824, 960], [0, 1056], [96, 1056], [192, 1056], [288, 1056], [384, 1056], [480, 1056], [576, 1056], [672, 1056], [768, 1056], [864, 1056], [960, 1056]] + "frames": [[1632, 1248], [1728, 1248], [1824, 1248], [1920, 1248], [2016, 1248], [2112, 1248], [2208, 1248], [2304, 1248], [2400, 1248], [0, 1344], [96, 1344], [192, 1344], [288, 1344], [384, 1344], [480, 1344], [576, 1344], [672, 1344], [768, 1344], [864, 1344], [960, 1344], [1056, 1344], [1152, 1344], [1248, 1344], [1344, 1344]] + }, + "quickstorm": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[1440, 1344], [1536, 1344], [1632, 1344], [1728, 1344], [1824, 1344], [1920, 1344], [2016, 1344], [2112, 1344], [2208, 1344], [2304, 1344]] + }, + "round_trip": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[2400, 1344], [0, 1440], [96, 1440], [192, 1440], [288, 1440], [384, 1440], [480, 1440], [576, 1440], [672, 1440], [768, 1440], [864, 1440], [960, 1440], [1056, 1440], [1152, 1440]] + }, + "sanctify": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[1248, 1440], [1344, 1440], [1440, 1440], [1536, 1440], [1632, 1440], [1728, 1440], [1824, 1440], [1920, 1440], [2016, 1440], [2112, 1440], [2208, 1440], [2304, 1440], [2400, 1440], [0, 1536], [96, 1536], [192, 1536], [288, 1536], [384, 1536]] }, "scary_numbers": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[1056, 1056], [1152, 1056], [1248, 1056], [1344, 1056], [1440, 1056], [1536, 1056], [1632, 1056], [1728, 1056], [1824, 1056], [0, 1152], [96, 1152], [192, 1152], [288, 1152], [384, 1152], [480, 1152], [576, 1152], [672, 1152], [768, 1152], [864, 1152], [960, 1152], [1056, 1152], [1152, 1152], [1248, 1152], [1344, 1152]] + "frames": [[480, 1536], [576, 1536], [672, 1536], [768, 1536], [864, 1536], [960, 1536], [1056, 1536], [1152, 1536], [1248, 1536], [1344, 1536], [1440, 1536], [1536, 1536], [1632, 1536], [1728, 1536], [1824, 1536], [1920, 1536], [2016, 1536], [2112, 1536], [2208, 1536], [2304, 1536], [2400, 1536], [0, 1632], [96, 1632], [192, 1632]] + }, + "set_ablaze": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[288, 1632], [384, 1632], [480, 1632], [576, 1632], [672, 1632], [768, 1632], [864, 1632], [960, 1632], [1056, 1632], [1152, 1632], [1248, 1632], [1344, 1632], [1440, 1632], [1536, 1632], [1632, 1632], [1728, 1632]] }, "sneak_attack": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[1440, 1152], [1536, 1152], [1632, 1152], [1728, 1152], [1824, 1152], [0, 1248], [96, 1248], [192, 1248], [288, 1248], [384, 1248], [480, 1248], [576, 1248], [672, 1248], [768, 1248], [864, 1248], [960, 1248], [1056, 1248], [1152, 1248]] + "frames": [[1824, 1632], [1920, 1632], [2016, 1632], [2112, 1632], [2208, 1632], [2304, 1632], [2400, 1632], [0, 1728], [96, 1728], [192, 1728], [288, 1728], [384, 1728], [480, 1728], [576, 1728], [672, 1728], [768, 1728], [864, 1728], [960, 1728]] }, "stat_boost_enemy": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[1248, 1248], [1344, 1248], [1440, 1248], [1536, 1248], [1632, 1248], [1728, 1248], [1824, 1248], [0, 1344], [96, 1344], [192, 1344], [288, 1344], [384, 1344], [480, 1344], [576, 1344]] + "frames": [[1056, 1728], [1152, 1728], [1248, 1728], [1344, 1728], [1440, 1728], [1536, 1728], [1632, 1728], [1728, 1728], [1824, 1728], [1920, 1728], [2016, 1728], [2112, 1728], [2208, 1728], [2304, 1728]] }, "stat_debuff_enemy": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[672, 1344], [768, 1344], [864, 1344], [960, 1344], [1056, 1344], [1152, 1344], [1248, 1344], [1344, 1344], [1440, 1344], [1536, 1344], [1632, 1344], [1728, 1344], [1824, 1344], [0, 1440]] + "frames": [[2400, 1728], [0, 1824], [96, 1824], [192, 1824], [288, 1824], [384, 1824], [480, 1824], [576, 1824], [672, 1824], [768, 1824], [864, 1824], [960, 1824], [1056, 1824], [1152, 1824]] + }, + "throw_pebble": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[1248, 1824], [1344, 1824], [1440, 1824], [1536, 1824], [1632, 1824], [1728, 1824], [1824, 1824], [1920, 1824], [2016, 1824], [2112, 1824], [2208, 1824], [2304, 1824], [2400, 1824], [0, 1920]] + }, + "triple_think": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[96, 1920], [192, 1920], [288, 1920], [384, 1920], [480, 1920], [576, 1920], [672, 1920], [768, 1920], [864, 1920], [960, 1920]] + }, + "unexpected_carrot": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[1056, 1920], [1152, 1920], [1248, 1920], [1344, 1920], [1440, 1920], [1536, 1920], [1632, 1920], [1728, 1920], [1824, 1920], [1920, 1920], [2016, 1920], [2112, 1920], [2208, 1920], [2304, 1920], [2400, 1920], [0, 2016]] }, "vital_siphon": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[96, 1440], [192, 1440], [288, 1440], [384, 1440], [480, 1440], [576, 1440], [672, 1440], [768, 1440], [864, 1440], [960, 1440], [1056, 1440], [1152, 1440], [1248, 1440], [1344, 1440], [1440, 1440], [1536, 1440], [1632, 1440], [1728, 1440], [1824, 1440], [0, 1536], [96, 1536], [192, 1536], [288, 1536], [384, 1536]] + "frames": [[96, 2016], [192, 2016], [288, 2016], [384, 2016], [480, 2016], [576, 2016], [672, 2016], [768, 2016], [864, 2016], [960, 2016], [1056, 2016], [1152, 2016], [1248, 2016], [1344, 2016], [1440, 2016], [1536, 2016], [1632, 2016], [1728, 2016], [1824, 2016], [1920, 2016], [2016, 2016], [2112, 2016], [2208, 2016], [2304, 2016]] + }, + "wither_away": { + "msPerFrame": 100, + "width": 96, + "height": 96, + "frames": [[2400, 2016], [0, 2112], [96, 2112], [192, 2112], [288, 2112], [384, 2112], [480, 2112], [576, 2112], [672, 2112], [768, 2112], [864, 2112], [960, 2112], [1056, 2112], [1152, 2112], [1248, 2112], [1344, 2112]] }, "gachachacha_bunnies": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[480, 1536], [576, 1536], [672, 1536], [672, 1536], [768, 1536], [864, 1536], [480, 1536], [480, 1536], [480, 1536], [960, 1536], [1056, 1536], [1152, 1536], [1248, 1536], [960, 1536], [1344, 1536], [1632, 1536], [0, 1632], [288, 1632], [576, 1632], [864, 1632], [1152, 1632], [1440, 1632], [1728, 1632], [96, 1728], [384, 1728], [384, 1728], [384, 1728]] + "frames": [[1440, 2112], [1536, 2112], [1632, 2112], [1632, 2112], [1728, 2112], [1824, 2112], [1440, 2112], [1440, 2112], [1440, 2112], [1920, 2112], [2016, 2112], [2112, 2112], [2208, 2112], [1920, 2112], [2304, 2112], [96, 2208], [384, 2208], [672, 2208], [960, 2208], [1248, 2208], [1536, 2208], [1824, 2208], [2112, 2208], [2400, 2208], [192, 2304], [192, 2304], [192, 2304]] }, "gachachacha_carrots": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[480, 1536], [576, 1536], [672, 1536], [672, 1536], [768, 1536], [864, 1536], [480, 1536], [480, 1536], [480, 1536], [960, 1536], [1056, 1536], [1152, 1536], [1248, 1536], [960, 1536], [1440, 1536], [1728, 1536], [96, 1632], [384, 1632], [672, 1632], [960, 1632], [1248, 1632], [1536, 1632], [1824, 1632], [192, 1728], [384, 1728], [384, 1728], [384, 1728]] + "frames": [[1440, 2112], [1536, 2112], [1632, 2112], [1632, 2112], [1728, 2112], [1824, 2112], [1440, 2112], [1440, 2112], [1440, 2112], [1920, 2112], [2016, 2112], [2112, 2112], [2208, 2112], [1920, 2112], [2400, 2112], [192, 2208], [480, 2208], [768, 2208], [1056, 2208], [1344, 2208], [1632, 2208], [1920, 2208], [2208, 2208], [0, 2304], [192, 2304], [192, 2304], [192, 2304]] }, "gachachacha_skulls": { "msPerFrame": 100, "width": 96, "height": 96, - "frames": [[480, 1536], [576, 1536], [672, 1536], [672, 1536], [768, 1536], [864, 1536], [480, 1536], [480, 1536], [480, 1536], [960, 1536], [1056, 1536], [1152, 1536], [1248, 1536], [960, 1536], [1536, 1536], [1824, 1536], [192, 1632], [480, 1632], [768, 1632], [1056, 1632], [1344, 1632], [1632, 1632], [0, 1728], [288, 1728], [384, 1728], [384, 1728], [384, 1728]] + "frames": [[1440, 2112], [1536, 2112], [1632, 2112], [1632, 2112], [1728, 2112], [1824, 2112], [1440, 2112], [1440, 2112], [1440, 2112], [1920, 2112], [2016, 2112], [2112, 2112], [2208, 2112], [1920, 2112], [0, 2208], [288, 2208], [576, 2208], [864, 2208], [1152, 2208], [1440, 2208], [1728, 2208], [2016, 2208], [2304, 2208], [96, 2304], [192, 2304], [192, 2304], [192, 2304]] } } \ No newline at end of file diff --git a/drool/imgs/attacks/attack_spritesheet.png b/drool/imgs/attacks/attack_spritesheet.png index 28051ea5..48c8eec9 100644 Binary files a/drool/imgs/attacks/attack_spritesheet.png and b/drool/imgs/attacks/attack_spritesheet.png differ diff --git a/drool/imgs/attacks/chill_out.gif b/drool/imgs/attacks/chill_out.gif new file mode 100644 index 00000000..5d79f63e Binary files /dev/null and b/drool/imgs/attacks/chill_out.gif differ diff --git a/drool/imgs/attacks/chill_out.png b/drool/imgs/attacks/chill_out.png new file mode 100644 index 00000000..85db81fd Binary files /dev/null and b/drool/imgs/attacks/chill_out.png differ diff --git a/drool/imgs/attacks/dual_shock.gif b/drool/imgs/attacks/dual_shock.gif new file mode 100644 index 00000000..46b754db Binary files /dev/null and b/drool/imgs/attacks/dual_shock.gif differ diff --git a/drool/imgs/attacks/dual_shock.png b/drool/imgs/attacks/dual_shock.png new file mode 100644 index 00000000..1e9f60a8 Binary files /dev/null and b/drool/imgs/attacks/dual_shock.png differ diff --git a/drool/imgs/attacks/electrocute.gif b/drool/imgs/attacks/electrocute.gif new file mode 100644 index 00000000..50f9827e Binary files /dev/null and b/drool/imgs/attacks/electrocute.gif differ diff --git a/drool/imgs/attacks/electrocute.png b/drool/imgs/attacks/electrocute.png new file mode 100644 index 00000000..b48728c4 Binary files /dev/null and b/drool/imgs/attacks/electrocute.png differ diff --git a/drool/imgs/attacks/federal_investigation.gif b/drool/imgs/attacks/federal_investigation.gif new file mode 100644 index 00000000..2e9d2e61 Binary files /dev/null and b/drool/imgs/attacks/federal_investigation.gif differ diff --git a/drool/imgs/attacks/federal_investigation.png b/drool/imgs/attacks/federal_investigation.png new file mode 100644 index 00000000..1fea7208 Binary files /dev/null and b/drool/imgs/attacks/federal_investigation.png differ diff --git a/drool/imgs/attacks/loop.gif b/drool/imgs/attacks/loop.gif new file mode 100644 index 00000000..01f34693 Binary files /dev/null and b/drool/imgs/attacks/loop.gif differ diff --git a/drool/imgs/attacks/loop.png b/drool/imgs/attacks/loop.png new file mode 100644 index 00000000..34be7b34 Binary files /dev/null and b/drool/imgs/attacks/loop.png differ diff --git a/drool/imgs/attacks/modal_bolt_fire.gif b/drool/imgs/attacks/modal_bolt_fire.gif new file mode 100644 index 00000000..5c067fa4 Binary files /dev/null and b/drool/imgs/attacks/modal_bolt_fire.gif differ diff --git a/drool/imgs/attacks/modal_bolt_fire.png b/drool/imgs/attacks/modal_bolt_fire.png new file mode 100644 index 00000000..8873e88d Binary files /dev/null and b/drool/imgs/attacks/modal_bolt_fire.png differ diff --git a/drool/imgs/attacks/modal_bolt_ice.gif b/drool/imgs/attacks/modal_bolt_ice.gif new file mode 100644 index 00000000..71217a66 Binary files /dev/null and b/drool/imgs/attacks/modal_bolt_ice.gif differ diff --git a/drool/imgs/attacks/modal_bolt_ice.png b/drool/imgs/attacks/modal_bolt_ice.png new file mode 100644 index 00000000..2cfeb348 Binary files /dev/null and b/drool/imgs/attacks/modal_bolt_ice.png differ diff --git a/drool/imgs/attacks/modal_bolt_lightning.gif b/drool/imgs/attacks/modal_bolt_lightning.gif new file mode 100644 index 00000000..282356ce Binary files /dev/null and b/drool/imgs/attacks/modal_bolt_lightning.gif differ diff --git a/drool/imgs/attacks/modal_bolt_lightning.png b/drool/imgs/attacks/modal_bolt_lightning.png new file mode 100644 index 00000000..3677d304 Binary files /dev/null and b/drool/imgs/attacks/modal_bolt_lightning.png differ diff --git a/drool/imgs/attacks/negative_thoughts.gif b/drool/imgs/attacks/negative_thoughts.gif new file mode 100644 index 00000000..9e013d78 Binary files /dev/null and b/drool/imgs/attacks/negative_thoughts.gif differ diff --git a/drool/imgs/attacks/negative_thoughts.png b/drool/imgs/attacks/negative_thoughts.png new file mode 100644 index 00000000..442134d7 Binary files /dev/null and b/drool/imgs/attacks/negative_thoughts.png differ diff --git a/drool/imgs/attacks/quickstorm.gif b/drool/imgs/attacks/quickstorm.gif new file mode 100644 index 00000000..1ac9e187 Binary files /dev/null and b/drool/imgs/attacks/quickstorm.gif differ diff --git a/drool/imgs/attacks/quickstorm.png b/drool/imgs/attacks/quickstorm.png new file mode 100644 index 00000000..11ce6a84 Binary files /dev/null and b/drool/imgs/attacks/quickstorm.png differ diff --git a/drool/imgs/attacks/round_trip.gif b/drool/imgs/attacks/round_trip.gif new file mode 100644 index 00000000..84e6c4bd Binary files /dev/null and b/drool/imgs/attacks/round_trip.gif differ diff --git a/drool/imgs/attacks/round_trip.png b/drool/imgs/attacks/round_trip.png new file mode 100644 index 00000000..5329d77d Binary files /dev/null and b/drool/imgs/attacks/round_trip.png differ diff --git a/drool/imgs/attacks/sanctify.gif b/drool/imgs/attacks/sanctify.gif new file mode 100644 index 00000000..155884ec Binary files /dev/null and b/drool/imgs/attacks/sanctify.gif differ diff --git a/drool/imgs/attacks/sanctify.png b/drool/imgs/attacks/sanctify.png new file mode 100644 index 00000000..926ad5be Binary files /dev/null and b/drool/imgs/attacks/sanctify.png differ diff --git a/drool/imgs/attacks/set_ablaze.gif b/drool/imgs/attacks/set_ablaze.gif new file mode 100644 index 00000000..d596146c Binary files /dev/null and b/drool/imgs/attacks/set_ablaze.gif differ diff --git a/drool/imgs/attacks/set_ablaze.png b/drool/imgs/attacks/set_ablaze.png new file mode 100644 index 00000000..103be62a Binary files /dev/null and b/drool/imgs/attacks/set_ablaze.png differ diff --git a/drool/imgs/attacks/throw_pebble.gif b/drool/imgs/attacks/throw_pebble.gif new file mode 100644 index 00000000..8aa550cc Binary files /dev/null and b/drool/imgs/attacks/throw_pebble.gif differ diff --git a/drool/imgs/attacks/throw_pebble.png b/drool/imgs/attacks/throw_pebble.png new file mode 100644 index 00000000..db6141da Binary files /dev/null and b/drool/imgs/attacks/throw_pebble.png differ diff --git a/drool/imgs/attacks/triple_think.gif b/drool/imgs/attacks/triple_think.gif new file mode 100644 index 00000000..42dfe234 Binary files /dev/null and b/drool/imgs/attacks/triple_think.gif differ diff --git a/drool/imgs/attacks/triple_think.png b/drool/imgs/attacks/triple_think.png new file mode 100644 index 00000000..d950b246 Binary files /dev/null and b/drool/imgs/attacks/triple_think.png differ diff --git a/drool/imgs/attacks/unexpected_carrot.gif b/drool/imgs/attacks/unexpected_carrot.gif new file mode 100644 index 00000000..43188949 Binary files /dev/null and b/drool/imgs/attacks/unexpected_carrot.gif differ diff --git a/drool/imgs/attacks/unexpected_carrot.png b/drool/imgs/attacks/unexpected_carrot.png new file mode 100644 index 00000000..4767a234 Binary files /dev/null and b/drool/imgs/attacks/unexpected_carrot.png differ diff --git a/drool/imgs/attacks/wither_away.gif b/drool/imgs/attacks/wither_away.gif new file mode 100644 index 00000000..217bcb93 Binary files /dev/null and b/drool/imgs/attacks/wither_away.gif differ diff --git a/drool/imgs/attacks/wither_away.png b/drool/imgs/attacks/wither_away.png new file mode 100644 index 00000000..8d109736 Binary files /dev/null and b/drool/imgs/attacks/wither_away.png differ diff --git a/drool/imgs/mon_switch.png b/drool/imgs/mon_switch.png index 947c6f60..6262058f 100644 Binary files a/drool/imgs/mon_switch.png and b/drool/imgs/mon_switch.png differ diff --git a/drool/moves.csv b/drool/moves.csv index 1ce57c1c..3f69901a 100644 --- a/drool/moves.csv +++ b/drool/moves.csv @@ -43,7 +43,7 @@ Volatile Punch,Aurox,40,3,100,0,Metal,Physical,Deals damage. 25% chance of Burn Gilded Recovery,Aurox,0,2,100,0,Faith,Self,"Heals a target friendly mon (Aurox included) from a status condition. If successful, Aurox heals {HEAL_PERCENT} of max HP and the target gains +1 stamina (this can exceed the stamina maximum). If the target has no status, the cast does nothing but still costs stamina.",,,self-mon,0,HEAL_PERCENT=50,none Iron Wall,Aurox,0,3,100,0,Metal,Self,"Until Aurox switches out, regenerate {HEAL_PERCENT} of all damage taken, its own recoil included. When activated for the first time each switch-in, Aurox heals {INITIAL_HEAL_PERCENT} of max HP.",,,none,0,HEAL_PERCENT=50;INITIAL_HEAL_PERCENT=20,self-only Bull Rush,Aurox,140,2,100,0,Metal,Physical,Deals damage. Also deals {SELF_DAMAGE_PERCENT} of max HP to self.,,,none,0,SELF_DAMAGE_PERCENT=20,opponent-slot -Big Bellow,Aurox,0,1,100,0,Metal,Other,Lowers the target's SpATK and Speed by 50%.,,,opponent-mon,6,,any-other-slot +Big Bellow,Aurox,0,1,100,0,Metal,Other,Lowers the target's SpATK and Speed by 50%.,,,none,6,,any-other-slot Contagious Slumber,Xmon,0,2,100,0,Cosmic,Other,Inflicts Sleep on self and opponent.,,,none,0,,opponent-slot Vital Siphon,Xmon,40,2,90,0,Cosmic,Special,"Deals damage, {STAMINA_STEAL_PERCENT} chance to steal 1 stamina from opponent.",,,none,0,STAMINA_STEAL_PERCENT=50,opponent-slot Somniphobia,Xmon,0,1,100,0,Cosmic,Other,"For 4 rounds, any opposing mon that gains stamina takes {DAMAGE_DENOM:frac} of its max HP per stack as damage. Repeated casts while active increase the damage but do not reset the cooldown.",,,none,0,DAMAGE_DENOM=8,self-only diff --git a/foundry.toml b/foundry.toml index c6adec91..5d6714fb 100644 --- a/foundry.toml +++ b/foundry.toml @@ -24,9 +24,21 @@ yul = true [profile.default.optimizer_details.yulDetails] stackAllocation = true +# Fast correctness iteration for Engine-heavy changes. This deliberately does NOT produce +# comparable gas numbers: keep the default profile for snapshots and final benchmarks. +# Separate artifacts preserve both caches when switching profiles. +[profile.fast] +out = 'out-fast' +cache_path = 'cache-fast' +optimizer = false +via_ir = true +sparse_mode = true +build_info = false +gas_snapshot_emit = false + [fmt] sort_imports = true # forge 1.5.x's formatter DROPS THE BRACES of one-line multi-statement if bodies under the # default/preserve/single modes ("if (c) { a; break; }" -> unconditional break). "multi" is # the one mode whose rewrite is brace-correct — verified on if+break / if+else / for shapes. -single_line_statement_blocks = "multi" \ No newline at end of file +single_line_statement_blocks = "multi" diff --git a/processing/deploy.py b/processing/deploy.py index e93f8479..7e85bf9f 100755 --- a/processing/deploy.py +++ b/processing/deploy.py @@ -338,6 +338,11 @@ def run_deploy(args, network, rpc_url, password, chomp_dir, env_path): if not run_validate(): raise RuntimeError("Move validation failed. Fix issues before deploying.") + print_banner("Validating effect getStepsBitmap() ALWAYS_APPLIES_BIT usage") + from validateEffectBitmaps import run as run_validate_effect_bitmaps + if not run_validate_effect_bitmaps(): + raise RuntimeError("Effect bitmap validation failed. Fix issues before deploying.") + print_banner("Generating Solidity deployment script (SetupMons.s.sol)") from generateSolidity import run as run_solidity if not run_solidity(): diff --git a/processing/generateMonsTypeScript.py b/processing/generateMonsTypeScript.py index 35d6bd2f..be6c01fb 100644 --- a/processing/generateMonsTypeScript.py +++ b/processing/generateMonsTypeScript.py @@ -124,6 +124,13 @@ def build_sprite_config( "oppKO": "gachachacha_bunnies", "selfKO": "gachachacha_skulls", }, + # Modal Bolt picks by the chosen mode (extraData 0/1/2), not a runtime outcome. + # Art pending — the keys/wiring are live so the sprites drop in with no code change. + "Modal Bolt": { + "fire": "modal_bolt_fire", + "ice": "modal_bolt_ice", + "lightning": "modal_bolt_lightning", + }, } # Maps move name → attack-overlay placement. Absent means the default 'target' @@ -132,6 +139,9 @@ def build_sprite_config( # same kind of override as MOVE_SPRITE_VARIANTS, keyed by exact move name. MOVE_OVERLAY_PLACEMENT: Dict[str, str] = { "Gachachacha": "canvas-center", + "Loop": "self", + "Triple Think": "self", + "Sanctify": "self", } @@ -502,13 +512,13 @@ def generate_typescript_const(data: Dict[int, Dict[str, Any]], output_file: str) export const MonMetadata = {json_str} as const; -// Moves with runtime-branching outcomes (currently just Gachachacha) emit -// this shape on `sprite` instead of a single SpriteAnimationConfig; the -// renderer picks by branch. +// Moves that fan out to more than one attack animation emit this open map on +// `sprite` instead of a single SpriteAnimationConfig; the renderer picks the +// key by branch (the move's MoveBranch). Each variant move owns its own keys — +// Gachachacha uses runtime outcomes (normal/oppKO/selfKO), Modal Bolt uses the +// cast-time mode (fire/ice/lightning) — so nothing central enumerates them. export type MoveSpriteVariants = {{ - readonly normal: SpriteAnimationConfig; - readonly oppKO: SpriteAnimationConfig; - readonly selfKO: SpriteAnimationConfig; + readonly [variant: string]: SpriteAnimationConfig; }}; // Where a move's attack overlay renders. Default 'target' overlays the sprite diff --git a/processing/generateSolidity.py b/processing/generateSolidity.py index 7b85eabb..26484482 100644 --- a/processing/generateSolidity.py +++ b/processing/generateSolidity.py @@ -289,6 +289,8 @@ def load_json_move(mon_name: str, move_name: str, base_path: str) -> Optional[di MOVE_META_TAG = 1 << 160 +MOVE_CONTEXT_STATUS_LANES = 1 << 161 +MOVE_RESOLVER_TAG = 1 << 162 MOVE_META_DYNAMIC = 0xF DEFAULT_PRIORITY = 3 @@ -326,16 +328,21 @@ def deployed_move_meta(mon_name: str, move_name: str, base_path: str) -> int: contract_name = contract_name_from_move_or_ability(move_name) sol_path = os.path.join(base_path, "src", "mons", mon_dir, f"{contract_name}.sol") stamina_val = priority_val = None + context_bits = 0 if os.path.exists(sol_path): with open(sol_path, "r", encoding="utf-8") as f: content = f.read() + if re.search(r"@move-context:\s*status-lanes\b", content): + context_bits |= MOVE_CONTEXT_STATUS_LANES + if re.search(r"@move-resolver\b", content): + context_bits |= MOVE_RESOLVER_TAG stamina_val = _extract_static_int(content, "stamina", "STAMINA_COST") priority_val = _extract_static_int(content, "priority", "PRIORITY") if stamina_val is None or not 0 <= stamina_val < MOVE_META_DYNAMIC: stamina_val = MOVE_META_DYNAMIC if priority_val is None or not 0 <= priority_val < MOVE_META_DYNAMIC: priority_val = MOVE_META_DYNAMIC - return MOVE_META_TAG | (stamina_val << 236) | (priority_val << 244) + return MOVE_META_TAG | context_bits | (stamina_val << 236) | (priority_val << 244) def effect_name_to_env_var(effect_name: str) -> str: @@ -806,4 +813,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/processing/validateEffectBitmaps.py b/processing/validateEffectBitmaps.py new file mode 100644 index 00000000..02c31a57 --- /dev/null +++ b/processing/validateEffectBitmaps.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Static checks on every IEffect contract's getStepsBitmap() under src/: + +1. ALWAYS_APPLIES_BIT (0x8000) is set when-and-only-when the contract has no shouldApply() + gating logic of its own. Engine._addEffectInternal skips the external shouldApply() call + entirely when the bit is set; BasicEffect's default shouldApply() unconditionally returns + true, so any contract relying on it should set the bit (skipping a wasted external call), + and any contract with a real shouldApply() override must NOT set it. + +2. Status class bits (10-13): deployable ids are 1..14, unique across src/ (two statuses + sharing an id would make the Engine treat one as a re-apply of the other). Id 15 is + reserved for test-only mocks (test/mocks/TestStatusClass.sol) and rejected in src/. + +3. HAS_REAPPLY_BIT (0x4000) is set when-and-only-when the contract implements onReapply() + (the Engine calls it on a same-class re-apply iff the bit is set), and requires a nonzero + status class. + +Pure Solidity source inspection over every contract in the IEffect hierarchy (declares +`is ... BasicEffect ...` or `is ... StatusEffect ...`). Bitmap expressions may reference +constants declared in the same file (e.g. STATUS_CLASS) plus the shared Constants.sol names. +""" + +import os +import re +import sys + +ALWAYS_APPLIES_BIT = 0x8000 +HAS_REAPPLY_BIT = 0x4000 +STATUS_CLASS_SHIFT = 10 +STATUS_CLASS_MASK = 0xF + +# Shared names resolvable inside bitmap expressions (mirrors src/Constants.sol and +# test/mocks/TestStatusClass.sol). +KNOWN_CONSTANTS = { + "ALWAYS_APPLIES_BIT": ALWAYS_APPLIES_BIT, + "HAS_REAPPLY_BIT": HAS_REAPPLY_BIT, + "STATUS_CLASS_SHIFT": STATUS_CLASS_SHIFT, + "STATUS_CLASS_MASK": STATUS_CLASS_MASK, + "TEST_STATUS_CLASS": 15, +} + +CONTRACT_RE = re.compile(r"\bcontract\s+(\w+)\s+is\s+([^{]+?)\s*\{", re.DOTALL) +STEPS_FN_RE = re.compile(r"function\s+getStepsBitmap\s*\([^)]*\)[^{]*\{(.*?)\}", re.DOTALL) +RETURN_RE = re.compile(r"return\s+([^;]+);") +SHOULD_APPLY_FN_RE = re.compile(r"\bfunction\s+shouldApply\s*\(") +ON_REAPPLY_FN_RE = re.compile(r"\bfunction\s+onReapply\s*\(") +CONSTANT_DECL_RE = re.compile( + r"\buint\d*\s+(?:public\s+|private\s+|internal\s+)?constant\s+(\w+)\s*=\s*([^;]+);" +) +SAFE_EXPR_RE = re.compile(r"^[\s0-9a-fA-FxX|&()<>+]*$") + + +def _collect_constants(text): + """Same-file constant declarations, resolved iteratively so one constant may + reference another (or the shared KNOWN_CONSTANTS).""" + consts = dict(KNOWN_CONSTANTS) + decls = CONSTANT_DECL_RE.findall(text) + for _ in range(len(decls) + 1): + progressed = False + for name, expr in decls: + if name in consts and name not in KNOWN_CONSTANTS: + continue + val = _eval_expr(expr, consts) + if val is not None: + if consts.get(name) != val: + progressed = True + consts[name] = val + if not progressed: + break + return consts + + +def _eval_expr(expr, consts): + """Evaluate a Solidity constant expression of hex/dec literals, |, &, <<, >>, and + parentheses. uintN(...) casts are stripped. Returns None if unresolvable.""" + expr = expr.strip() + expr = re.sub(r"\buint\d*\s*\(", "(", expr) + for name, val in sorted(consts.items(), key=lambda kv: -len(kv[0])): + expr = re.sub(r"\b" + re.escape(name) + r"\b", str(val), expr) + # Unresolved identifier = a letter-initiated token (hex literals like 0x801D start with a + # digit, so their letter digits don't trip this). + if not SAFE_EXPR_RE.match(expr) or re.search(r"(?> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK + + has_own_should_apply = bool(SHOULD_APPLY_FN_RE.search(text)) + has_own_on_reapply = bool(ON_REAPPLY_FN_RE.search(text)) + + # Rule 1: ALWAYS_APPLIES_BIT <=> no shouldApply override. + if has_own_should_apply and has_always: + issues.append(( + "error", + f"{name}: has custom shouldApply() gating but ALWAYS_APPLIES_BIT is set - " + f"this makes the Engine skip the gate entirely", + )) + elif not has_own_should_apply and not has_always: + issues.append(( + "error", + f"{name}: relies on BasicEffect's default shouldApply() (always true) but " + f"ALWAYS_APPLIES_BIT is not set in getStepsBitmap() - missed optimization " + f"(see src/Constants.sol ALWAYS_APPLIES_BIT)", + )) + + # Rule 2: status class range + uniqueness (deployable ids are 1..14; 15 is test-only). + if status_class == 15: + issues.append(( + "error", + f"{name}: status class 15 is reserved for test mocks " + f"(test/mocks/TestStatusClass.sol) - deployable statuses use 1..14", + )) + elif status_class != 0: + prior = class_registry.get(status_class) + if prior is not None: + issues.append(( + "error", + f"{name}: status class {status_class} already used by {prior} - the Engine " + f"would treat one as a re-apply of the other", + )) + else: + class_registry[status_class] = name + + # Rule 3: HAS_REAPPLY_BIT <=> onReapply implemented, and requires a class. + if has_reapply_bit and not has_own_on_reapply: + issues.append(( + "error", + f"{name}: HAS_REAPPLY_BIT is set but onReapply() is not implemented - the " + f"Engine's same-class re-apply call would revert", + )) + elif has_own_on_reapply and not has_reapply_bit: + issues.append(( + "error", + f"{name}: implements onReapply() but HAS_REAPPLY_BIT is not set - the Engine " + f"would never call it", + )) + if has_reapply_bit and status_class == 0: + issues.append(( + "error", + f"{name}: HAS_REAPPLY_BIT requires a nonzero status class (bits 10-13)", + )) + + return issues + + +def run(src_path="src/"): + all_issues = [] + class_registry = {} + for path in sorted(find_sol_files(src_path)): + rel = os.path.relpath(path) + for severity, message in check_file(path, class_registry): + all_issues.append((severity, rel, message)) + + errors = [i for i in all_issues if i[0] == "error"] + warnings = [i for i in all_issues if i[0] == "warn"] + + for _severity, rel, message in warnings: + print(f"WARNING {rel}: {message}") + for _severity, rel, message in errors: + print(f"ERROR {rel}: {message}") + + if errors: + print(f"\n❌ {len(errors)} effect bitmap issue(s) found.") + return False + + used = ", ".join(f"{c}={n}" for c, n in sorted(class_registry.items())) + print(f"\n✅ All {sum(1 for _ in find_sol_files(src_path))} Solidity files checked - effect bitmaps OK.") + print(f" Status classes in use: {used or '(none)'}") + return True + + +def find_sol_files(src_path): + for root, _dirs, files in os.walk(src_path): + for f in files: + if f.endswith(".sol"): + yield os.path.join(root, f) + + +def main(): + src_path = sys.argv[1] if len(sys.argv) > 1 else "src/" + if not run(src_path): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/script/SetupMons.s.sol b/script/SetupMons.s.sol index b76f36a2..d4f7b3fc 100644 --- a/script/SetupMons.s.sol +++ b/script/SetupMons.s.sol @@ -170,7 +170,7 @@ contract SetupMons is Script { moves[1] = 0x0030200000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[1])); moves[2] = 0x0030300000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[2])); moves[3] = 0x5a00200000000000000000000000000000000000000000000000000000000000; - moves[4] = 0x0030200000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[3])); + moves[4] = 0x0030200000000000000000030000000000000000000000000000000000000000 | uint256(uint160(addrs[3])); uint256[] memory abilities = new uint256[](1); abilities[0] = uint256(uint160(addrs[4])); bytes32[] memory keys = new bytes32[](0); @@ -228,7 +228,7 @@ contract SetupMons is Script { moves[0] = 0x0030100000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[0])); moves[1] = 0x0030200000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[1])); moves[2] = 0x5009200000000000000000000000000000000000000000000000000000000000; - moves[3] = 0x0030200000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[2])); + moves[3] = 0x0030200000000000000000050000000000000000000000000000000000000000 | uint256(uint160(addrs[2])); moves[4] = 0x0030200000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[3])); uint256[] memory abilities = new uint256[](1); abilities[0] = uint256(uint160(addrs[4])); @@ -544,7 +544,7 @@ contract SetupMons is Script { uint256[] memory moves = new uint256[](4); moves[0] = 0x00f0200000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[0])); moves[1] = 0x00f0300000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[1])); - moves[2] = 0x00f0000000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[2])); + moves[2] = 0x00f0000000000000000000030000000000000000000000000000000000000000 | uint256(uint160(addrs[2])); moves[3] = 0x00f0200000000000000000010000000000000000000000000000000000000000 | uint256(uint160(addrs[3])); uint256[] memory abilities = new uint256[](1); abilities[0] = (uint256(1) << 248) | uint256(uint160(addrs[4])); diff --git a/sims/arena/bench-doubles.ts b/sims/arena/bench-doubles.ts deleted file mode 100644 index 95a69dd3..00000000 --- a/sims/arena/bench-doubles.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Doubles TS benchmark + win-rate: the ported joint maximin search (side 1) vs the greedy "Hard" - * pilot (side 0). Rust reference: search d1 ≈ 57.5% vs Hard; d1 decision ≈ sub-ms native. - * - * bun arena/bench-doubles.ts [games] - */ -import { makeSimContext, startBattle, executeSlotTurn } from '../src/harness'; -import { loadRoster } from '../src/util/csv-load'; -import { buildTeamMon } from '../src/arena/team'; -import { MemoizedInlineRngOracle } from '../src/arena/rng-oracle'; -import { makeRng } from '../src/arena/rng'; -import { packSide } from '../src/cpu/forward-model'; -import { searchSideMoves, greedySideMoves, type SlotMove } from '../src/cpu/doubles-search'; - -const games = Number(process.argv[2] ?? 20); -const roster = loadRoster(); -const allIds = roster.mons.map((m) => m.id); - -type Ctx = ReturnType; - -function newDoublesBattle(seed: number): { ctx: Ctx; bk: `0x${string}` } { - const rng = makeRng(seed); - const pool = [...allIds]; - const draw = (): number => pool.splice(Math.floor(rng() * pool.length), 1)[0]; - const ctx = makeSimContext({ monsPerTeam: 4n }); - const p0 = [draw(), draw(), draw(), draw()].map((id) => buildTeamMon(ctx, roster, id)); - const p1 = [draw(), draw(), draw(), draw()].map((id) => buildTeamMon(ctx, roster, id)); - const { battleKey } = startBattle(ctx, p0, p1, 1); // BATTLE_MODE_DOUBLES - const engine = ctx.engine as any; - engine.battleConfig[engine._getStorageKey(battleKey)].rngOracle = new MemoizedInlineRngOracle(); - return { ctx, bk: battleKey }; -} - -type Pilot = (e: any, bk: `0x${string}`, side: 0 | 1) => [SlotMove, SlotMove]; - -function playDoubles(seed: number, p1Pilot: Pilot, p0Pilot: Pilot, maxTurns = 200): 0 | 1 | null { - const { ctx, bk } = newDoublesBattle(seed); - const engine = ctx.engine as any; - const salt = makeRng(seed ^ 0xabcdef); - for (let t = 0; t < maxTurns; t++) { - const w = Number(engine.battleData[bk].winnerIndex); - if (w !== 2) return w as 0 | 1; - const m0 = p0Pilot(engine, bk, 0); - const m1 = p1Pilot(engine, bk, 1); - const s0 = packSide(m0[0].moveIndex, m0[0].extraData, m0[1].moveIndex, m0[1].extraData, BigInt(Math.floor(salt() * 2 ** 30))); - const s1 = packSide(m1[0].moveIndex, m1[0].extraData, m1[1].moveIndex, m1[1].extraData, BigInt(Math.floor(salt() * 2 ** 30))); - executeSlotTurn(ctx, bk, s0, s1); - } - const w = Number(engine.battleData[bk].winnerIndex); - return w !== 2 ? (w as 0 | 1) : null; -} - -const greedy: Pilot = (e, bk, side) => greedySideMoves(e, bk, side); -const searchD1: Pilot = (e, bk, side) => searchSideMoves(e, bk, side, 1); - -// ── 1. Decision timing at a mid-game both-act position ────────────────────── -{ - const { ctx, bk } = newDoublesBattle(777); - const engine = ctx.engine as any; - const salt = makeRng(3); - for (let t = 0; t < 6; t++) { - if (Number(engine.battleData[bk].winnerIndex) !== 2) break; - const m0 = greedySideMoves(engine, bk, 0); - const m1 = greedySideMoves(engine, bk, 1); - executeSlotTurn( - ctx, bk, - packSide(m0[0].moveIndex, m0[0].extraData, m0[1].moveIndex, m0[1].extraData, BigInt(Math.floor(salt() * 2 ** 30))), - packSide(m1[0].moveIndex, m1[0].extraData, m1[1].moveIndex, m1[1].extraData, BigInt(Math.floor(salt() * 2 ** 30))), - ); - } - const flag = Number(engine.getBattleContext(bk).playerSwitchForTurnFlag); - console.log(`doubles mid-game ready (turn ${engine.getTurnIdForBattleState(bk)}, flag ${flag})`); - for (const [label, f, iters] of [ - ['greedy side-decision', () => greedySideMoves(engine, bk, 1), 50], - ['search d1 side-decision', () => searchSideMoves(engine, bk, 1, 1), 5], - ] as Array<[string, () => unknown, number]>) { - for (let i = 0; i < 2; i++) f(); - const t0 = performance.now(); - for (let i = 0; i < iters; i++) f(); - console.log(`${label.padEnd(28)} ${((performance.now() - t0) / iters).toFixed(1)} ms/op (${iters} iters)`); - } -} - -// ── 2. Win rate: search d1 (side 1) vs greedy Hard (side 0) ───────────────── -let wins = 0, losses = 0, draws = 0; -const t0 = performance.now(); -for (let g = 0; g < games; g++) { - const w = playDoubles(4000 + g, searchD1, greedy); - if (w === 1) wins++; - else if (w === 0) losses++; - else draws++; -} -const secs = (performance.now() - t0) / 1000; -console.log( - `\nsearch d1 vs greedy-hard: ${wins}W-${losses}L-${draws}D → ${((wins / Math.max(1, wins + losses)) * 100).toFixed(1)}% · ${games} games in ${secs.toFixed(1)}s (${((secs / games) * 1000).toFixed(0)} ms/game)`, -); diff --git a/sims/arena/bench-search.ts b/sims/arena/bench-search.ts deleted file mode 100644 index 27d32c6d..00000000 --- a/sims/arena/bench-search.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Benchmark: what would the new search CPUs cost per decision in TypeScript? - * - * Measures the primitive the search is built from — a hypothetical fork (clone battle - * + run one silent turn + capture view) — plus the existing 1-ply strategies for - * context, on a real mid-game 4v4 position. Projects per-decision latency for the - * search configs from the Rust-measured forks-per-decision bands. - * - * bun arena/bench-search.ts - */ -import { makeSimContext, startBattle, executeTurn } from '../src/harness'; -import { loadRoster } from '../src/util/csv-load'; -import { buildTeamMon } from '../src/arena/team'; -import { MemoizedInlineRngOracle } from '../src/arena/rng-oracle'; -import { makeRng } from '../src/arena/rng'; -import { captureBattleView } from '../src/cpu/battle-view'; -import { applyHypotheticalMove } from '../src/cpu/forward-model'; -import { CPU_STRATEGIES } from '../src/cpu/registry'; -import * as Constants from '../../transpiler/ts-output/Constants'; - -const roster = loadRoster(); -const SWITCH = Number(Constants.SWITCH_MOVE_INDEX); - -// ── Stand up a 4v4 battle and walk it to a mid-game position ──────────────── -const ctx = makeSimContext({ monsPerTeam: 4n }); -const ids = roster.mons.map((m: any) => m.id); -const p0Team = ids.slice(0, 4).map((id: number) => buildTeamMon(ctx, roster, id)); -const p1Team = ids.slice(4, 8).map((id: number) => buildTeamMon(ctx, roster, id)); -const { battleKey } = startBattle(ctx, p0Team, p1Team); -const engine = ctx.engine as any; -engine.battleConfig[engine._getStorageKey(battleKey)].rngOracle = new MemoizedInlineRngOracle(); - -// Leads, then a few attack turns; resolve forced switches mid-walk (first legal bench) so the -// bench lands on a genuine both-act (flag == 2) position — the search's real per-turn workload. -executeTurn(ctx, battleKey, { p0MoveIndex: SWITCH, p1MoveIndex: SWITCH, p0ExtraData: 0n, p1ExtraData: 0n, p0Salt: 1n, p1Salt: 2n }); -const NO_OP = Number(Constants.NO_OP_MOVE_INDEX); -const firstBench = (side: 0 | 1): bigint => { - const ko = Number(engine.getKOBitmap(battleKey, BigInt(side))); - const active = Number(engine.getActiveMonIndexForBattleState(battleKey)[side]); - for (let i = 0; i < 4; i++) if (i !== active && (ko & (1 << i)) === 0) return BigInt(i); - return 0n; -}; -for (let t = 0; t < 6; t++) { - const flag = Number(engine.getBattleContext(battleKey).playerSwitchForTurnFlag); - if (flag === 2) { - executeTurn(ctx, battleKey, { - p0MoveIndex: 0, p1MoveIndex: 0, p0ExtraData: 0n, p1ExtraData: 0n, - p0Salt: BigInt(100 + t), p1Salt: BigInt(200 + t), - }); - } else { - // Forced-switch turn: only the flagged side acts. - executeTurn(ctx, battleKey, { - p0MoveIndex: flag === 0 ? SWITCH : NO_OP, p1MoveIndex: flag === 1 ? SWITCH : NO_OP, - p0ExtraData: flag === 0 ? firstBench(0) : 0n, p1ExtraData: flag === 1 ? firstBench(1) : 0n, - p0Salt: BigInt(300 + t), p1Salt: BigInt(400 + t), - }); - } -} -const finalFlag = Number(engine.getBattleContext(battleKey).playerSwitchForTurnFlag); -console.log(`mid-game position ready (turn ${engine.getTurnIdForBattleState(battleKey)}, flag ${finalFlag})`); - -function bench(label: string, iters: number, warmup: number, f: () => void): number { - for (let i = 0; i < warmup; i++) f(); - const t0 = performance.now(); - for (let i = 0; i < iters; i++) f(); - const ms = (performance.now() - t0) / iters; - console.log(`${label.padEnd(34)} ${ms.toFixed(3)} ms/op (${iters} iters)`); - return ms; -} - -// ── 1. The search primitive: hypothetical fork (fork + silent turn + view) ── -const msFork = bench('applyHypotheticalMove (1 fork)', 200, 20, () => { - applyHypotheticalMove(engine, battleKey, { moveIndex: 0, salt: 0n, extraData: 0 }, { moveIndex: 0, salt: 0n, extraData: 0 }); -}); - -// ── 2. Existing 1-ply strategies for context ───────────────────────────────── -const rng = makeRng(42); -const view = () => captureBattleView(engine, battleKey); -const greedy = CPU_STRATEGIES.get('greedy')!; -const hard = CPU_STRATEGIES.get('hard')!; -const msGreedy = bench('greedy.decide (1-ply)', 50, 5, () => { - greedy.decide(view(), { moveIndex: 0, extraData: 0 }, rng, greedy.createState()); -}); -let msHard = Number.NaN; -try { - msHard = bench('hard.decide (heuristic)', 50, 5, () => { - hard.decide(view(), { moveIndex: 0, extraData: 0 }, rng, hard.createState()); - }); -} catch (e) { - console.log(`hard.decide: SKIPPED (stale vs current engine surface: ${(e as Error).message})`); -} - -// ── 3. REAL search decisions (the ported SearchCpu) ───────────────────────── -import { SearchCpu } from '../src/cpu/strategies/search-cpu'; -for (const [label, depth, peek, iters] of [ - ['search d1 no-peek', 1, false, 20], - ['search d2 PEEK', 2, true, 10], - ['search d2 no-peek', 2, false, 5], -] as Array<[string, number, boolean, number]>) { - const s = new SearchCpu(label, depth, peek); - bench(`${label}.decide`, iters, 2, () => { - s.decide(view(), { moveIndex: 0, extraData: 0 }, rng, s.createState()); - }); -} -console.log(`\nms/fork: TS=${msFork.toFixed(3)} (greedy decision=${msGreedy.toFixed(1)}ms, hard=${msHard.toFixed(1)}ms)`); diff --git a/sims/arena/mon-data.ts b/sims/arena/mon-data.ts deleted file mode 100644 index 8cf8e37c..00000000 --- a/sims/arena/mon-data.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Entry shim so `bun arena/mon-data.ts` (run from sims/) reaches the real script under src/arena. -// process.argv is global, so flags pass straight through. -import '../src/arena/mon-data.ts'; diff --git a/sims/arena/probe-greedy-scores.ts b/sims/arena/probe-greedy-scores.ts deleted file mode 100644 index 610402ea..00000000 --- a/sims/arena/probe-greedy-scores.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Probe: what does greedy's own forward model score for each candidate on a board where - * Inutia's Initialize lock is set? Prints the per-candidate scores so the selection bug's - * layer (fork vs evaluator vs tie-break) is measured instead of guessed. - * - * bun arena/probe-greedy-scores.ts - */ -import { makeSimContext, startBattle, executeTurn } from '../src/harness'; -import { loadRoster } from '../src/util/csv-load'; -import { buildTeamMon } from '../src/arena/team'; -import { MemoizedInlineRngOracle } from '../src/arena/rng-oracle'; -import { captureBattleView } from '../src/cpu/battle-view'; -import { applyHypotheticalMove, disposeFork } from '../src/cpu/forward-model'; -import { scoreStateWith, DEFAULT_EVAL_WEIGHTS } from '../src/cpu/evaluator'; -import * as Constants from '../../transpiler/ts-output/Constants'; - -const roster = loadRoster(); -const monByName = (n: string) => roster.mons.find((r: any) => r.name === n)!; - -const ctx = makeSimContext({ monsPerTeam: 1n }); -const p0Team = [buildTeamMon(ctx, roster, monByName('Gorillax').id)]; -const p1Team = [buildTeamMon(ctx, roster, monByName('Inutia').id)]; -const { battleKey } = startBattle(ctx, p0Team, p1Team); -const engine = ctx.engine as any; -engine.battleConfig[engine._getStorageKey(battleKey)].rngOracle = new MemoizedInlineRngOracle(); - -const SWITCH = Number(Constants.SWITCH_MOVE_INDEX); -const NO_OP = Number(Constants.NO_OP_MOVE_INDEX); - -// Leads, then t1: Inutia (p1) casts Initialize for real — lock now set, boost live. -executeTurn(ctx, battleKey, { p0MoveIndex: SWITCH, p1MoveIndex: SWITCH, p0ExtraData: 0n, p1ExtraData: 0n, p0Salt: 1n, p1Salt: 2n }); -executeTurn(ctx, battleKey, { p0MoveIndex: NO_OP, p1MoveIndex: 1, p0ExtraData: 0n, p1ExtraData: 0n, p0Salt: 3n, p1Salt: 4n }); - -// Score each p1 candidate against a revealed p0 rest, exactly as greedy does (salt 0). -const view = captureBattleView(engine, battleKey); -const candidates: Array<[string, number]> = [ - ['Chain Expansion (0)', 0], - ['Initialize LOCKED (1)', 1], - ['Big Bite (2)', 2], - ['Hit And Dip (3)', 3], - ['rest (126)', NO_OP], -]; -console.log('board: p1 Inutia, Initialize cast last turn (lock set, +50% live); p0 Gorillax rests'); -for (const [label, idx] of candidates) { - const fork = applyHypotheticalMove(engine, view.bk as any, { moveIndex: NO_OP, salt: 0n, extraData: 0 }, { moveIndex: idx, salt: 0n, extraData: 0 }); - const score = scoreStateWith(fork, DEFAULT_EVAL_WEIGHTS); - console.log(` ${label.padEnd(24)} -> ${score.toFixed(2)}`); - disposeFork(engine, fork.bk); -} diff --git a/sims/arena/probe-initialize.ts b/sims/arena/probe-initialize.ts deleted file mode 100644 index c456d034..00000000 --- a/sims/arena/probe-initialize.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Probe: how many of greedy's Inutia Initialize casts are silent no-ops (cast while the - * once-per-send-out lock is set)? Gap-list instrument for design-pass v4's Inutia section; - * the pre-registered prediction there is ">= 40% of casts are locked". - * - * Lock proxy: Inutia's ATK delta > 0 at selection time — Initialize is the only source of a - * positive ATK delta on Inutia, the boost is Temp (shed on switch-out), and the lock also - * clears on switch-out, so boost-live and lock-set coincide. - * - * bun arena/probe-initialize.ts [--games N] - */ -import { playGame } from '../src/arena/game'; -import { GreedyEvalCpu } from '../src/cpu/strategies/greedy-eval'; -import { activeMonIndices, monMaxHp } from '../src/cpu/engine-view'; -import { MonStateIndexName } from '../../transpiler/ts-output/Enums'; -import { SWITCH_MOVE_INDEX } from '../src/cpu/constants'; - -const args = process.argv.slice(2); -const gi = args.indexOf('--games'); -const GAMES = gi >= 0 ? Number(args[gi + 1]) : 300; - -const INUTIA_ID = 1; -const INUTIA_HP = 351; -const INITIALIZE_SLOT = 1; // default equip order: CE 0, Initialize 1, Big Bite 2, Hit And Dip 3 - -let moveTurns = 0; -let initCasts = 0; -let lockedCasts = 0; -let switches = 0; -let errors = 0; - -function mulberry(seed: number): () => number { - let a = seed >>> 0; - return () => { - a |= 0; a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -function draftTeam(rng: () => number, forceInutia: boolean): { id: number }[] { - const pool = Array.from({ length: 13 }, (_, i) => i); - const picked: number[] = forceInutia ? [INUTIA_ID] : []; - while (picked.length < 4) { - const c = pool[Math.floor(rng() * pool.length)]; - if (!picked.includes(c)) picked.push(c); - } - return picked.map((id) => ({ id })); -} - -for (let g = 0; g < GAMES; g++) { - const rng = mulberry(g + 1); - const teams: [{ id: number }[], { id: number }[]] = [draftTeam(rng, g % 2 === 0), draftTeam(rng, g % 2 === 1)]; - const outcome = playGame(new GreedyEvalCpu(), new GreedyEvalCpu(), teams as any, g + 1, 150, ({ engine, battleKey, p0Move, p1Move }) => { - const [p0Active, p1Active] = activeMonIndices(engine, battleKey); - const sides: Array<[bigint, number, { moveIndex: number } | null]> = [ - [0n, p0Active, p0Move], - [1n, p1Active, p1Move], - ]; - for (const [player, idx, mv] of sides) { - if (!mv) continue; - if (monMaxHp(engine, battleKey, player, idx) !== INUTIA_HP) continue; - if (mv.moveIndex === Number(SWITCH_MOVE_INDEX)) { switches++; continue; } - if (mv.moveIndex > 3) continue; // rest / other non-move actions - moveTurns++; - if (mv.moveIndex === INITIALIZE_SLOT) { - initCasts++; - const atkDelta = Number(engine.getMonStateForBattle(battleKey, player, BigInt(idx), MonStateIndexName.Attack)); - if (atkDelta > 0) lockedCasts++; - } - } - }); - if ('error' in outcome) errors++; -} - -const pct = (n: number, d: number) => (d === 0 ? '0' : ((100 * n) / d).toFixed(1)); -console.log(`games ${GAMES} (errors ${errors}) greedy-vs-greedy, Inutia forced onto one seat per game`); -console.log(`Inutia move-turns: ${moveTurns} switches: ${switches}`); -console.log(`Initialize casts: ${initCasts} (${pct(initCasts, moveTurns)}% of move-turns)`); -console.log(` cast while locked (ATK delta > 0): ${lockedCasts} (${pct(lockedCasts, initCasts)}% of casts)`); diff --git a/sims/arena/smoke-games.ts b/sims/arena/smoke-games.ts deleted file mode 100644 index 3f0c15f4..00000000 --- a/sims/arena/smoke-games.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Smoke: full TS games after the staleness fixes — greedy & hard on both seats - * (exercises the transpose proxy, forward model, and both strategies end-to-end). - * - * bun arena/smoke-games.ts - */ -import { playGame } from '../src/arena/game'; -import { CPU_STRATEGIES } from '../src/cpu/registry'; -import { loadRoster } from '../src/util/csv-load'; -import { monCatalog } from '../src/arena/team'; -import type { DraftedMon } from '../src/arena/team'; - -const roster = loadRoster(); -const draft = (ids: number[]): DraftedMon[] => - ids.map((id) => { - const mon = roster.mons.find((m) => m.id === id)!; - const n = Math.min(4, monCatalog(roster, mon).length); - return { id, equip: Array.from({ length: n }, (_, i) => i) }; - }); - -const greedy = CPU_STRATEGIES.get('greedy')!; -const hard = CPU_STRATEGIES.get('hard')!; -const pairs: Array<[string, any, any]> = [ - ['greedy vs greedy', greedy, greedy], - ['hard vs greedy', hard, greedy], - ['greedy vs hard ', greedy, hard], -]; - -let ok = 0, err = 0; -for (const [label, p1, p0] of pairs) { - for (let g = 0; g < 4; g++) { - const ids = [...roster.mons.map((m) => m.id)].sort(() => 0.5 - Math.abs(Math.sin(g * 13 + ok))); // deterministic-ish shuffle - const teams: [DraftedMon[], DraftedMon[]] = [draft(ids.slice(0, 4)), draft(ids.slice(4, 8))]; - const out = playGame(p1, p0, teams, 1000 + g, 200); - if ('error' in out) { - err++; - console.log(`${label} game ${g}: ERROR ${out.error}`); - } else { - ok++; - console.log(`${label} game ${g}: winner seat ${out.winnerSeat} in ${out.turns} turns`); - } - } -} -console.log(`\n${ok} games completed, ${err} errors`); diff --git a/sims/arena/walk.ts b/sims/arena/walk.ts deleted file mode 100644 index 54ebc8d1..00000000 --- a/sims/arena/walk.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Entry shim so `bun arena/walk.ts` (run from sims/) reaches the real script under src/arena. -// process.argv is global, so flags pass straight through. -import '../src/arena/walk.ts'; diff --git a/sims/arena/winrate-search.ts b/sims/arena/winrate-search.ts deleted file mode 100644 index d50c515d..00000000 --- a/sims/arena/winrate-search.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Win-rate sanity for the ported SearchCpu vs greedy — direction should match the Rust arena - * (d2 no-peek ≈ 65%, d2 peek ≈ 86% vs greedy over matched drafts). - * - * bun arena/winrate-search.ts [games-per-config] - */ -import { playGame } from '../src/arena/game'; -import { CPU_STRATEGIES } from '../src/cpu/registry'; -import { makeRng } from '../src/arena/rng'; -import { loadRoster } from '../src/util/csv-load'; -import { monCatalog } from '../src/arena/team'; -import type { DraftedMon } from '../src/arena/team'; - -const games = Number(process.argv[2] ?? 30); -const roster = loadRoster(); -const allIds = roster.mons.map((m) => m.id); - -function draftTeams(seed: number): [DraftedMon[], DraftedMon[]] { - const rng = makeRng(seed); - const pool = [...allIds]; - const draw = (): number => pool.splice(Math.floor(rng() * pool.length), 1)[0]; - const draft = (ids: number[]): DraftedMon[] => - ids.map((id) => { - const mon = roster.mons.find((m) => m.id === id)!; - const n = Math.min(4, monCatalog(roster, mon).length); - return { id, equip: Array.from({ length: n }, (_, i) => i) }; - }); - return [draft([draw(), draw(), draw(), draw()]), draft([draw(), draw(), draw(), draw()])]; -} - -const greedy = CPU_STRATEGIES.get('greedy')!; -for (const key of ['search-peek', 'search']) { - const strat = CPU_STRATEGIES.get(key)!; - let wins = 0, losses = 0, errs = 0; - const t0 = performance.now(); - for (let g = 0; g < games; g++) { - const out = playGame(strat, greedy, draftTeams(5000 + g), 9000 + g, 200); - if ('error' in out) errs++; - else if (out.winnerSeat === 1) wins++; - else if (out.winnerSeat === 0) losses++; - } - const secs = (performance.now() - t0) / 1000; - const wr = (wins / Math.max(1, wins + losses)) * 100; - console.log( - `${key.padEnd(12)} vs greedy: ${wins}W-${losses}L (${errs} err) → ${wr.toFixed(1)}% · ${games} games in ${secs.toFixed(1)}s (${(secs / games * 1000).toFixed(0)} ms/game)`, - ); -} diff --git a/sims/src/arena/fast-status-key.ts b/sims/src/arena/fast-status-key.ts deleted file mode 100644 index 984e2b9b..00000000 --- a/sims/src/arena/fast-status-key.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { StatusEffectLib } from '../../../transpiler/ts-output/effects/status/StatusEffectLib'; - -/** - * SIM-ONLY perf install (imported for its side effect). `StatusEffectLib.getKeyForMonIndex` derives a - * status-effect storage key as `keccak256(encodePacked(STATUS_EFFECT, playerIndex, monIndex)) & mask64`. - * It is PURE — the only varying inputs are `playerIndex ∈ {0,1}` and `monIndex ∈ {0..7}`, so there are - * 16 possible results — yet the engine re-derives it ~8x per turn and every forward-model fork replays - * those turns, making it the dominant keccak in arena runs (~9x the volume of the per-turn RNG hash). - * - * The effects call it through the module singleton `statusEffectLib`, which shares this prototype, so - * memoizing the prototype method covers every caller with a ≤16-entry cache. Values are byte identical; - * only the repeated hashing is skipped. (A general proxy-layer memo was measured and REJECTED: gating - * every external contract call cost more than the keccak it saved — a targeted memo on this one hot pure - * method is strictly cheaper.) Lives in `sims/` (never the deployed contracts) and survives transpiler - * regeneration since the method name is stable. - */ -const proto = StatusEffectLib.prototype as unknown as { - getKeyForMonIndex(playerIndex: bigint, monIndex: bigint): bigint; - __simMemoizedKey?: boolean; -}; -if (!proto.__simMemoizedKey) { - const orig = proto.getKeyForMonIndex; - const cache = new Map(); - proto.getKeyForMonIndex = function (playerIndex: bigint, monIndex: bigint): bigint { - const k = Number(playerIndex) * 256 + Number(monIndex); - let v = cache.get(k); - if (v === undefined) { - v = orig.call(this, playerIndex, monIndex); - cache.set(k, v); - } - return v; - }; - proto.__simMemoizedKey = true; -} diff --git a/sims/src/arena/game.ts b/sims/src/arena/game.ts deleted file mode 100644 index b0a4ded2..00000000 --- a/sims/src/arena/game.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Single CPU-vs-CPU game driver for the balance arena on the TS engine - * (`sims/src/harness.ts` over `transpiler/ts-output`). Bulk simulation now - * lives entirely on the native Rust stack (`transpiler/strategies-rs`, the - * `arena`/`trace` bins); this TS driver is the port-back reference — the two - * stacks are decoupled and need not make identical decisions. - * - * Seating (from munch): every strategy assumes it is p1, so the p0 seat reads through `transposeEngine`; - * the p1 seat gets the true reveal of p0's move (production peek), the p0 seat only the opponent's - * previous move. - * - * Bridge specifics vs the raw chomp harness: - * 1. After `startBattle`, the battle's rngOracle is patched to a MEMOIZED reproduction of the engine's - * inline zero-oracle rng (`keccak256(uint104 p0Salt, uint104 p1Salt)`) — same values as munch's - * ZERO-oracle harness, but forks with repeated salts skip the keccak (see `rng-oracle.ts`). - * 2. On a forced-switch turn only the acting side is decided AND only its salt is drawn (p0 before p1), - * exactly mirroring munch's `toDecision(null)` skip — the shared rng stream must stay in lockstep. - */ -import { CpuStrategy, PlayerMove, StrategyState } from '../cpu/strategy'; -import { captureBattleView } from '../cpu/battle-view'; -import { NO_OP_INDEX, TEAM_SIZE } from '../cpu/constants'; -import { makeSimContext, startBattle, executeTurn, P0_ADDR, P1_ADDR, type SimContext } from '../harness'; -import { loadRoster } from '../util/csv-load'; -import { buildTeamMon, type DraftedMon } from './team'; -import { transposeEngine } from './transpose'; -import { makeRng, randomSalt } from './rng'; -import { MemoizedInlineRngOracle } from './rng-oracle'; -import './fast-status-key'; // registers StatusEffectLib.getKeyForMonIndex as a pure (memoized) proxy method -const roster = loadRoster(); - -export interface Seat { - strategy: CpuStrategy; - state: StrategyState; - engine: any; // raw engine for the p1 seat, transposed proxy for the p0 seat - lastOwnMove: PlayerMove; // most recent move this seat actually played (the other seat's stale peek) -} - -export type GameOutcome = { winnerSeat: 0 | 1 | null; turns: number } | { error: string }; - -/** Called after both seats decided, before the turn executes. `engine`/`battleKey` are the RAW views. */ -export interface TurnHook { - (info: { - turn: number; - engine: any; - battleKey: `0x${string}`; - /** null when that seat does not act this turn (forced-switch flag). */ - p0Move: PlayerMove | null; - p1Move: PlayerMove | null; - seats: [Seat, Seat]; - }): void; -} - -// Reuse chomp's startBattle, then flip the rngOracle to ZERO so the keccak256(p0Salt,p1Salt) inline -// path runs (parity with munch's harness). -function startArenaBattle(ctx: SimContext, teams: [DraftedMon[], DraftedMon[]]): `0x${string}` { - const p0Team = teams[0].map((dm) => buildTeamMon(ctx, roster, dm.id, dm.equip)); - const p1Team = teams[1].map((dm) => buildTeamMon(ctx, roster, dm.id, dm.equip)); - const { battleKey } = startBattle(ctx, p0Team, p1Team); - const engine = ctx.engine as any; - const storageKey = engine._getStorageKey(battleKey); - engine.battleConfig[storageKey].rngOracle = new MemoizedInlineRngOracle(); - return battleKey; -} - -export function playGame( - stratP1: CpuStrategy, stratP0: CpuStrategy, - teams: [DraftedMon[], DraftedMon[]], seed: number, maxTurns: number, - onBeforeExecute?: TurnHook, -): GameOutcome { - const rng = makeRng(seed); - const ctx = makeSimContext({ monsPerTeam: BigInt(TEAM_SIZE) }); - const battleKey = startArenaBattle(ctx, teams); - return runGameLoop(ctx, battleKey, stratP1, stratP0, rng, maxTurns, onBeforeExecute); -} - -function runGameLoop( - ctx: SimContext, - battleKey: `0x${string}`, - stratP1: CpuStrategy, stratP0: CpuStrategy, - rng: () => number, maxTurns: number, - onBeforeExecute?: TurnHook, -): GameOutcome { - const engine = ctx.engine as any; - - const winnerNow = () => Number(engine.battleData[battleKey].winnerIndex); - - const seats: [Seat, Seat] = [ - { strategy: stratP0, state: stratP0.createState(), engine: transposeEngine(engine), lastOwnMove: { moveIndex: 0, extraData: 0 } }, - { strategy: stratP1, state: stratP1.createState(), engine, lastOwnMove: { moveIndex: 0, extraData: 0 } }, - ]; - - for (let t = 0; t < maxTurns; t++) { - const winner = winnerNow(); - if (winner !== 2) return { winnerSeat: winner as 0 | 1, turns: t }; - - const flag = Number(engine.getBattleContext(battleKey).playerSwitchForTurnFlag); - const p0Acts = flag !== 1; - const p1Acts = flag !== 0; - - try { - // p0 seat decides first, peeking only the opponent's previous move. - let p0Move: PlayerMove | null = null; - if (p0Acts) { - const view = captureBattleView(seats[0].engine, battleKey); - p0Move = seats[0].strategy.decide(view, seats[1].lastOwnMove, rng, seats[0].state); - seats[0].lastOwnMove = p0Move; - } - // p1 seat replies with the true reveal (production semantics). - let p1Move: PlayerMove | null = null; - if (p1Acts) { - const view = captureBattleView(seats[1].engine, battleKey); - p1Move = seats[1].strategy.decide(view, p0Move ?? { moveIndex: 0, extraData: 0 }, rng, seats[1].state); - seats[1].lastOwnMove = p1Move; - } - - onBeforeExecute?.({ turn: t, engine, battleKey, p0Move, p1Move, seats }); - - // Salt is drawn ONLY for an acting side, p0 before p1 — matching munch's `toDecision(null)` skip. - const p0Salt = p0Move ? randomSalt(rng) : 0n; - const p1Salt = p1Move ? randomSalt(rng) : 0n; - executeTurn(ctx, battleKey, { - p0MoveIndex: p0Move ? p0Move.moveIndex : NO_OP_INDEX, - p1MoveIndex: p1Move ? p1Move.moveIndex : NO_OP_INDEX, - p0Salt, - p1Salt, - p0ExtraData: BigInt(p0Move ? p0Move.extraData : 0), - p1ExtraData: BigInt(p1Move ? p1Move.extraData : 0), - }); - } catch (e) { - return { error: `turn ${t}: ${(e as Error).stack ?? (e as Error).message}` }; - } - } - - const final = winnerNow(); - if (final !== 2) return { winnerSeat: final as 0 | 1, turns: maxTurns }; - return { winnerSeat: null, turns: maxTurns }; // turn-cap draw (stalemate) -} diff --git a/sims/src/arena/mon-data-core.ts b/sims/src/arena/mon-data-core.ts deleted file mode 100644 index 5811f0e9..00000000 --- a/sims/src/arena/mon-data-core.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Reusable core for the per-mon win-rate + move-usage arena (`mon-data.ts`), factored out so the same - * tally logic runs inline (sequential) or inside a Worker (parallel shard). A work unit is one - * (strategy, seed) pair, which expands to the two seat-swapped games. Records merge by summation, so - * the aggregate is independent of how work units are sharded across workers — parallel output is - * byte-identical to sequential. - */ -import { buildRandomTeam } from './team-builder'; -import { getCpuStrategy } from '../cpu'; -import { MonMetadata } from '../cpu/mon-meta'; -import { activeMonIndices } from '../cpu/engine-view'; -import { makeRng } from './rng'; -import { playGame } from './game'; -import { draftMoveSelection, type DraftedMon } from './team'; - -export interface MonRec { - wins: number; - losses: number; - draws: number; - moveUsed: number[]; // per catalog lane: move-turns this mon chose that move - moveEquipped: number[]; // per catalog lane: drafts that fielded that move - moveTurns: number; // total move-turns for this mon (denominator for used%) - drafts: number; // team-slots this mon was drafted into (denominator for equip%) -} - -export interface ShardResult { - rec: Record; - /** Flat matchup tally: pairWins[a * PAIR_STRIDE + b] = decided games where a mon `a` stood on the - * winning side and a mon `b` on the losing side. Win rate of a vs b = wins[a,b]/(wins[a,b]+wins[b,a]). */ - pairWins: number[]; - totalGames: number; - errors: number; -} - -export interface WorkItem { - strat: string; - seed: number; -} - -export const MON_IDS: number[] = Object.keys(MonMetadata).map(Number); -export const PAIR_STRIDE = Math.max(...MON_IDS) + 1; - -// Catalog length per mon = the number of moves it could field (MonMetadata.moves is the full catalog). -const catalogLen = (id: number): number => (MonMetadata as any)[id].moves.length; - -export function newShardResult(): ShardResult { - const rec: Record = {}; - for (const id of MON_IDS) { - const n = catalogLen(id); - rec[id] = { wins: 0, losses: 0, draws: 0, moveUsed: new Array(n).fill(0), moveEquipped: new Array(n).fill(0), moveTurns: 0, drafts: 0 }; - } - return { rec, pairWins: new Array(PAIR_STRIDE * PAIR_STRIDE).fill(0), totalGames: 0, errors: 0 }; -} - -/** Run one (strategy, seed) unit — the two seat-swapped games — tallying into `res`. */ -export function runPair(stratKey: string, seed: number, maxTurns: number, res: ShardResult): void { - const s = getCpuStrategy(stratKey); - if (!s) throw new Error(`unknown strategy "${stratKey}"`); - const rec = res.rec; - const teamRng = makeRng(seed * 7919 + 17); - const baseIds: [number[], number[]] = [ - buildRandomTeam(teamRng).monIndices.map(Number), - buildRandomTeam(teamRng).monIndices.map(Number), - ]; - // Max-level loadouts come off a SEPARATE stream so the team-draw rng above stays byte-locked to munch. - const moveRng = makeRng(seed * 6151 + 23); - const draft = (ids: number[]): DraftedMon[] => ids.map((id) => ({ id, equip: draftMoveSelection(catalogLen(id), moveRng) })); - const baseTeams: [DraftedMon[], DraftedMon[]] = [draft(baseIds[0]), draft(baseIds[1])]; - // Equip is fixed per drafted slot (unchanged across the seat swap), so count equip stats once here. - for (const side of baseTeams) { - for (const dm of side) { - rec[dm.id].drafts++; - for (const lane of dm.equip) rec[dm.id].moveEquipped[lane]++; - } - } - for (const swap of [false, true]) { - const t: [DraftedMon[], DraftedMon[]] = swap ? [baseTeams[1], baseTeams[0]] : [baseTeams[0], baseTeams[1]]; - const hook = (info: any) => { - const [a0, a1] = activeMonIndices(info.engine, info.battleKey); - const tally = (dm: DraftedMon, moveIndex: number) => { - // Map the played battle slot back to its catalog lane via this draft's equip. - const lane = dm.equip[Math.min(moveIndex, dm.equip.length - 1)]; - rec[dm.id].moveUsed[lane]++; - rec[dm.id].moveTurns++; - }; - if (info.p0Move && info.p0Move.moveIndex < 4) tally(t[0][a0], info.p0Move.moveIndex); - if (info.p1Move && info.p1Move.moveIndex < 4) tally(t[1][a1], info.p1Move.moveIndex); - }; - const out = playGame(s, s, t, seed, maxTurns, hook); - res.totalGames++; - if ('error' in out) { res.errors++; continue; } - const drew = out.winnerSeat === null; - const tally = (id: number, seatWon: boolean) => { - if (drew) rec[id].draws++; - else if (seatWon) rec[id].wins++; - else rec[id].losses++; - }; - for (const id of new Set(t[0].map((d) => d.id))) tally(id, out.winnerSeat === 0); - for (const id of new Set(t[1].map((d) => d.id))) tally(id, out.winnerSeat === 1); - if (!drew) { - const winners = new Set(t[out.winnerSeat === 0 ? 0 : 1].map((d) => d.id)); - const losers = new Set(t[out.winnerSeat === 0 ? 1 : 0].map((d) => d.id)); - for (const w of winners) for (const l of losers) res.pairWins[w * PAIR_STRIDE + l]++; - } - } -} - -/** Run a list of work units into a fresh result (the unit of work a worker processes). */ -export function runItems(items: WorkItem[], maxTurns: number): ShardResult { - const res = newShardResult(); - for (const it of items) runPair(it.strat, it.seed, maxTurns, res); - return res; -} - -/** Fold `src` into `dst` (summation — commutative, so shard order is irrelevant). */ -export function mergeInto(dst: ShardResult, src: ShardResult): void { - dst.totalGames += src.totalGames; - dst.errors += src.errors; - if (src.pairWins) for (let i = 0; i < dst.pairWins.length; i++) dst.pairWins[i] += src.pairWins[i]; - for (const id of MON_IDS) { - const a = dst.rec[id]; - const b = src.rec[id]; - if (!b) continue; - a.wins += b.wins; - a.losses += b.losses; - a.draws += b.draws; - a.moveTurns += b.moveTurns; - a.drafts += b.drafts; - for (let k = 0; k < a.moveUsed.length; k++) { - a.moveUsed[k] += b.moveUsed[k]; - a.moveEquipped[k] += b.moveEquipped[k]; - } - } -} diff --git a/sims/src/arena/mon-data-worker.ts b/sims/src/arena/mon-data-worker.ts deleted file mode 100644 index 19e84cd3..00000000 --- a/sims/src/arena/mon-data-worker.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Worker entry for the parallel arena. Receives a shard of (strategy, seed) work units, runs them, and - * posts back the partial {@link ShardResult}. Each worker has its own module graph (own engine - * container, event stream, fork counter) so shards are fully isolated — no cross-worker shared state. - */ -import { runItems, type ShardResult, type WorkItem } from './mon-data-core'; - -declare var self: Worker; - -self.onmessage = (e: MessageEvent) => { - const { items, maxTurns } = e.data as { items: WorkItem[]; maxTurns: number }; - const res: ShardResult = runItems(items, maxTurns); - postMessage(res); -}; diff --git a/sims/src/arena/mon-data.ts b/sims/src/arena/mon-data.ts deleted file mode 100644 index cabaa2e6..00000000 --- a/sims/src/arena/mon-data.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Per-mon win rate + per-move usage over random 4v4s, seat-swapped to cancel the p1 peek, same strategy - * on both sides. - * - * bun arena/mon-data.ts --strategies hard,greedy --games 300 - * bun arena/mon-data.ts --strategies hard,greedy --games 1000 --workers 8 - * - * Win rate = wins / (wins + losses) for every game a mon was on the team (deduped per team so a - * repeated draft counts once). Move usage = share of that mon's move-turns spent on each catalog move; - * ✗ marks a move equipped but never chosen. Mons treated as max level, so mons with a >4-move catalog - * (the level-6 unlockers) field a random 4-of-N per draft — for those, `[equip N%]` shows how often each - * move made the loadout. - * - * Games are independent and deterministic per (strategy, seed), so the run is sharded across a Worker - * pool (default = CPU count; `--workers 1` forces sequential). Records merge by summation, so parallel - * output is byte-identical to sequential — only wall-time changes. - */ -import { createHash } from 'node:crypto'; -import { appendFileSync, readFileSync } from 'node:fs'; -import { MonMetadata } from '../cpu/mon-meta'; -import { getCpuStrategy } from '../cpu'; -import { newShardResult, mergeInto, runItems, PAIR_STRIDE, type ShardResult, type WorkItem } from './mon-data-core'; - -const args = process.argv.slice(2); -const argVal = (flag: string, def: string) => { - const i = args.indexOf(flag); - return i >= 0 && i + 1 < args.length ? args[i + 1] : def; -}; -const strategies = argVal('--strategies', 'hard,greedy').split(','); -const showMatrix = args.includes('--matrix'); -const games = Number(argVal('--games', '300')); -const maxTurns = Number(argVal('--turns', '150')); -const baseSeed = Number(argVal('--seed', '1')); -const defaultWorkers = Math.max(1, Number(navigator.hardwareConcurrency ?? 4)); -const workers = Math.max(1, Number(argVal('--workers', String(defaultWorkers)))); - -// Override runs measure a scripted hypothesis, so the hypothesis must exist before the run: write -// the prediction into the design doc, then pass it here. Plain screens stay ungated. -const prediction = argVal('--prediction', ''); -if (strategies.includes('override') && !prediction) { - console.error('error: override runs require --prediction "".'); - console.error('Register the prediction in the design doc first (prompting/design-pass-prompt.md).'); - process.exit(1); -} - -// Validate strategies up front so a bad key fails fast (not inside a worker). -for (const strat of strategies) { - if (!getCpuStrategy(strat)) throw new Error(`unknown strategy "${strat}"`); -} - -// Work units: one (strategy, seed) pair = the two seat-swapped games. Flatten across all strategies. -const items: WorkItem[] = []; -const pairs = Math.max(1, Math.floor(games / 2)); -for (const strat of strategies) { - for (let i = 0; i < pairs; i++) items.push({ strat, seed: baseSeed + i }); -} - -function shard(arr: T[], n: number): T[][] { - const out: T[][] = Array.from({ length: n }, () => []); - arr.forEach((v, i) => out[i % n].push(v)); - return out; -} - -async function runParallel(nw: number): Promise { - const shards = shard(items, nw).filter((s) => s.length > 0); - const partials = await Promise.all(shards.map((sd) => new Promise((resolve, reject) => { - const w = new Worker(new URL('./mon-data-worker.ts', import.meta.url).href, { type: 'module' }); - w.onmessage = (e: MessageEvent) => { resolve(e.data as ShardResult); w.terminate(); }; - w.onerror = (err) => { w.terminate(); reject(err); }; - w.postMessage({ items: sd, maxTurns }); - }))); - const agg = newShardResult(); - for (const p of partials) mergeInto(agg, p); - return agg; -} - -const nw = Math.min(workers, items.length); -const res = nw <= 1 ? runItems(items, maxTurns) : await runParallel(nw); -const { rec, totalGames, errors } = res; - -const ids: number[] = Object.keys(MonMetadata).map(Number); -const rows = ids.map((id) => { - const r = rec[id]; - const decided = r.wins + r.losses; - return { id, name: (MonMetadata as any)[id].name as string, wr: decided ? (100 * r.wins) / decided : 0, r }; -}).sort((a, b) => b.wr - a.wr); - -const parallelNote = nw > 1 ? ` workers: ${nw}` : ''; -console.log(`\nGames: ${totalGames} (errors ${errors}) strategies: ${strategies.join('+')} turns<=${maxTurns}${parallelNote}\n`); -console.log('WIN RATE wins/(wins+losses), seat-swapped:'); -for (const row of rows) { - const r = row.r; - console.log(` ${row.name.padEnd(10)} ${row.wr.toFixed(1).padStart(5)}% (W${r.wins}/L${r.losses}/D${r.draws})`); -} -console.log('\nMOVE USAGE used% = share of the mon\'s move-turns; ✗ = equipped but never chosen; [equip N%] = draft rate (rotating catalogs only):'); -for (const row of rows) { - const r = row.r; - const catalog = (MonMetadata as any)[row.id].moves; // full catalog names (may exceed 4 battle slots) - const rotating = catalog.length > 4; // only these vary their loadout per draft - const parts = catalog.map((m: any, lane: number) => { - const equipNote = rotating && r.drafts ? ` [equip ${((100 * r.moveEquipped[lane]) / r.drafts).toFixed(0)}%]` : ''; - if (r.moveUsed[lane] === 0) return `${m.name} ✗${equipNote}`; - return `${m.name} ${r.moveTurns ? ((100 * r.moveUsed[lane]) / r.moveTurns).toFixed(0) : '0'}%${equipNote}`; - }); - console.log(` ${row.name.padEnd(10)} | ${parts.join(' · ')}`); -} - -// Matchup matrix (--matrix): win rate of the row mon's side over decided games where the row and -// column mons stood on opposite sides. This is the instrument behind a design pass's "good at" / -// "checked by" profile claims — prey and walls read straight off the row. -if (showMatrix) { - const wrAB = (a: number, b: number) => { - const w = res.pairWins[a * PAIR_STRIDE + b]; - const l = res.pairWins[b * PAIR_STRIDE + a]; - return { pct: w + l > 0 ? (100 * w) / (w + l) : NaN, n: w + l }; - }; - const nSample = wrAB(rows[0].id, rows[rows.length - 1].id).n; - console.log(`\nMATCHUP MATRIX row mon's win% when row and column mons are on opposite sides (~n=${nSample} per cell; blank diagonal):`); - const header = ' ' + rows.map((r) => r.name.slice(0, 4).padStart(5)).join(''); - console.log(header); - for (const a of rows) { - const cells = rows.map((b) => { - if (a.id === b.id) return ' ·'; - const { pct } = wrAB(a.id, b.id); - return (Number.isNaN(pct) ? '?' : pct.toFixed(0)).padStart(5); - }); - console.log(` ${a.name.padEnd(10)}${cells.join('')}`); - } -} - -// Journal the run so every number a design doc cites traces to a recorded invocation, and so the -// prediction ledger can be rebuilt by joining predictions to results. The override-script hash makes -// a script edit between two runs visible in the record. -const overrideSrc = readFileSync(new URL('../cpu/strategies/override-cpu.ts', import.meta.url), 'utf8'); -const journalEntry = { - ts: new Date().toISOString(), - args, - strategies, - games: totalGames, - seed: baseSeed, - workers: nw, - prediction: prediction || null, - overrideScriptsSha256: createHash('sha256').update(overrideSrc).digest('hex').slice(0, 16), - results: Object.fromEntries( - rows.map((row) => [row.name, { wr: Number(row.wr.toFixed(1)), wins: row.r.wins, losses: row.r.losses, draws: row.r.draws }]), - ), - pairWins: res.pairWins, -}; -appendFileSync(new URL('../../runs.jsonl', import.meta.url), JSON.stringify(journalEntry) + '\n'); -console.log(`\nrun journaled -> sims/runs.jsonl (${journalEntry.ts})`); diff --git a/sims/src/arena/rng-oracle.ts b/sims/src/arena/rng-oracle.ts deleted file mode 100644 index 0bc950e3..00000000 --- a/sims/src/arena/rng-oracle.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { encodeAbiParameters, keccak256 } from 'viem'; - -/** - * Memoized drop-in for the engine's inline (zero-oracle) RNG. - * - * When `config.rngOracle` is the zero address the engine derives each turn's rng inline as - * `keccak256(abi.encode(uint104 p0Salt, uint104 p1Salt))` (Engine.ts). The forward model replays ~10 - * candidate turns per real turn, and in the arena those forks use CONSTANT salts (plain greedy passes - * `0n`), so the identical `(source0, source1)` pair recurs every fork — yet the inline path re-runs the - * full keccak + ABI-encode + hex round-trip each time (~15% of arena CPU per the profile). - * - * Installing this as a NON-zero `rngOracle` routes the engine through `getRNG` instead, which reproduces - * the inline formula EXACTLY (same uint104 masking, same keccak) but caches on the salt pair. Repeated - * fork salts become cache hits; real turns draw fresh 104-bit salts (misses) and compute the identical - * value — so battle outcomes are byte-identical to the inline path. - * - * Must be a CLASS instance, not a plain object: the fork's `cloneState` deep-copies plain objects but - * shares class instances by reference, so a plain-object oracle would be re-copied (and its cache lost) - * on every fork. The cache is module-level so it stays warm across every game in a worker. - */ - -// keccak256(abi.encode(uint104,uint104)) is a pure function of the salt pair, so one process-wide cache -// is valid for every battle/fork. -const rngCache = new Map(); -const MASK_104 = (1n << 104n) - 1n; - -// Any non-zero address works — it only has to differ from the zero address so the engine takes the -// oracle branch rather than the inline branch. -const ORACLE_ADDRESS = '0x0000000000000000000000000000000000005a17'; - -export class MemoizedInlineRngOracle { - _contractAddress = ORACLE_ADDRESS; - - getRNG(source0: string, source1: string): bigint { - const key = source0 + source1; - let v = rngCache.get(key); - if (v === undefined) { - const p0 = BigInt(source0) & MASK_104; - const p1 = BigInt(source1) & MASK_104; - v = BigInt(keccak256(encodeAbiParameters([{ type: 'uint104' }, { type: 'uint104' }], [p0, p1]))); - rngCache.set(key, v); - } - return v; - } -} diff --git a/sims/src/arena/rng.ts b/sims/src/arena/rng.ts deleted file mode 100644 index 1cd65cfe..00000000 --- a/sims/src/arena/rng.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Seeded RNG + salt — verbatim from munch's sim harness so the team draws, battle rng, and per-turn - * salt values reproduce munch's stream exactly (identical seed => identical salts => identical battle RNG). - */ -export function makeRng(seed: number): () => number { - let a = seed >>> 0; - return function () { - a = (a + 0x6d2b79f5) >>> 0; - let t = a; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -export function randomSalt(rng: () => number): bigint { - // 26 hex nibbles -> uint104, matching SignedCommitLib / MonMoves packing. - let s = '0x'; - for (let i = 0; i < 26; i++) s += Math.floor(rng() * 16).toString(16); - return BigInt(s); -} diff --git a/sims/src/arena/team-builder.ts b/sims/src/arena/team-builder.ts deleted file mode 100644 index f8fa6a02..00000000 --- a/sims/src/arena/team-builder.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Self-contained port of munch's `cpu-team-builder.buildRandomTeam`. Reproduces the EXACT rng-draw - * sequence — including the four facet draws (their values are unused here, but each consumes one draw - * off the shared teamRng, so dropping them would desync the second team and break cross-repo parity). - */ -import { loadRoster } from '../util/csv-load'; -import { TEAM_SIZE } from '../cpu/constants'; - -type Rng = () => number; - -const TOTAL_FACETS = 12; -const RANDOM_REPEAT_BIAS = 0.04; - -// Mon ids [0..12] in id order — matches munch's `Object.values(MonMetadata).map(m => BigInt(m.id))`. -const ALL_MON_IDS: readonly bigint[] = loadRoster().mons - .slice() - .sort((a, b) => a.id - b.id) - .map((m) => BigInt(m.id)); - -function randomFacetId(rand: Rng): number { - return 1 + Math.floor(rand() * TOTAL_FACETS); -} - -export function buildRandomTeam(rand: Rng): { monIndices: bigint[] } { - const monIndices: bigint[] = []; - const remaining = ALL_MON_IDS.slice(); - for (let i = 0; i < TEAM_SIZE; i++) { - if (i > 0 && rand() < RANDOM_REPEAT_BIAS) { - monIndices.push(monIndices[Math.floor(rand() * i)]); - } else { - const idx = Math.floor(rand() * remaining.length); - monIndices.push(remaining[idx]); - remaining.splice(idx, 1); - } - } - // Facet draws — value discarded, but the draws must happen to keep the rng stream in lockstep. - for (let i = 0; i < TEAM_SIZE; i++) randomFacetId(rand); - return { monIndices }; -} diff --git a/sims/src/arena/team.ts b/sims/src/arena/team.ts deleted file mode 100644 index 8ea8f87f..00000000 --- a/sims/src/arena/team.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Builds a chomp `Structs.Mon` from a mon id, self-contained for the arena. - * - * Reuses only the SAFE chomp util helpers (`moveNameToContract`, `findInlineMoveJson`, the container), - * but does its OWN type mapping and inline packing so it handles every CSV type name including "Faith" - * (both `mon-builder.ts`'s TYPE_BY_NAME and `inline-pack.ts`'s TYPE_MAP predate the Mythic->Faith enum - * rename and would throw). `typeToEnum` reads the `Type` enum by name directly, so it survives renames. - * `monCatalog` is shared with `mon-meta.ts` so the usage-table labels index the same catalog the draft - * selects a battle loadout from. - */ -import { contracts as CONTRACT_REGISTRY } from '../../../transpiler/ts-output/factories'; -import { Type } from '../../../transpiler/ts-output/Enums'; -import { addressToUint } from '../../../transpiler/ts-output/runtime'; -import { registerMoveInputType, registerMoveTargetSpec } from '../cpu/engine-view'; -import * as Structs from '../../../transpiler/ts-output/Structs'; -import type { SimContext } from '../harness'; -import { moveNameToContract } from '../util/mon-builder'; -import { findInlineMoveJson, type InlineMoveJson } from '../util/inline-pack'; -import type { Roster, MonRow } from '../util/csv-load'; - -const DEFAULT_STAMINA = 5n; - -const CLASS_MAP: Record = { Physical: 0, Special: 1, Self: 2, Other: 3 }; - -function typeToEnum(name: string): number { - if (name === 'NA' || name === '') return Type.None; - const v = (Type as any)[name]; - if (v === undefined) throw new Error(`Unknown type "${name}"`); - return v as number; -} - -function isImplemented(contractName: string): boolean { - return contractName in CONTRACT_REGISTRY; -} - -type ResolvedSlot = - | { kind: 'contract'; contractName: string; name: string; inputType: string; targetSpec: string } - | { kind: 'inline'; json: InlineMoveJson; name: string }; - -/** - * The mon's full implemented move catalog in learnset (CSV) order — every move with a contract or - * inline JSON, uncapped and unpadded. Lanes [0,4) are the level-0 moves; a 5th lane, where present, is - * the mon's level-6 unlock. Treating mons as max level means the whole catalog is selectable; the draft - * then picks which lanes fill the 4 battle slots. Throws if the mon resolves to zero moves. - */ -export function monCatalog(roster: Roster, mon: MonRow): ResolvedSlot[] { - const csvMoves = roster.movesByMon.get(mon.name) ?? []; - const monDir = mon.name.toLowerCase(); - const slots: ResolvedSlot[] = []; - for (const mv of csvMoves) { - const contract = moveNameToContract(mv.name); - if (isImplemented(contract)) { - slots.push({ kind: 'contract', contractName: contract, name: mv.name, inputType: mv.inputType, targetSpec: mv.targetSpec }); - continue; - } - const inlineJson = findInlineMoveJson(monDir, contract); - if (inlineJson) { - slots.push({ kind: 'inline', json: inlineJson, name: mv.name }); - continue; - } - // Unimplemented move — skip (mirrors buildMonConfig). - } - if (slots.length === 0) throw new Error(`mon ${mon.name} has no resolvable moves`); - return slots; -} - -/** A mon drafted into a team slot: its id plus the catalog lanes it fields this draft (`equip`). */ -export interface DraftedMon { - id: number; - equip: number[]; -} - -/** - * Which catalog lanes fill a max-level mon's 4 battle slots. A mon with <=4 catalog moves fields all of - * them (no draw). A larger catalog — the level-6-unlock mons — drops one random lane per draft, so over a - * run every catalog move (including the level-6 move) gets played. Drops preserve ascending order, so - * slot 0 stays the mon's first move. The `rand` here MUST be a stream separate from the team-draw rng — - * that one is byte-locked to munch's for cross-repo team parity. - */ -export function draftMoveSelection(catalogLen: number, rand: () => number): number[] { - const pool = Array.from({ length: catalogLen }, (_, i) => i); - while (pool.length > 4) pool.splice(Math.floor(rand() * pool.length), 1); - return pool; -} - -// Inline-move packing — mirrors sims/src/util/inline-pack.ts:packMove but with a type-robust map. -// Layout: [basePower:8 | moveClass:2 | priority:2 | moveType:4 | stamina:4 | effectAccuracy:8 | ... | effect:160] -function packInlineMove(m: InlineMoveJson, effectAddress: bigint): bigint { - const movClass = CLASS_MAP[m.moveClass as string]; - const movType = typeToEnum(m.moveType as string); - const priority = m.priority ?? 0; - if (movClass === undefined) throw new Error(`Unknown moveClass "${m.moveClass}"`); - let packed = BigInt(m.basePower) << 248n; - packed |= BigInt(movClass) << 246n; - packed |= BigInt(priority) << 244n; - packed |= BigInt(movType) << 240n; - packed |= BigInt(m.staminaCost) << 236n; - packed |= BigInt(m.effectAccuracy) << 228n; - packed |= effectAddress; - return packed; -} - -function resolveContractAddress(ctx: SimContext, name: string): bigint { - const c = ctx.container.resolve(name); - return addressToUint(c._contractAddress); -} - -export function buildTeamMon(ctx: SimContext, roster: Roster, monId: number, equip?: number[]): Structs.Mon { - const mon = roster.mons.find((m) => m.id === monId); - if (!mon) throw new Error(`no mon with id ${monId}`); - const catalog = monCatalog(roster, mon); - // Default loadout = the first up-to-4 catalog lanes (the level-0 moves); callers pass `equip` to field - // a specific max-level 4-of-N selection. Engine fields exactly 4 lanes, so pad a short loadout by - // repeating the last (duplicate lanes are inert). - const lanes = equip ?? Array.from({ length: Math.min(4, catalog.length) }, (_, i) => i); - const slots = lanes.map((i) => catalog[i]); - while (slots.length < 4) slots.push(slots[slots.length - 1]); - - const moves = slots.map((s) => { - if (s.kind === 'contract') { - const addr = resolveContractAddress(ctx, s.contractName); - // Address → InputType / TargetSpec registries (the off-chain replacement for the removed - // on-chain ExtraDataType) — CPU enumeration consults them for payload/slot targeting. - registerMoveInputType(addr, s.inputType); - registerMoveTargetSpec(addr, s.targetSpec); - return addr; - } - const effectAddr = s.json.effect ? resolveContractAddress(ctx, s.json.effect) : 0n; - return packInlineMove(s.json, effectAddr); - }); - - const ability = roster.abilityByMon.get(mon.name); - const abilityContract = ability ? moveNameToContract(ability.name) : null; - const abilitySlot = abilityContract && isImplemented(abilityContract) - ? resolveContractAddress(ctx, abilityContract) - : 0n; - - return { - stats: { - hp: BigInt(mon.hp), - stamina: DEFAULT_STAMINA, - speed: BigInt(mon.speed), - attack: BigInt(mon.attack), - defense: BigInt(mon.defense), - specialAttack: BigInt(mon.specialAttack), - specialDefense: BigInt(mon.specialDefense), - type1: typeToEnum(mon.type1), - type2: typeToEnum(mon.type2), - }, - moves, - ability: abilitySlot, - }; -} diff --git a/sims/src/arena/transpose.ts b/sims/src/arena/transpose.ts deleted file mode 100644 index 2c0d85a8..00000000 --- a/sims/src/arena/transpose.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Transposed engine view for the CPU arena. - * - * Every CPU strategy hardcodes the production convention "the CPU is p1, the - * opponent is p0". To let a strategy play the p0 seat of a real battle, we hand - * it a Proxy of the engine that flips the playerIndex argument (0n <-> 1n) on - * the strategy-facing read surface, so the seat-0 strategy sees itself as p1. - * - * Scope: this transposes exactly the methods the strategies + their helpers - * (engine-view / heuristic-shared / battle-view / forward-model) reach, plus - * the forward-model's fork submission hooks. Engine-internal self-calls are - * unaffected (wrapped methods are applied to the raw target). A known hole: - * an EXTERNAL move whose getMeta/basePower reads battle state through a - * captured engine singleton (rather than the engine argument it receives) - * would bypass the proxy — no current move does this; inline moves decode - * purely from the packed slot. - */ - -// Methods whose playerIndex argument(s) get flipped, by argument position. -const FLIP_ARG_POSITIONS: Record = { - getMonStatsForBattle: [1], - getMonStateForBattle: [1], - getMonValueForBattle: [1], - getMoveForMonForBattle: [1], - getKOBitmap: [1], - getTeamSize: [1], - getMoveDecisionForBattleState: [1], - validatePlayerMoveForBattle: [2], - getDamageCalcContext: [1, 3], // (bk, atkPlayer, atkMon, defPlayer, defMon) — player args at 1 & 3 - // forward-model fork submission: _setMoveInternal(config, playerIndex, ...) - _setMoveInternal: [1], -}; - -// Methods whose names swap with each other (forward-model turn transients). -const SWAP_NAMES: Record = { - __mutate_turnP0Packed: '__mutate_turnP1Packed', - __mutate_turnP1Packed: '__mutate_turnP0Packed', -}; - -function flipPlayerIndex(v: unknown): unknown { - if (v === 0n) return 1n; - if (v === 1n) return 0n; - if (v === 0) return 1; - if (v === 1) return 0; - return v; -} - -/** Wrap `engine` so playerIndex-sensitive reads are seen from the p0 seat as if it were p1. */ -export function transposeEngine(engine: any): any { - const wrapped = new Map(); - return new Proxy(engine, { - get(target, prop) { - if (wrapped.has(prop)) return wrapped.get(prop); - const name = String(prop); - const swapped = SWAP_NAMES[name]; - const val = target[swapped ?? name]; - if (typeof val !== 'function') return val; - - let out: any; - if (name in FLIP_ARG_POSITIONS) { - const positions = FLIP_ARG_POSITIONS[name]; - out = (...args: any[]) => { - for (const p of positions) args[p] = flipPlayerIndex(args[p]); - return val.apply(target, args); - }; - } else if (name === 'getActiveMonIndexForBattleState') { - out = (...args: any[]) => { - const r = val.apply(target, args); - return [r[1], r[0]]; - }; - } else if (name === 'getBattleContext') { - out = (...args: any[]) => { - const ctx = val.apply(target, args); - const flag = Number(ctx.playerSwitchForTurnFlag); - if (flag === 0 || flag === 1) { - return { ...ctx, playerSwitchForTurnFlag: BigInt(1 - flag) }; - } - return ctx; - }; - } else { - out = val.bind(target); - } - wrapped.set(prop, out); - return out; - }, - }); -} diff --git a/sims/src/arena/walk.ts b/sims/src/arena/walk.ts deleted file mode 100644 index 960d89c8..00000000 --- a/sims/src/arena/walk.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Exact-1v1 walk runner — plays a scripted board deterministically so the consequence walks in the - * design docs can be machine-checked turn by turn. No CPU anywhere; both sides' actions come from - * the script, so there is no peek or prediction confound. - * - * bun arena/walk.ts --p0 gorillax --p1 malalien,sofabbi \ - * --script "p0:PoundGround p1:rest; p0:RockPull p1:switch:1" \ - * --expect "t2 p1.0.ko == 1; t2 p1.active == 1" - * - * Script: turns separated by ';', each turn gives both sides' actions. An action is a move name - * (case/space-insensitive, resolved against the active mon's four slots), a slot number 0-3, - * `switch:`, or `rest`. Targeted moves take extra data with `@`: `HitAndDip@1`. - * Expectations: "tN pS.. " or "tN pS.active == ", checked - * after turn N. Any failed expectation exits 3. Every run journals to sims/runs.jsonl. - */ -import { appendFileSync } from 'node:fs'; -import { makeSimContext, startBattle, executeTurn, type TurnInput, type TurnSnapshot } from '../harness'; -import { loadRoster } from '../util/csv-load'; -import { buildTeamMon } from './team'; -import { MemoizedInlineRngOracle } from './rng-oracle'; -import * as Constants from '../../../transpiler/ts-output/Constants'; - -const args = process.argv.slice(2); -const argVal = (flag: string, def: string) => { - const i = args.indexOf(flag); - return i >= 0 && i + 1 < args.length ? args[i + 1] : def; -}; - -const roster = loadRoster(); -const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); - -function monByName(name: string) { - const m = roster.mons.find((r: any) => norm(r.name) === norm(name)); - if (!m) throw new Error(`unknown mon "${name}" — roster: ${roster.mons.map((r: any) => r.name).join(', ')}`); - return m; -} - -const p0Names = argVal('--p0', ''); -const p1Names = argVal('--p1', ''); -const script = argVal('--script', ''); -const expectArg = argVal('--expect', ''); -const seed = Number(argVal('--seed', '1')); -if (!p0Names || !p1Names || !script) { - console.error('usage: bun arena/walk.ts --p0 --p1 --script "p0: p1:; ..." [--expect "..."] [--seed N]'); - process.exit(1); -} - -const p0Mons = p0Names.split(',').map(monByName); -const p1Mons = p1Names.split(',').map(monByName); -const ctx = makeSimContext({ monsPerTeam: BigInt(Math.max(p0Mons.length, p1Mons.length)) }); -const p0Team = p0Mons.map((m: any) => buildTeamMon(ctx, roster, m.id)); -const p1Team = p1Mons.map((m: any) => buildTeamMon(ctx, roster, m.id)); -const { battleKey } = startBattle(ctx, p0Team, p1Team); -// Same oracle flip as the arena, so the keccak(p0Salt, p1Salt) inline rng path runs. -const engine = ctx.engine as any; -engine.battleConfig[engine._getStorageKey(battleKey)].rngOracle = new MemoizedInlineRngOracle(); - -// The engine's first turn is lead selection (both sides must send a mon out), so the runner plays -// it implicitly — scripted turns then start on the first real move turn. Override with --leads. -const leads = argVal('--leads', '0,0').split(',').map(Number); -executeTurn(ctx, battleKey, { - p0MoveIndex: Number(Constants.SWITCH_MOVE_INDEX), - p1MoveIndex: Number(Constants.SWITCH_MOVE_INDEX), - p0ExtraData: BigInt(leads[0]), - p1ExtraData: BigInt(leads[1]), - p0Salt: 1n, - p1Salt: 2n, -}); -console.log(`leads: p0 ${p0Mons[leads[0]].name}, p1 ${p1Mons[leads[1]].name}`); - -interface Action { index: number; extra: bigint; label: string } - -function parseAction(token: string, sideMons: any[], activeIdx: number): Action { - const [head, extraStr] = token.split('@'); - const extra = extraStr !== undefined ? BigInt(extraStr) : 0n; - if (head === 'rest') return { index: Number(Constants.NO_OP_MOVE_INDEX), extra, label: 'rest' }; - if (head.startsWith('switch:')) { - const target = head.slice('switch:'.length); - return { index: Number(Constants.SWITCH_MOVE_INDEX), extra: BigInt(target), label: `switch:${target}` }; - } - if (/^[0-3]$/.test(head)) return { index: Number(head), extra, label: `slot ${head}` }; - const active = sideMons[activeIdx]; - const slots = (roster.movesByMon.get(active.name) ?? []).slice(0, 4); - const slot = slots.findIndex((mv: any) => norm(mv.name) === norm(head)); - if (slot < 0) { - throw new Error(`"${head}" is not a move of ${active.name} — slots: ${slots.map((mv: any) => mv.name).join(', ')}`); - } - return { index: slot, extra, label: `${slots[slot].name}` }; -} - -interface Expect { turn: number; side: 0 | 1; slot: string; field: string; op: string; value: number; raw: string } - -function parseExpects(text: string): Expect[] { - if (!text.trim()) return []; - return text.split(';').map((raw) => { - const m = raw.trim().match(/^t(\d+)\s+p([01])\.(\d+|active)(?:\.(hp|stamina|ko))?\s*(<=|>=|==|<|>)\s*(-?\d+)$/); - if (!m) throw new Error(`bad expectation "${raw.trim()}" — form: tN pS.. or tN pS.active == `); - return { turn: Number(m[1]), side: Number(m[2]) as 0 | 1, slot: m[3], field: m[4] ?? '', op: m[5], value: Number(m[6]), raw: raw.trim() }; - }); -} - -function evalExpect(e: Expect, snap: TurnSnapshot, teams: [any[], any[]]): { ok: boolean; actual: number } { - let actual: number; - if (e.slot === 'active') { - actual = e.side === 0 ? snap.p0Active : snap.p1Active; - } else { - const i = Number(e.slot); - const st = (e.side === 0 ? snap.p0States : snap.p1States)[i]; - const base = teams[e.side][i]; - if (e.field === 'hp') actual = Number(BigInt(base.stats.hp) + st.hpDelta); - else if (e.field === 'stamina') actual = Number(BigInt(base.stats.stamina) + st.staminaDelta); - else actual = st.isKnockedOut ? 1 : 0; - } - const ok = - (e.op === '<=' && actual <= e.value) || (e.op === '>=' && actual >= e.value) || - (e.op === '==' && actual === e.value) || (e.op === '<' && actual < e.value) || - (e.op === '>' && actual > e.value); - return { ok, actual }; -} - -function printState(turn: number, snap: TurnSnapshot) { - const line = (side: 0 | 1) => { - const mons = side === 0 ? p0Mons : p1Mons; - const states = side === 0 ? snap.p0States : snap.p1States; - const active = side === 0 ? snap.p0Active : snap.p1Active; - return states.map((st, i) => { - const base: any = (side === 0 ? p0Team : p1Team)[i]; - const hp = BigInt(base.stats.hp) + st.hpDelta; - const stam = BigInt(base.stats.stamina) + st.staminaDelta; - const mark = st.isKnockedOut ? ' KO' : i === active ? ' *' : ''; - return `${mons[i].name} ${hp}/${base.stats.hp} s${stam}${mark}`; - }).join(' | '); - }; - console.log(`t${turn} p0: ${line(0)}`); - console.log(` p1: ${line(1)}`); -} - -const expects = parseExpects(expectArg); -const turns = script.split(';').map((t) => t.trim()).filter(Boolean); -const failures: string[] = []; -const turnLog: any[] = []; -let p0Active = 0; -let p1Active = 0; - -for (let t = 1; t <= turns.length; t++) { - const parts = Object.fromEntries( - turns[t - 1].split(/\s+/).map((tok) => { - const m = tok.match(/^p([01]):(.+)$/); - if (!m) throw new Error(`bad script token "${tok}" in turn ${t} — expected p0: p1:`); - return [m[1], m[2]]; - }), - ); - if (!(('0' in parts) && ('1' in parts))) throw new Error(`turn ${t} needs both p0: and p1: actions`); - const a0 = parseAction(parts['0'], p0Mons, p0Active); - const a1 = parseAction(parts['1'], p1Mons, p1Active); - const input: TurnInput = { - p0MoveIndex: a0.index, - p1MoveIndex: a1.index, - p0ExtraData: a0.extra, - p1ExtraData: a1.extra, - p0Salt: BigInt(seed * 7919 + t * 2), - p1Salt: BigInt(seed * 104729 + t * 2 + 1), - }; - const flag = Number(engine.getBattleContext(battleKey).playerSwitchForTurnFlag); - const forced = flag === 0 ? ' (forced: only p0 acts)' : flag === 1 ? ' (forced: only p1 acts)' : ''; - console.log(`\n== turn ${t}: p0 ${a0.label} / p1 ${a1.label}${forced}`); - const snap = executeTurn(ctx, battleKey, input); - p0Active = snap.p0Active; - p1Active = snap.p1Active; - printState(t, snap); - if (snap.winnerIndex !== 2n) console.log(` winner: p${snap.winnerIndex}`); - turnLog.push({ t, p0: a0.label, p1: a1.label, p0Active, p1Active }); - for (const e of expects.filter((x) => x.turn === t)) { - const { ok, actual } = evalExpect(e, snap, [p0Team as any[], p1Team as any[]]); - console.log(` expect ${e.raw} -> ${ok ? 'PASS' : `FAIL (actual ${actual})`}`); - if (!ok) failures.push(`${e.raw} (actual ${actual})`); - } -} - -const journalEntry = { - ts: new Date().toISOString(), - kind: 'walk', - args, - script, - expects: expectArg || null, - turns: turnLog, - failures, -}; -appendFileSync(new URL('../../runs.jsonl', import.meta.url), JSON.stringify(journalEntry) + '\n'); -console.log(`\n${failures.length === 0 ? 'all expectations passed' : `${failures.length} expectation(s) FAILED`} — journaled -> sims/runs.jsonl`); -process.exit(failures.length === 0 ? 0 : 3); diff --git a/sims/src/cpu/battle-view.ts b/sims/src/cpu/battle-view.ts deleted file mode 100644 index e8147851..00000000 --- a/sims/src/cpu/battle-view.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Hex } from './hex'; -import { MonStateIndexName, Type } from '../../../transpiler/ts-output/Enums'; -import { - cpuActiveMonIndex, - oppActiveMonIndex, - cpuTeamSize, - oppTeamSize, - koBitmap, - monMaxHp, - monHpDelta, - monBaseStamina, - monStaminaDelta, - monTypes, -} from './engine-view'; - -/** - * SEARCH SUBSTRATE — layer 1: a read-once snapshot of a battle position. - * - * `captureBattleView` pulls the frequently-read position fields off the live (or forked) engine in one - * pass, reusing the faithful `engine-view.ts` readers (so HP / stamina / types / KO bitmaps match what - * the ported CPUs trust). It is a thin projection the forward model + evaluator consume without - * re-touching the engine. It deliberately does NOT enumerate valid moves: the ports call - * `calculateValidMoves` themselves with their own rng for stream parity, so a snapshot copy would be - * unused — they read the few scalars here off the view and enumerate candidates live. - * - * Convention (inherited from the CPU ports): the CPU is ALWAYS p1, the human opponent is p0. - * - `cpu*` fields read p1 (playerIndex 1). - * - `opp*` fields read p0 (playerIndex 0). - * - `mons.p0` / `mons.p1` carry the per-slot snapshot for each side. - */ - -const CPU_PLAYER_INDEX = 1n; -const OPP_PLAYER_INDEX = 0n; - -/** Per-slot snapshot. `hp` / `stamina` are CURRENT values (base + delta). */ -export interface MonView { - /** Current HP (base + delta). */ - hp: number; - /** Base (max) HP — lets consumers compute hp% without re-reading the engine. */ - maxHp: number; - /** Current stamina (base + delta). */ - stamina: number; - type1: Type; - type2: Type; - ko: boolean; - /** - * Net stat-stage position: Σ delta/base over the five combat stats (atk/def/spatk/spdef/speed). - * Setup boosts land positive; burn's attack divide / frostbite's spatk divide land negative. - */ - statDeltaScore: number; - /** ShouldSkipTurn flag (zap) — the mon loses its next action. */ - skipTurn: boolean; -} - -export interface BattleView { - engine: any; - bk: string; - /** playerSwitchForTurnFlag: 0 = p0-only, 1 = p1-only (CPU forced switch), 2 = both move. */ - switchFlag: number; - cpuActive: number; - oppActive: number; - /** KO bitmaps (bit i set => slot i is KO'd). */ - cpuKO: number; - oppKO: number; - // Team sizes are just `mons.p1.length` / `mons.p0.length` — read them off the arrays, not a field. - mons: { p0: MonView[]; p1: MonView[] }; -} - -// Σ delta/base over the five combat stats. The getters normalize the cleared sentinel to 0, so a -// switched-out mon's score reads 0 without special-casing. -function readStatDeltaScore(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - const stats = e.getMonStatsForBattle(bk, playerIndex, BigInt(monIndex)); - const mi = BigInt(monIndex); - let score = 0; - const add = (base: bigint, stat: MonStateIndexName) => { - const b = Number(base); - if (b <= 0) return; - score += Number(e.getMonStateForBattle(bk, playerIndex, mi, stat)) / b; - }; - add(stats.attack, MonStateIndexName.Attack); - add(stats.defense, MonStateIndexName.Defense); - add(stats.specialAttack, MonStateIndexName.SpecialAttack); - add(stats.specialDefense, MonStateIndexName.SpecialDefense); - add(stats.speed, MonStateIndexName.Speed); - return score; -} - -// Per-slot snapshot. `hp` / `maxHp` / `ko` are read eagerly (every consumer uses HP, and the fork that -// produced this view may be disposed after scoring). The four heavier fields — `stamina`, the type -// pair, `statDeltaScore` (5 stat reads + a stats load), `skipTurn` — are LAZY: computed on first -// access and cached. A position evaluator only reads them for the two ACTIVE mons, so the ~6 bench -// slots per view never pay for them. Exact: every consumer touches these before its fork is disposed -// (scoring precedes `disposeFork`), and fork/child views are only ever read for eager `.hp`. -class LazyMonView implements MonView { - readonly hp: number; - readonly maxHp: number; - readonly ko: boolean; - private _stamina?: number; - private _types?: [Type, Type]; - private _statDeltaScore?: number; - private _skipTurn?: boolean; - - constructor( - private readonly e: any, - private readonly bk: Hex, - private readonly pi: bigint, - private readonly mi: number, - ko: boolean, - ) { - this.ko = ko; - this.maxHp = monMaxHp(e, bk, pi, mi); - this.hp = this.maxHp + monHpDelta(e, bk, pi, mi); - } - - get stamina(): number { - if (this._stamina === undefined) { - this._stamina = monBaseStamina(this.e, this.bk, this.pi, this.mi) + monStaminaDelta(this.e, this.bk, this.pi, this.mi); - } - return this._stamina; - } - get type1(): Type { return this.typePair()[0]; } - get type2(): Type { return this.typePair()[1]; } - private typePair(): [Type, Type] { - if (this._types === undefined) this._types = monTypes(this.e, this.bk, this.pi, this.mi); - return this._types; - } - get statDeltaScore(): number { - if (this._statDeltaScore === undefined) this._statDeltaScore = readStatDeltaScore(this.e, this.bk, this.pi, this.mi); - return this._statDeltaScore; - } - get skipTurn(): boolean { - if (this._skipTurn === undefined) { - this._skipTurn = Number(this.e.getMonStateForBattle(this.bk, this.pi, BigInt(this.mi), MonStateIndexName.ShouldSkipTurn)) !== 0; - } - return this._skipTurn; - } -} - -function readSide(e: any, bk: Hex, playerIndex: bigint, size: number): MonView[] { - const ko = koBitmap(e, bk, playerIndex); - const out: MonView[] = []; - for (let i = 0; i < size; i++) { - out.push(new LazyMonView(e, bk, playerIndex, i, (ko & (1 << i)) !== 0)); - } - return out; -} - -/** Read-once snapshot of the position at `bk`, populated entirely from the engine-view readers. */ -export function captureBattleView(e: any, bk: string): BattleView { - const key = bk as Hex; - const p0Size = oppTeamSize(e, key); - const p1Size = cpuTeamSize(e, key); - return { - engine: e, - bk, - switchFlag: Number(e.getBattleContext(key).playerSwitchForTurnFlag), - cpuActive: cpuActiveMonIndex(e, key), - oppActive: oppActiveMonIndex(e, key), - cpuKO: koBitmap(e, key, CPU_PLAYER_INDEX), - oppKO: koBitmap(e, key, OPP_PLAYER_INDEX), - mons: { - p0: readSide(e, key, OPP_PLAYER_INDEX, p0Size), - p1: readSide(e, key, CPU_PLAYER_INDEX, p1Size), - }, - }; -} diff --git a/sims/src/cpu/constants.ts b/sims/src/cpu/constants.ts deleted file mode 100644 index e0b4c029..00000000 --- a/sims/src/cpu/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Subset of munch's src/app/types/constants.ts that the cpu stack + arena import. -export const NO_OP_INDEX = 126; -export const SWITCH_MOVE_INDEX = 125; -export const TEAM_SIZE = 4; diff --git a/sims/src/cpu/doubles-search.ts b/sims/src/cpu/doubles-search.ts deleted file mode 100644 index 7ae229cb..00000000 --- a/sims/src/cpu/doubles-search.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * DOUBLES SEARCH — TS port of the Rust doubles joint maximin (`strategies-rs/src/doubles.rs`, - * search half) plus the greedy pilot it benchmarks against. - * - * Doubles differs from singles in three ways handled here: two active mons per side at absolute - * slots 0-3 (side = absSlot >> 1), a move targets a SLOT via extraData's target nibble (bits - * 12-15), and `playerSwitchForTurnFlag` is a bitmask of which absolute slots must forced-switch - * (not the singles 0/1/2). - * - * Per-slot candidates: TOP-damage (move,target) options with per-target diversity (the best - * option against EACH live enemy slot is always kept), nibble-free status/setup moves - * (self-only / none TargetSpec), a pivot switch, and rest — bench/rest never truncated away. - * Joint actions are the slot-candidate cartesian product; forced half-turns resolve without - * consuming depth; terminal values are mate-distance discounted; the row prune is - * argmax-invariant (doubles enumeration draws no rng at all). - */ -import { Hex } from './hex'; -import { MoveClass, MonStateIndexName } from '../../../transpiler/ts-output/Enums'; -import { moveSlotLib } from '../../../transpiler/ts-output/moves/MoveSlotLib'; -import { buildDamageCalcContext, estimateDamageMeta } from './heuristic-shared'; -import { koBitmap, monCurrentHp, monCurrentStamina, moveSlot, moveInputTypeOf, moveTargetSpecOf } from './engine-view'; -import { applyHypotheticalSlotMoveKeyed, disposeFork, packSide } from './forward-model'; - -const SWITCH = 125; -const NO_OP = 126; -const EMPTY_LANE = 0xff; -const MAX_DAMAGING = 3; -const MAX_STATUS = 2; -const WIN = 1e9; -const LOSS = -1e9; -// Linear 2-slot evaluator weights (mirrors the Rust doubles_eval). -const D_W_HP = 1.0; -const D_W_KO = 150.0; -const D_W_STAMINA = 2.0; - -export interface SlotMove { - moveIndex: number; - extraData: number; -} -const NO_OP_MOVE: SlotMove = { moveIndex: NO_OP, extraData: 0 }; -const sw = (i: number): SlotMove => ({ moveIndex: SWITCH, extraData: i }); - -/** The two absolute slots owned by a side (0 → [0,1], 1 → [2,3]). */ -const sideSlots = (side: 0 | 1): [number, number] => (side === 0 ? [0, 1] : [2, 3]); - -/** Target nibble: a bitmask with bit `absSlot` set, in extraData bits 12-15. */ -const targetBits = (absSlot: number): number => 1 << (12 + absSlot); - -function activeSlots(e: any, bk: Hex): number[] { - return (e.getActiveSlots(bk) as bigint[]).map((s) => Number(s)); -} - -function teamSizeOf(e: any, bk: Hex, side: 0 | 1): number { - return Number(e.getTeamSize(bk, BigInt(side))); -} - -function monMaxHp(e: any, bk: Hex, side: 0 | 1, mon: number): number { - return Number(e.getMonValueForBattle(bk, BigInt(side), BigInt(mon), MonStateIndexName.Hp)); -} - -/** Every affordable damaging (move, target-slot) for `myMon` on `side`, with estimated damage. */ -function damagingOptions(e: any, bk: Hex, side: 0 | 1, myMon: number): Array<{ sm: SlotMove; dmg: number }> { - const oppSide = (1 - side) as 0 | 1; - const slots = activeSlots(e, bk); - const oppKo = koBitmap(e, bk, BigInt(oppSide)); - - const targets: Array<{ abs: number; mon: number }> = []; - for (const abs of sideSlots(oppSide)) { - const mon = slots[abs]; - if (mon !== EMPTY_LANE && (oppKo & (1 << mon)) === 0) targets.push({ abs, mon }); - } - if (targets.length === 0) return []; - - const stamina = monCurrentStamina(e, bk, BigInt(side), myMon); - const metas: Array<{ mi: number; meta: any }> = []; - for (let mi = 0; mi < 4; mi++) { - const slot = moveSlot(e, bk, BigInt(side), myMon, mi); - if (slot === undefined) break; - metas.push({ mi, meta: moveSlotLib.decodeMeta(slot, e, bk, BigInt(side), BigInt(myMon)) }); - } - - const options: Array<{ sm: SlotMove; dmg: number }> = []; - for (const t of targets) { - const ctx = buildDamageCalcContext(e, bk, BigInt(side), myMon, BigInt(oppSide), t.mon); - for (const { mi, meta } of metas) { - if (Number(meta.stamina) > stamina) continue; - const cls = Number(meta.moveClass); - if (cls !== Number(MoveClass.Physical) && cls !== Number(MoveClass.Special)) continue; - const dmg = estimateDamageMeta(ctx, meta); - if (dmg > 0) options.push({ sm: { moveIndex: mi, extraData: targetBits(t.abs) }, dmg }); - } - } - return options; -} - -/** All legal bench targets for a slot: non-KO roster mons not held by either of the side's slots. */ -function legalBenches(teamSize: number, ko: number, slots: number[], absSlot: number): number[] { - const side = absSlot >> 1; - const allyAbs = side * 2 + (1 - (absSlot & 1)); - const out: number[] = []; - for (let i = 0; i < teamSize; i++) { - if ((ko & (1 << i)) === 0 && i !== slots[allyAbs] && i !== slots[absSlot]) out.push(i); - } - return out; -} - -function firstLegalBench(teamSize: number, ko: number, slots: number[], absSlot: number): number { - const b = legalBenches(teamSize, ko, slots, absSlot); - return b.length ? b[0] : -1; -} - -/** Per-slot candidates on a normal turn (see module doc). Empty lane → rest only. */ -function slotCandidates(e: any, bk: Hex, side: 0 | 1, absSlot: number, slots: number[], teamSize: number): SlotMove[] { - const myMon = slots[absSlot]; - if (myMon === EMPTY_LANE) return [NO_OP_MOVE]; - - const dmg = damagingOptions(e, bk, side, myMon).sort((a, b) => b.dmg - a.dmg); - // Best option per distinct target first (≤2 live slots), then next-best overall. - const out: SlotMove[] = []; - const seenTargets = new Set(); - for (const o of dmg) { - if (!seenTargets.has(o.sm.extraData)) { - seenTargets.add(o.sm.extraData); - out.push(o.sm); - } - } - for (const o of dmg) { - if (out.length >= MAX_DAMAGING) break; - if (!out.some((s) => s.moveIndex === o.sm.moveIndex && s.extraData === o.sm.extraData)) out.push(o.sm); - } - out.length = Math.min(out.length, MAX_DAMAGING); - - // Status/setup moves (non-damaging class, affordable, nibble-free targeting): extraData 0. - const stamina = monCurrentStamina(e, bk, BigInt(side), myMon); - let nStatus = 0; - for (let mi = 0; mi < 4 && nStatus < MAX_STATUS; mi++) { - const slot = moveSlot(e, bk, BigInt(side), myMon, mi); - if (slot === undefined) break; - if (moveSlotLib.isInline(slot)) continue; // inline words are standard attacks - const meta = moveSlotLib.decodeMeta(slot, e, bk, BigInt(side), BigInt(myMon)); - const cls = Number(meta.moveClass); - if (cls === Number(MoveClass.Physical) || cls === Number(MoveClass.Special)) continue; - if (Number(meta.stamina) > stamina) continue; - const spec = moveTargetSpecOf(slot); - if ((spec === 'self-only' || spec === 'none') && moveInputTypeOf(slot) === 'none') { - out.push({ moveIndex: mi, extraData: 0 }); - nStatus++; - } - } - - const bench = firstLegalBench(teamSize, koBitmap(e, bk, BigInt(side)), slots, absSlot); - if (bench >= 0) out.push(sw(bench)); - out.push(NO_OP_MOVE); - return out; -} - -/** Joint (slot0, slot1) actions for a side on a normal turn. */ -function sideJoint(e: any, bk: Hex, side: 0 | 1): Array<[SlotMove, SlotMove]> { - const [a0, a1] = sideSlots(side); - const slots = activeSlots(e, bk); - const ts = teamSizeOf(e, bk, side); - const c0 = slotCandidates(e, bk, side, a0, slots, ts); - const c1 = slotCandidates(e, bk, side, a1, slots, ts); - const out: Array<[SlotMove, SlotMove]> = []; - for (const m0 of c0) for (const m1 of c1) out.push([m0, m1]); - return out; -} - -/** Fork one slot-turn from `bk`: `mine` = cpuSide's joint, `theirs` = the opponent's. */ -function forkJoint(e: any, bk: Hex, cpuSide: 0 | 1, mine: [SlotMove, SlotMove], theirs: [SlotMove, SlotMove]): Hex { - const myWord = packSide(mine[0].moveIndex, mine[0].extraData, mine[1].moveIndex, mine[1].extraData, 0n); - const thWord = packSide(theirs[0].moveIndex, theirs[0].extraData, theirs[1].moveIndex, theirs[1].extraData, 0n); - const [s0, s1] = cpuSide === 0 ? [myWord, thWord] : [thWord, myWord]; - return applyHypotheticalSlotMoveKeyed(e, bk, s0, s1); -} - -/** Σ hp% over a side's whole roster. */ -function sideRosterHp(e: any, bk: Hex, side: 0 | 1, teamSize: number): number { - let sum = 0; - for (let i = 0; i < teamSize; i++) { - const mhp = monMaxHp(e, bk, side, i); - if (mhp <= 0) continue; - sum = sum + (Math.max(0, monCurrentHp(e, bk, BigInt(side), i)) * 100) / mhp; - } - return sum; -} - -function sideActiveStamina(e: any, bk: Hex, side: 0 | 1, slots: number[]): number { - let sum = 0; - for (const abs of sideSlots(side)) { - const mon = slots[abs]; - if (mon !== EMPTY_LANE) sum += monCurrentStamina(e, bk, BigInt(side), mon); - } - return sum; -} - -const popcount = (b: number): number => { - let c = 0; - for (let x = b; x; x &= x - 1) c++; - return c; -}; - -/** Linear 2-slot position value, `cpuSide`-perspective (higher = better). */ -function doublesEval(e: any, bk: Hex, cpuSide: 0 | 1): number { - const oppSide = (1 - cpuSide) as 0 | 1; - const hp = sideRosterHp(e, bk, cpuSide, teamSizeOf(e, bk, cpuSide)) - sideRosterHp(e, bk, oppSide, teamSizeOf(e, bk, oppSide)); - const ko = popcount(koBitmap(e, bk, BigInt(oppSide))) - popcount(koBitmap(e, bk, BigInt(cpuSide))); - const slots = activeSlots(e, bk); - const stam = sideActiveStamina(e, bk, cpuSide, slots) - sideActiveStamina(e, bk, oppSide, slots); - return D_W_HP * hp + D_W_KO * ko + D_W_STAMINA * stam; -} - -/** Terminal value if a side is fully KO'd (mate-distance discounted), else null. */ -function terminal(e: any, bk: Hex, cpuSide: 0 | 1, depth: number): number | null { - const oppSide = (1 - cpuSide) as 0 | 1; - if (popcount(koBitmap(e, bk, BigInt(oppSide))) >= teamSizeOf(e, bk, oppSide)) return WIN + depth; - if (popcount(koBitmap(e, bk, BigInt(cpuSide))) >= teamSizeOf(e, bk, cpuSide)) return LOSS - depth; - return null; -} - -/** A side's deterministic forced-switch resolution (first-legal bench per masked slot). */ -function forcedJointModel(e: any, bk: Hex, side: 0 | 1, mask: number, slots: number[]): [SlotMove, SlotMove] { - const ts = teamSizeOf(e, bk, side); - const ko = koBitmap(e, bk, BigInt(side)); - const pick = (abs: number): SlotMove => { - if ((mask & (1 << abs)) === 0) return NO_OP_MOVE; - const b = firstLegalBench(ts, ko, slots, abs); - return b >= 0 ? sw(b) : NO_OP_MOVE; - }; - const [a0, a1] = sideSlots(side); - return [pick(a0), pick(a1)]; -} - -const isNoOpJoint = (j: [SlotMove, SlotMove]): boolean => - j[0].moveIndex === NO_OP && j[1].moveIndex === NO_OP; - -/** Recursive joint maximin value at `bk`, cpuSide-perspective. Forced half-turns don't consume depth. */ -function searchValue(e: any, bk: Hex, cpuSide: 0 | 1, depth: number): number { - const t = terminal(e, bk, cpuSide, depth); - if (t !== null) return t; - if (depth <= 0) return doublesEval(e, bk, cpuSide); - - const flag = Number(e.getBattleContext(bk).playerSwitchForTurnFlag); - if (flag !== 2) { - const mask = flag & 0x0f; - const slots = activeSlots(e, bk); - const mine = forcedJointModel(e, bk, cpuSide, mask, slots); - const theirs = forcedJointModel(e, bk, (1 - cpuSide) as 0 | 1, mask, slots); - if (isNoOpJoint(mine) && isNoOpJoint(theirs)) return doublesEval(e, bk, cpuSide); // no resolution — don't loop - const child = forkJoint(e, bk, cpuSide, mine, theirs); - const v = searchValue(e, child, cpuSide, depth); - disposeFork(e, child); - return v; - } - - const my = sideJoint(e, bk, cpuSide); - const opp = sideJoint(e, bk, (1 - cpuSide) as 0 | 1); - let best = -Infinity; - for (const mine of my) { - let worst = Infinity; - for (const theirs of opp) { - const child = forkJoint(e, bk, cpuSide, mine, theirs); - const v = searchValue(e, child, cpuSide, depth - 1); - disposeFork(e, child); - if (v < worst) worst = v; - if (worst <= best) break; // argmax-invariant row prune (enumeration is rng-free) - } - if (worst > best) best = worst; - } - return best; -} - -/** Pick `cpuSide`'s two slot moves by depth-`depth` joint maximin (turn 0 → searched leads; - * forced-switch → enumerated bench combos; normal → joint maximin). */ -export function searchSideMoves(e: any, bk: Hex, cpuSide: 0 | 1, depth: number): [SlotMove, SlotMove] { - const [a0, a1] = sideSlots(cpuSide); - - // Turn 0: SEARCH the lead pair — maximin over both sides' send-ins. - if (Number(e.getTurnIdForBattleState(bk)) === 0) { - const leadPairs = (n: number): Array<[SlotMove, SlotMove]> => { - const v: Array<[SlotMove, SlotMove]> = []; - for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) if (i !== j) v.push([sw(i), sw(j)]); - return v; - }; - const my = leadPairs(teamSizeOf(e, bk, cpuSide)); - const opp = leadPairs(teamSizeOf(e, bk, (1 - cpuSide) as 0 | 1)); - let best: [SlotMove, SlotMove] = [sw(0), sw(1)]; - let bestVal = -Infinity; - for (const mine of my) { - let worst = Infinity; - for (const theirs of opp) { - const child = forkJoint(e, bk, cpuSide, mine, theirs); - const v = searchValue(e, child, cpuSide, Math.max(0, depth - 1)); - disposeFork(e, child); - if (v < worst) worst = v; - if (worst <= bestVal) break; - } - if (worst > bestVal) { - bestVal = worst; - best = mine; - } - } - return best; - } - - const flag = Number(e.getBattleContext(bk).playerSwitchForTurnFlag); - if (flag !== 2) { - // Forced-switch turn: enumerate my legal bench combos; opponent modeled first-legal. - const mask = flag & 0x0f; - const slots = activeSlots(e, bk); - const ts = teamSizeOf(e, bk, cpuSide); - const ko = koBitmap(e, bk, BigInt(cpuSide)); - const m0 = (mask & (1 << a0)) !== 0; - const m1 = (mask & (1 << a1)) !== 0; - if (!m0 && !m1) return [NO_OP_MOVE, NO_OP_MOVE]; // only the opponent is forced - - const b0 = m0 ? legalBenches(ts, ko, slots, a0) : []; - const b1 = m1 ? legalBenches(ts, ko, slots, a1) : []; - const combos: Array<[SlotMove, SlotMove]> = []; - if (m0 && m1) { - for (const x of b0) for (const y of b1) if (x !== y) combos.push([sw(x), sw(y)]); - } else if (m0) { - for (const x of b0) combos.push([sw(x), NO_OP_MOVE]); - } else { - for (const y of b1) combos.push([NO_OP_MOVE, sw(y)]); - } - if (combos.length === 0) combos.push([NO_OP_MOVE, NO_OP_MOVE]); - - const theirs = forcedJointModel(e, bk, (1 - cpuSide) as 0 | 1, mask, slots); - let best = combos[0]; - let bestVal = -Infinity; - for (const mine of combos) { - const child = forkJoint(e, bk, cpuSide, mine, theirs); - const v = searchValue(e, child, cpuSide, depth); // forced turns don't consume depth - disposeFork(e, child); - if (v > bestVal) { - bestVal = v; - best = mine; - } - } - return best; - } - - // Normal turn: joint maximin with the row prune. - const my = sideJoint(e, bk, cpuSide); - const opp = sideJoint(e, bk, (1 - cpuSide) as 0 | 1); - let best: [SlotMove, SlotMove] = my[0] ?? [NO_OP_MOVE, NO_OP_MOVE]; - let bestVal = -Infinity; - for (const mine of my) { - let worst = Infinity; - for (const theirs of opp) { - const child = forkJoint(e, bk, cpuSide, mine, theirs); - const v = searchValue(e, child, cpuSide, Math.max(0, depth - 1)); - disposeFork(e, child); - if (v < worst) worst = v; - if (worst <= bestVal) break; - } - if (worst > bestVal) { - bestVal = worst; - best = mine; - } - } - return best; -} - -/** The epsilon-greedy "Hard" doubles pilot (opt_prob = 1: always the best damaging option) — - * the baseline the search benchmarks against, mirroring the Rust `pick_side_moves`. */ -export function greedySideMoves(e: any, bk: Hex, side: 0 | 1): [SlotMove, SlotMove] { - const [a0, a1] = sideSlots(side); - if (Number(e.getTurnIdForBattleState(bk)) === 0) return [sw(0), sw(1)]; - - const flag = Number(e.getBattleContext(bk).playerSwitchForTurnFlag); - const slots = activeSlots(e, bk); - const ts = teamSizeOf(e, bk, side); - if (flag !== 2) { - return forcedJointModel(e, bk, side, flag & 0x0f, slots); - } - const pick = (abs: number): SlotMove => { - const myMon = slots[abs]; - if (myMon === EMPTY_LANE) return NO_OP_MOVE; - const options = damagingOptions(e, bk, side, myMon); - if (options.length === 0) return NO_OP_MOVE; - let best = options[0]; - for (const o of options) if (o.dmg > best.dmg) best = o; - return best.sm; - }; - void ts; - return [pick(a0), pick(a1)]; -} diff --git a/sims/src/cpu/engine-view.ts b/sims/src/cpu/engine-view.ts deleted file mode 100644 index 01db9a72..00000000 --- a/sims/src/cpu/engine-view.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Hex } from './hex'; -import { MoveClass, MonStateIndexName, Type } from '../../../transpiler/ts-output/Enums'; -import { moveSlotLib } from '../../../transpiler/ts-output/moves/MoveSlotLib'; -import { typeCalcLib } from '../../../transpiler/ts-output/types/TypeCalcLib'; -import { SWITCH_MOVE_INDEX, NO_OP_INDEX } from './constants'; - -/** - * Idiomatic TS port of the SHARED on-chain CPU base (`CPU.sol`): - * - `_calculateValidMoves` -> {@link calculateValidMoves} - * - `_sampleRNG` / `getRNG` -> {@link makeRng} (a simple injected PRNG; chain parity is NOT needed - * because whatever we submit is just a *legal* move; only the DISTRIBUTION of random draws must - * match — see the fidelity notes on each random pick). - * - the validation helpers (`_validateCPUMove*` / `ValidatorLogic`) -> the local engine's - * `validatePlayerMoveForBattle`, which runs the inline validator when `validator == 0` (the - * local case), so it is the faithful equivalent of `CPU._validateCPUMove`. - * - `_buildCPUContext` reads -> the small native readers below. - * - * The CPU is ALWAYS p1 (playerIndex 1n); the human opponent is p0 (0n). `e` is the LOCAL transpiled - * engine (LocalBattleService.getEngine). This module is heuristic-free — it provides the shared - * candidate-enumeration + readers that the OkayCPU / FairCPU / BetterCPU ports build their decisions - * on. (`MoveMeta` caching and the inline-vs-engine validation split are gas scaffolding and are - * intentionally dropped per the FIDELITY MANDATE.) - */ - -// The CPU's player index. CPU.sol hard-codes p1 everywhere (`_buildCPUContext`, `_validateCPUMove` -// pass playerIndex 1). -const CPU_PLAYER_INDEX = 1n; -const OPP_PLAYER_INDEX = 0n; - -// NUM_MOVES in CPU.sol is the engine's DEFAULT_MOVES_PER_MON (4). We read the active mon's actual -// move slots and stop at the first empty slot so <4-move mons are handled exactly like -// `_buildCPUContext` (which caps `len` at the mon's real `moves.length`). -const MAX_MOVES = 4; - -export type RevealedMove = { moveIndex: number; extraData: number }; - -// --------------------------------------------------------------------------------------------- -// RNG -// --------------------------------------------------------------------------------------------- - -/** - * Simple deterministic PRNG (mulberry32) returning a float in [0, 1). Whatever it returns is - * submitted as the CPU move, so there is NO chain-parity requirement — the only contract is that - * the *distribution* of draws reproduces the Solidity `_sampleRNG(...) % N` uniform picks. A - * Solidity `_getRNG % 6 == 5` becomes `rng() < 1/6`; a uniform pick among N candidates becomes - * `Math.floor(rng() * N)`. - */ -export function makeRng(seed: number = (Math.random() * 0x100000000) >>> 0): () => number { - let s = seed >>> 0; - return () => { - s = (s + 0x6d2b79f5) | 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -// --------------------------------------------------------------------------------------------- -// Small readers (the state CPU.sol's `_buildCPUContext` pulls off the engine) -// --------------------------------------------------------------------------------------------- - -/** - * Active mon indices [p0, p1]. The transpiled `getActiveMonIndexForBattleState` already UNPACKS to - * a 2-element array `[p0Index, p1Index]` (it is NOT a packed byte pair in the local engine — the - * on-chain `data.activeMonIndex` packing is hidden behind the getter), so we read it directly. - */ -export function activeMonIndices(e: any, bk: Hex): [number, number] { - const r = e.getActiveMonIndexForBattleState(bk); - return [Number(r[0]), Number(r[1])]; -} - -export function cpuActiveMonIndex(e: any, bk: Hex): number { - return activeMonIndices(e, bk)[1]; -} - -export function oppActiveMonIndex(e: any, bk: Hex): number { - return activeMonIndices(e, bk)[0]; -} - -export function teamSize(e: any, bk: Hex, playerIndex: bigint): number { - return Number(e.getTeamSize(bk, playerIndex)); -} - -export function cpuTeamSize(e: any, bk: Hex): number { - return teamSize(e, bk, CPU_PLAYER_INDEX); -} - -export function oppTeamSize(e: any, bk: Hex): number { - return teamSize(e, bk, OPP_PLAYER_INDEX); -} - -export function koBitmap(e: any, bk: Hex, playerIndex: bigint): number { - return Number(e.getKOBitmap(bk, playerIndex)); -} - -export function isMonKO(e: any, bk: Hex, playerIndex: bigint, monIndex: number): boolean { - return (koBitmap(e, bk, playerIndex) & (1 << monIndex)) !== 0; -} - -// Base stamina (max) of a mon — `cpuActiveMonBaseStamina` in CPUContext. -export function monBaseStamina(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - return Number(e.getMonValueForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Stamina)); -} - -// staminaDelta (the engine getter already normalizes the CLEARED sentinel to 0). -export function monStaminaDelta(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - return Number(e.getMonStateForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Stamina)); -} - -// Current stamina = base + delta (matches ValidatorLogic.validateSpecificMoveSelection's arithmetic). -export function monCurrentStamina(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - return monBaseStamina(e, bk, playerIndex, monIndex) + monStaminaDelta(e, bk, playerIndex, monIndex); -} - -export function monHpDelta(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - return Number(e.getMonStateForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Hp)); -} - -// Base (max) HP of a mon — the value getter, no delta. -export function monMaxHp(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - return Number(e.getMonValueForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Hp)); -} - -// Current HP = base + delta (the engine getter normalizes the CLEARED sentinel to 0). -export function monCurrentHp(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - return monMaxHp(e, bk, playerIndex, monIndex) + monHpDelta(e, bk, playerIndex, monIndex); -} - -// Current speed = base + delta (the speed-race sibling of monCurrentStamina). -export function monCurrentSpeed(e: any, bk: Hex, playerIndex: bigint, monIndex: number): number { - const base = Number(e.getMonValueForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Speed)); - const delta = Number(e.getMonStateForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Speed)); - return base + delta; -} - -// Mon types as [type1, type2]. type2 may be Type.None for single-type mons. -export function monTypes(e: any, bk: Hex, playerIndex: bigint, monIndex: number): [Type, Type] { - return [ - Number(e.getMonValueForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Type1)) as Type, - Number(e.getMonValueForBattle(bk, playerIndex, BigInt(monIndex), MonStateIndexName.Type2)) as Type, - ]; -} - -// Raw move slot (bigint) for a mon's move index, or undefined past the mon's real move count -// (the transpiled getter indexes `moves[i]`, returning undefined for <4-move mons). -export function moveSlot(e: any, bk: Hex, playerIndex: bigint, monIndex: number, moveIndex: number): bigint | undefined { - return e.getMoveForMonForBattle(bk, playerIndex, BigInt(monIndex), BigInt(moveIndex)); -} - -export function moveClassOf(e: any, bk: Hex, slot: bigint): MoveClass { - return moveSlotLib.moveClass(slot, e, bk) as MoveClass; -} - -export function moveTypeOf(e: any, bk: Hex, slot: bigint): Type { - return moveSlotLib.moveType(slot, e, bk) as Type; -} - -// Deployed-move address → its moves.csv InputType ('none' | 'self-mon' | 'opponent-mon') — the -// off-chain replacement for the removed on-chain ExtraDataType, mirroring the Rust port's -// INPUT_TYPE_BY_ADDR. Filled by `buildTeamMon` at team-resolve time (before any game runs). -const INPUT_TYPE_BY_ADDR = new Map(); - -export function registerMoveInputType(addr: bigint, inputType: string): void { - INPUT_TYPE_BY_ADDR.set(addr, inputType); -} - -/** InputType of an EXTERNAL move word (address = low 160 bits); 'none' for unknown moves. */ -export function moveInputTypeOf(slot: bigint): string { - return INPUT_TYPE_BY_ADDR.get(slot & ((1n << 160n) - 1n)) ?? 'none'; -} - -// Deployed-move address → its moves.csv TargetSpec (self-only / none / opponent-slot / …) — -// the client-facing target domain; nibble-free specs (self-only/none) need no target bits. -const TARGET_SPEC_BY_ADDR = new Map(); - -export function registerMoveTargetSpec(addr: bigint, targetSpec: string): void { - TARGET_SPEC_BY_ADDR.set(addr, targetSpec); -} - -/** TargetSpec of an EXTERNAL move word; 'any-other-slot' (the blank default) for unknown moves. */ -export function moveTargetSpecOf(slot: bigint): string { - return TARGET_SPEC_BY_ADDR.get(slot & ((1n << 160n) - 1n)) ?? 'any-other-slot'; -} - -// The opponent's (p0's) revealed move this turn — BetterCPU peeks at this. { moveIndex, extraData } -// with the on-chain packing decoded (low 7 bits = move index; bit 7 = isRealTurn). -export function opponentRevealedMove(e: any, bk: Hex): RevealedMove { - const d = e.getMoveDecisionForBattleState(bk, OPP_PLAYER_INDEX); - return { moveIndex: Number(d.packedMoveIndex) & 0x7f, extraData: Number(d.extraData) }; -} - -// --------------------------------------------------------------------------------------------- -// Type effectiveness primitive — the SAME scaled value the Solidity CPUs compare against. -// -// This is the RAW single-pair primitive `TYPE_CALC.getTypeEffectiveness(attack, defType, scale)`. -// Subclasses MUST compose it exactly as their Solidity counterpart does — there is deliberately NO -// "combined dual-type" helper here, because the Solidity CPUs compose differently per use-site and -// baking one rule in would be an invented heuristic: -// - OkayCPU.sol:192-196 — move-vs-defender at scale 2: `eff = f(attack, defType1, 2)`, then if -// defType2 != None `eff *= f(attack, defType2, 2)` (a RAW PRODUCT, no division), comparing -// `eff > 2` (super-effective) and `eff == 0 || eff == 1` (immune/resist). -// - HeuristicCPUBase `_offensiveMatchupScore` (scale 10) — a SUM across the type pairs. -// With scale=2 the primitive returns 0 (immune) / 1 (0.5x) / 2 (1x) / 4 (2x); with scale=10 it -// returns 0 / 5 / 10 / 20. Use whatever scale + composition the specific Solidity CPU uses. -// --------------------------------------------------------------------------------------------- - -/** - * `TYPE_CALC.getTypeEffectiveness(attackType, defenderType, scale)` — TypeCalculator.sol delegates - * straight to TypeCalcLib, so calling the lib directly is the faithful equivalent. - */ -export function typeEffectiveness(attack: Type, defenderType: Type, scale: number): number { - return Number(typeCalcLib.getTypeEffectiveness(attack, defenderType, BigInt(scale))); -} - -// --------------------------------------------------------------------------------------------- -// calculateValidMoves — faithful port of CPU._calculateValidMoves -// --------------------------------------------------------------------------------------------- - -function validate(e: any, bk: Hex, moveIndex: number, extraData: number): boolean { - // Faithful equivalent of CPU._validateCPUMove: the local engine runs the inline validator - // (validator == 0) for p1. - return e.validatePlayerMoveForBattle(bk, BigInt(moveIndex), CPU_PLAYER_INDEX, BigInt(extraData)) as boolean; -} - -/** - * Port of `CPU._calculateValidMoves`. Returns the three candidate buckets the subclass heuristics - * consume. `rng` is only consulted to pick the extraData TARGET for Self/Opponent-index moves (the - * same place CPU.sol calls `_sampleRNG(...) % count`); pass one if you want deterministic targets. - * - * CPU.sol:58-169 - */ -export function calculateValidMoves( - e: any, - bk: Hex, - rng: () => number = makeRng(), -): { noOp: RevealedMove[]; moves: RevealedMove[]; switches: RevealedMove[] } { - const tId = Number(e.getTurnIdForBattleState(bk)); - const p1TeamSize = cpuTeamSize(e, bk); - - // CPU.sol:68-75 — turn 0: every team slot is an (unvalidated) switch-in choice. - if (tId === 0) { - const switches: RevealedMove[] = []; - for (let i = 0; i < p1TeamSize; i++) { - switches.push({ moveIndex: SWITCH_MOVE_INDEX, extraData: i }); - } - return { noOp: [], moves: [], switches }; - } - - const activeMonIndex = cpuActiveMonIndex(e, bk); - - // CPU.sol:80-91 — collect valid switch targets (i != active, validated). - const validSwitchIndices: number[] = []; - for (let i = 0; i < p1TeamSize; i++) { - if (i !== activeMonIndex && validate(e, bk, SWITCH_MOVE_INDEX, i)) { - validSwitchIndices.push(i); - } - } - - const validSwitchesArray: RevealedMove[] = validSwitchIndices.map((i) => ({ - moveIndex: SWITCH_MOVE_INDEX, - extraData: i, - })); - - // CPU.sol:93-103 — a p1 forced-switch turn (playerSwitchForTurnFlag === 1) returns ONLY valid switches. - if (Number(e.getBattleContext(bk).playerSwitchForTurnFlag) === 1) { - return { noOp: [], moves: [], switches: validSwitchesArray }; - } - - // CPU.sol:108-152 — enumerate valid moves. For each move slot pick a valid extraData target the - // way _calculateValidMoves does (random among the legal targets), then validate. - const moves: RevealedMove[] = []; - for (let i = 0; i < MAX_MOVES; i++) { - const slot = moveSlot(e, bk, CPU_PLAYER_INDEX, activeMonIndex, i); - if (slot === undefined) break; // <4-move mon: stop at the real move count (mirrors len cap) - - let extraDataToUse = 0; - - if (!moveSlotLib.isInline(slot)) { - // The engine dropped the on-chain ExtraDataType getter; consult the off-chain InputType - // (moves.csv) instead, a uniform pick like the old CPU._calculateValidMoves (Rust-port parity). - const it = moveInputTypeOf(slot); - - if (it === 'self-mon') { - // Needs a self switch target; skip the move if there are none. - if (validSwitchIndices.length === 0) continue; - const r = Math.floor(rng() * validSwitchIndices.length); - extraDataToUse = validSwitchIndices[r]; - } else if (it === 'opponent-mon') { - // Build non-KO opponent targets; skip the move if there are none. - const opponentTeamSize = oppTeamSize(e, bk); - const oppKO = koBitmap(e, bk, OPP_PLAYER_INDEX); - const validTargets: number[] = []; - for (let j = 0; j < opponentTeamSize; j++) { - if ((oppKO & (1 << j)) === 0) validTargets.push(j); - } - if (validTargets.length === 0) continue; - const r = Math.floor(rng() * validTargets.length); - extraDataToUse = validTargets[r]; - } - // 'none' / inline moves fall through with extraData 0. - } - - // CPU.sol:149-151 — validate the candidate (stamina + move-specific) and keep it if legal. - if (validate(e, bk, i, extraDataToUse)) { - moves.push({ moveIndex: i, extraData: extraDataToUse }); - } - } - - // CPU.sol:164-165 — a single no-op is always offered on a non-forced-switch turn. - const noOp: RevealedMove[] = [{ moveIndex: NO_OP_INDEX, extraData: 0 }]; - - return { noOp, moves, switches: validSwitchesArray }; -} - -// Uniform pick among a candidate list (the shape every subclass uses for "pick one of N"). Returns -// undefined for an empty list so callers can fall back the way the Solidity CPUs do. -export function pickUniform(arr: T[], rng: () => number): T | undefined { - if (arr.length === 0) return undefined; - return arr[Math.floor(rng() * arr.length)]; -} diff --git a/sims/src/cpu/evaluator-ttk.ts b/sims/src/cpu/evaluator-ttk.ts deleted file mode 100644 index f8f577ab..00000000 --- a/sims/src/cpu/evaluator-ttk.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Hex } from './hex'; -import { MoveClass } from '../../../transpiler/ts-output/Enums'; -import { MoveMeta } from '../../../transpiler/ts-output/Structs'; -import { BattleView } from './battle-view'; -import { monCurrentSpeed } from './engine-view'; -import { - buildDamageCalcContext, - estimateDamageMeta, - loadMonMetas, - popcount8, -} from './heuristic-shared'; - -/** - * RACE-MATRIX EVALUATOR — first-principles alternative to `scoreState`'s linear feature weights. - * - * The win condition is KOing the whole opposing roster, so a position's value is the state of every - * pairwise RACE: for each (my mon i, their mon j), the turns i needs to KO j (TTK) under the real - * damage formula — per-pair contexts include stat deltas, so boosts/burns reprice races natively. - * Stats, types, and HP carry no intrinsic weight; each matters exactly as far as it changes a race. - * - * Aggregation: each side's threat against an enemy mon is its best ANSWER speed — the min adjusted - * TTK over its living roster, where "adjusted" pays +1 turn for deploying from the bench and +1 for - * losing the head-to-head race (the answer can't simply stand and trade). Answer speeds sum as - * 1/TTK (a wall ⇒ 1/∞ ⇒ 0 — the anti-wall signal is intrinsic, and removing the one mon that - * answered a sweeper visibly rewrites a column). Banked KOs, the current active pair's race (tempo), - * and a small stamina nudge complete the score. Three scale knobs replace six feature weights. - * - * Costlier than the pure-view scoreState (it decodes metas and builds contexts per pair) — fine for - * root-level forks, noticeable but acceptable inside deep planner trees. - */ - -const CPU_PLAYER_INDEX = 1n; -const OPP_PLAYER_INDEX = 0n; - -const SCALE = 150; // one full answer-unit (a 1-turn answer) in scoreState-comparable units -const W_KO = 300; // a banked KO outranks the best living answer -const W_TEMPO = 75; // current-pair race edge — the one positional fact that matters -const W_STAMINA = 2; - -interface SideData { - metas: (MoveMeta[] | null)[]; - speed: number[]; -} - -function readSide(e: any, bk: Hex, playerIndex: bigint, mons: BattleView['mons']['p0']): SideData { - const metas: (MoveMeta[] | null)[] = []; - const speed: number[] = []; - for (let i = 0; i < mons.length; i++) { - metas.push(mons[i].ko ? null : loadMonMetas(e, bk, playerIndex, i)); - speed.push(mons[i].ko ? 0 : monCurrentSpeed(e, bk, playerIndex, i)); - } - return { metas, speed }; -} - -/** Best single-move damage from attacker (its decoded metas) into the prepared context. */ -function bestDamage(ctx: any, metas: MoveMeta[]): number { - let best = 0; - for (const meta of metas) { - if (meta.moveClass !== MoveClass.Physical && meta.moveClass !== MoveClass.Special) continue; - const d = estimateDamageMeta(ctx, meta); - if (d > best) best = d; - } - return best; -} - -export function ttkEval(view: BattleView): number { - const e = view.engine; - const bk = view.bk as Hex; - const mine = view.mons.p1; - const theirs = view.mons.p0; - - const my = readSide(e, bk, CPU_PLAYER_INDEX, mine); - const op = readSide(e, bk, OPP_PLAYER_INDEX, theirs); - - // Pairwise TTK in both directions (Infinity = walled). FRACTIONAL turns: the aggregation must be - // smooth in damage (ceil'd TTK is piecewise-constant in exactly what a root decision varies, which - // collapses most candidates into ties); race OUTCOMES compare ceil'd turns below, where turn - // boundaries are real. - const ttkMine: number[][] = mine.map(() => theirs.map(() => Infinity)); - const ttkTheirs: number[][] = theirs.map(() => mine.map(() => Infinity)); - for (let i = 0; i < mine.length; i++) { - if (mine[i].ko) continue; - for (let j = 0; j < theirs.length; j++) { - if (theirs[j].ko) continue; - // Floored at one turn: nothing dies faster, and without the floor overkill capacity against - // weakened prey outranks the KO credit for actually finishing it. - const ctxMine = buildDamageCalcContext(e, bk, CPU_PLAYER_INDEX, i, OPP_PLAYER_INDEX, j); - const dMine = bestDamage(ctxMine, my.metas[i]!); - if (dMine > 0) ttkMine[i][j] = Math.max(1, theirs[j].hp / dMine); - const ctxTheirs = buildDamageCalcContext(e, bk, OPP_PLAYER_INDEX, j, CPU_PLAYER_INDEX, i); - const dTheirs = bestDamage(ctxTheirs, op.metas[j]!); - if (dTheirs > 0) ttkTheirs[j][i] = Math.max(1, mine[i].hp / dTheirs); - } - } - - // Answer speeds: min adjusted TTK per enemy mon; +1 for bench deployment, +1 for losing the - // head-to-head race (equal TTK loses on slower-or-tied speed, matching weGoFirst's pessimism). - let offense = 0; - for (let j = 0; j < theirs.length; j++) { - if (theirs[j].ko) continue; - let bestAnswer = Infinity; - for (let i = 0; i < mine.length; i++) { - if (mine[i].ko || ttkMine[i][j] === Infinity) continue; - const tMine = Math.ceil(ttkMine[i][j]); - const tTheirs = Math.ceil(ttkTheirs[j][i]); - const losesRace = tTheirs < tMine || (tTheirs === tMine && op.speed[j] >= my.speed[i]); - const adj = ttkMine[i][j] + (i === view.cpuActive ? 0 : 1) + (losesRace ? 1 : 0); - if (adj < bestAnswer) bestAnswer = adj; - } - if (bestAnswer !== Infinity) offense += 1 / bestAnswer; - } - let defense = 0; - for (let i = 0; i < mine.length; i++) { - if (mine[i].ko) continue; - let bestAnswer = Infinity; - for (let j = 0; j < theirs.length; j++) { - if (theirs[j].ko || ttkTheirs[j][i] === Infinity) continue; - const tMine = Math.ceil(ttkMine[i][j]); - const tTheirs = Math.ceil(ttkTheirs[j][i]); - const losesRace = tMine < tTheirs || (tMine === tTheirs && my.speed[i] >= op.speed[j]); - const adj = ttkTheirs[j][i] + (j === view.oppActive ? 0 : 1) + (losesRace ? 1 : 0); - if (adj < bestAnswer) bestAnswer = adj; - } - if (bestAnswer !== Infinity) defense += 1 / bestAnswer; - } - - // Tempo: the race already in progress between the actives (a pending skip costs its side a turn). - const a = view.cpuActive; - const b = view.oppActive; - let tempo = 0; - let stamina = 0; - if (!mine[a]?.ko && !theirs[b]?.ko) { - const ta = ttkMine[a][b] + (mine[a].skipTurn ? 1 : 0); - const tb = ttkTheirs[b][a] + (theirs[b].skipTurn ? 1 : 0); - tempo = W_TEMPO * ((ta === Infinity ? 0 : 1 / ta) - (tb === Infinity ? 0 : 1 / tb)); - stamina = W_STAMINA * (mine[a].stamina - theirs[b].stamina); - } - - const ko = W_KO * (popcount8(view.oppKO) - popcount8(view.cpuKO)); - return ko + SCALE * (offense - defense) + tempo + stamina; -} diff --git a/sims/src/cpu/evaluator.ts b/sims/src/cpu/evaluator.ts deleted file mode 100644 index 81035152..00000000 --- a/sims/src/cpu/evaluator.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { offensiveMatchupScore, popcount8 } from './heuristic-shared'; -import { BattleView, MonView } from './battle-view'; - -/** - * SEARCH SUBSTRATE — layer 3: a single static evaluation of a position from the CPU's (p1's) point of - * view. HIGHER = better for the CPU. - * - * This is deliberately an AGGREGATOR, not a new combat model: every term is either a `BattleView` - * field or an existing `heuristic-shared` / `engine-view` primitive. There are NO invented combat - * heuristics, damage models, or threat tables here — a search routine layers those on top by calling - * `applyHypotheticalMove` and scoring the resulting views with this function. - * - * Terms (all from the CPU's perspective; p1 = CPU, p0 = opponent): - * 1. HP swing — Σ (cpu hp% − opp hp%) over active rosters, scaled to be the dominant term. - * 2. KO differential — (opp mons KO'd − cpu mons KO'd), heavily weighted (a KO is worth a lot). - * 3. Matchup edge — active-mon `offensiveMatchupScore` (CPU→opp) minus the opponent's (opp→CPU). - * 4. Stamina edge — small bonus for the CPU active mon having more stamina than the opp active mon. - * 5. Stat-stage edge — active-mon `statDeltaScore` diff: setup boosts score positive, burn's attack - * divide / frostbite's spatk divide negative. (Panic is a stamina drain — term 4 already sees it.) - * 6. Skip edge — the active mon's pending ShouldSkipTurn (zap) costs its next action. Sleep is - * NOT visible to a static snapshot (it NO_OPs the sleeper's move instead of flagging); a deeper - * search still prices it in as zero damage output across the simulated turns. - */ - -// Weights. HP/KO dominate; matchup and stamina are tie-breakers. Kept as named constants so the -// composition is legible — none of these are engine values, just the relative emphasis of the -// already-existing signals. -/** The relative emphasis of the six position signals. Tunable: `fit-eval.ts` fits these from logged - * game outcomes; strategies can carry their own weights for A/B without touching the default. */ -export interface EvalWeights { - hp: number; - ko: number; - matchup: number; - stamina: number; - statDelta: number; - skip: number; -} - -export const DEFAULT_EVAL_WEIGHTS: EvalWeights = { - hp: 1, // hp% diff is already 0..100 per mon; summed it is the primary signal. - ko: 150, // one KO swing ≈ a mon at full HP plus margin. - matchup: 0.5, // offensiveMatchupScore is in scale-10 units summed over type pairs. - stamina: 2, // tiny nudge per point of active-mon stamina advantage. - statDelta: 40, // a full base-stat of boost (Σ delta/base = 1.0) ≈ 40 hp%; burn's half-attack ≈ -20. - skip: 30, // losing the next action ≈ eating a strong hit. -}; - -/** hp% (0..100) for a slot: current hp / max hp — both already on the snapshot, so this is pure-view - * (no engine read), which matters when a search scores thousands of forked positions. */ -function hpPercent(mon: MonView): number { - if (mon.maxHp <= 0) return 0; - return (Math.max(0, mon.hp) * 100) / mon.maxHp; -} - -/** - * The six UNWEIGHTED terms, CPU-perspective (higher = better for the CPU). `scoreStateWith` is their - * weighted sum; the fitting script consumes them raw so features and scoring can't drift apart. - */ -export function evalFeatures(view: BattleView): [number, number, number, number, number, number] { - // 1. HP swing: Σ cpu hp% − Σ opp hp% over all roster slots. - let cpuHpPct = 0; - for (let i = 0; i < view.mons.p1.length; i++) { - cpuHpPct += hpPercent(view.mons.p1[i]); - } - let oppHpPct = 0; - for (let i = 0; i < view.mons.p0.length; i++) { - oppHpPct += hpPercent(view.mons.p0[i]); - } - const hp = cpuHpPct - oppHpPct; - - // 2. KO differential: opponent KOs are good for the CPU, CPU KOs are bad. - const ko = popcount8(view.oppKO) - popcount8(view.cpuKO); - - // 3-6. Active-mon terms: matchup edge, stamina edge, stat-stage edge (setup / burn / frostbite), - // pending turn-skips (zap). - const cpu = view.mons.p1[view.cpuActive]; - const opp = view.mons.p0[view.oppActive]; - let matchup = 0; - let stamina = 0; - let statDelta = 0; - let skip = 0; - if (cpu && opp) { - matchup = - offensiveMatchupScore(cpu.type1, cpu.type2, opp.type1, opp.type2) - - offensiveMatchupScore(opp.type1, opp.type2, cpu.type1, cpu.type2); - stamina = cpu.stamina - opp.stamina; - statDelta = cpu.statDeltaScore - opp.statDeltaScore; - skip = (opp.skipTurn ? 1 : 0) - (cpu.skipTurn ? 1 : 0); - } - - return [hp, ko, matchup, stamina, statDelta, skip]; -} - -export function scoreStateWith(view: BattleView, w: EvalWeights): number { - const [hp, ko, matchup, stamina, statDelta, skip] = evalFeatures(view); - return w.hp * hp + w.ko * ko + w.matchup * matchup + w.stamina * stamina + w.statDelta * statDelta + w.skip * skip; -} - -export function scoreState(view: BattleView): number { - return scoreStateWith(view, DEFAULT_EVAL_WEIGHTS); -} diff --git a/sims/src/cpu/forward-model.ts b/sims/src/cpu/forward-model.ts deleted file mode 100644 index 307180f1..00000000 --- a/sims/src/cpu/forward-model.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { Hex } from './hex'; -import { runNested } from '../../../transpiler/ts-output/runtime'; -import { SWITCH_MOVE_INDEX, MOVE_INDEX_OFFSET, IS_REAL_TURN_BIT } from '../../../transpiler/ts-output/Constants'; -import { - BattleConfig, - BattleData, - createDefaultBattleConfig, - createDefaultBattleData, -} from '../../../transpiler/ts-output/Structs'; -import { BattleView, captureBattleView } from './battle-view'; - -/** - * SEARCH SUBSTRATE — layer 2: a NON-DESTRUCTIVE forward model. - * - * Lets a strategy ask "what would the position look like if both players played X / Y this turn?" - * WITHOUT touching the live battle. The trick is a shallow-but-careful clone of the engine's per- - * battle storage (`battleData[bk]` + `battleConfig[storageKey]`) under a throwaway fork key, then a - * faithful replay of `Engine.executeWithMoves` against that fork — minus the moveManager authorization - * gate (which we are deliberately bypassing because the search code is not the registered moveManager). - * - * What is and isn't cloned: - * - Plain objects / arrays / bigints / strings / booleans -> DEEP-COPIED (mutating the fork can't - * leak back into the live battle's state tree). - * - Class instances (`v.constructor !== Object`) -> SHARED BY REFERENCE. Effects, the - * team registry, validator, rng oracle, engine-hook contracts and the packed move/ability - * Contract slots are global singletons; cloning them would (a) be wrong (they hold no per-battle - * mutable state we own) and (b) break identity comparisons the engine relies on. - * - * CPU is p1, human is p0 (inherited convention), but the fork machinery is side-agnostic. - */ - -// --------------------------------------------------------------------------------------------- -// cloneState — deep-copy plain data, share class instances by reference. -// --------------------------------------------------------------------------------------------- - -/** - * Deep-copy `v`, but pass anything that is NOT a plain Object/Array (i.e. a class instance, where - * `v.constructor !== Object`) straight through by reference. This is the single invariant that keeps - * the fork cheap AND correct: the engine's singletons (Contracts) are shared, the per-battle data - * tree (structs, packed Records, Mon arrays) is copied. - * - * Proxy-wrapped Records (the auto-vivifying `battleConfig.p0Team` etc.) report `constructor === Object` - * through their target, so they are treated as plain objects and copied via their OWN enumerable keys - * (the proxy only materializes a key on access, so `Object.keys` already lists every touched slot). - */ -export function cloneState(v: T, seen: WeakMap = new WeakMap()): T { - // Primitives (incl. bigint, string, boolean, number, null, undefined, symbol) — copied by value. - if (v === null || typeof v !== 'object') return v; - - // Class instances (Contracts, effects, registries, …) — shared by reference, NOT cloned. - // Arrays and plain/Proxy-wrapped objects have `constructor === Object`/`Array`; everything else - // (Engine, IEffect impls, ITeamRegistry, the stub `{_contractAddress}` is Object so it copies). - const ctor = (v as object).constructor; - if (ctor !== Object && ctor !== Array) return v; - - const cached = seen.get(v as object); - if (cached !== undefined) return cached; - - if (Array.isArray(v)) { - const out: any[] = []; - seen.set(v as object, out); - for (let i = 0; i < v.length; i++) out[i] = cloneState((v as any[])[i], seen); - return out as unknown as T; - } - - const out: Record = {}; - seen.set(v as object, out); - for (const k of Object.keys(v as object)) { - out[k] = cloneState((v as Record)[k], seen); - } - return out as unknown as T; -} - -// --------------------------------------------------------------------------------------------- -// forkBattle — clone the live battle's storage under a fresh fork key. -// --------------------------------------------------------------------------------------------- - -// The `BattleConfig` fields that are auto-vivifying Record Proxies (the engine writes NEW keys into -// these during execution — e.g. `config.p1Effects[slot]` when a move adds an effect — and relies on -// the default-factory `get` trap). A naive deep-clone turns them into plain objects and breaks that, -// so the fork must keep FRESH proxies (from `createDefaultBattleConfig`) and copy materialized -// entries into them. All OTHER config fields are scalars/structs and clone cleanly via `cloneState`. -const CONFIG_PROXY_MAP_FIELDS = [ - 'p0States', - 'p1States', - 'globalEffects', - 'p0Effects', - 'p1Effects', - 'engineHooks', -] as const; - -// Team arrays are written only at battle start (`Engine.startBattle`), never during a turn — the engine -// only READS `config.p{0,1}Team` while replaying a move (stat boosts mutate mon STATE deltas, not the -// stored team). So the fork shares the live team proxies by reference instead of deep-cloning ~8 -// nested StoredMon structs per fork. Exact: nothing on the replayed turn writes them. -const CONFIG_SHARED_FIELDS = new Set(['p0Team', 'p1Team']); - -/** - * Clone a live `BattleConfig` into a fresh one that PRESERVES the auto-vivifying proxies on its map - * fields. Scalar/struct fields are deep-copied (`cloneState`); each proxy-map field starts from the - * fresh default proxy and gets every materialized own-key copied in (deep-cloned), so the engine can - * keep adding new slots on the fork without `undefined`-slot crashes. The immutable-during-turn team - * proxies are shared by reference (see {@link CONFIG_SHARED_FIELDS}). - */ -function cloneBattleConfig(src: BattleConfig): BattleConfig { - const out = createDefaultBattleConfig(); - const proxyFields = new Set(CONFIG_PROXY_MAP_FIELDS as readonly string[]); - const s = src as unknown as Record; - const o = out as unknown as Record; - - for (const k of Object.keys(s)) { - if (CONFIG_SHARED_FIELDS.has(k)) { - o[k] = s[k]; // share the live team proxy by reference (read-only during a turn) - } else if (proxyFields.has(k)) { - // Copy each materialized entry into the fresh proxy (which retains its default factory). - const srcMap = s[k] as Record; - const dstMap = o[k] as Record; - for (const mk of Object.keys(srcMap)) { - dstMap[mk] = cloneState(srcMap[mk]); - } - } else { - o[k] = cloneState(s[k]); - } - } - return out; -} - -/** Clone live `BattleData` into a fresh struct (all scalar fields — plain deep-copy). */ -function cloneBattleData(src: BattleData): BattleData { - const out = createDefaultBattleData(); - const s = src as unknown as Record; - const o = out as unknown as Record; - for (const k of Object.keys(s)) o[k] = cloneState(s[k]); - return out; -} - -let _forkCounter = 0; - -/** Deterministic-ish unique fork battle key (32-byte hex). Marked with a high tag nibble so it can - * never collide with a real keccak battle key in practice. */ -function nextForkKey(): Hex { - _forkCounter = (_forkCounter + 1) >>> 0; - const tag = 'f0fc'; // "fork" - const body = _forkCounter.toString(16).padStart(8, '0'); - // 4 (tag) + 8 (counter) = 12 hex; pad the remaining 52 with the counter again for uniqueness. - return ('0x' + tag + body + body.padStart(52, '0')) as Hex; -} - -/** - * Clone `e.battleData[bk]` and `e.battleConfig[_getStorageKey(bk)]` into a fresh fork key and return - * that key. The fork's storageKey naturally EQUALS the fork key (no `battleKeyToStorageKey` redirect - * is wired), mirroring `LocalBattleService.createBattle`'s re-key block — so every engine reader and - * `_executeInternal` resolves the fork's config at `battleConfig[forkKey]`. - */ -export function forkBattle(e: any, bk: string): Hex { - const eng = e as any; - const forkKey = nextForkKey(); - - // Source data lives at battleData[bk]; source config at battleConfig[_getStorageKey(bk)]. - const srcStorageKey = eng._getStorageKey(bk); - const srcData = eng.battleData[bk]; - const srcConfig = eng.battleConfig[srcStorageKey]; - - // Deep-clone the per-battle data tree; singletons inside are shared by reference (cloneState). The - // config clone preserves the auto-vivifying proxies on its map fields so the engine can write new - // effect / state slots on the fork during execution. - eng.__mutateBattleData(forkKey, cloneBattleData(srcData)); - eng.__mutateBattleConfig(forkKey, cloneBattleConfig(srcConfig)); - - // globalKV + globalKVKeySlots live OUTSIDE battleData/battleConfig, keyed by storageKey — and the - // locks / once-per-game flags of ~18 contracts live there (Initialize/Loop locks, the one-status- - // per-mon flag, Snack Break's halving ladder, Savior Complex, Modal Bolt modes, ...). Copy the - // source battle's entries under the fork key, or every forked turn sees them all as zero and the - // search rewards re-casting locked moves. Values are immutable strings/bigints, so a shallow - // per-entry copy through the auto-vivifying proxies is exact. - const srcKV = eng.globalKV[srcStorageKey] as Record; - const dstKV = eng.globalKV[forkKey] as Record; - for (const k of Object.keys(srcKV)) dstKV[k] = srcKV[k]; - const srcSlots = eng.globalKVKeySlots[srcStorageKey] as Record; - const dstSlots = eng.globalKVKeySlots[forkKey] as Record; - for (const k of Object.keys(srcSlots)) dstSlots[k] = srcSlots[k]; - - // No redirect: leaving battleKeyToStorageKey[forkKey] == 0 makes _getStorageKey(forkKey) return - // forkKey itself, so battleConfig[forkKey] is the resolved slot for both reads and execution. - return forkKey; -} - -/** - * Reclaim a fork's cloned state — deletes the `battleData` / `battleConfig` entries `forkBattle` added - * (the fork's storageKey equals its key, so both live under `forkKey`). A 1-ply search can skip this, - * but a deep search forks O(branching^depth) times per turn and must dispose each fork once its subtree - * is scored, or the fork maps grow without bound across a session. - */ -export function disposeFork(e: any, forkKey: string): void { - const eng = e as any; - delete eng.battleData[forkKey]; - delete eng.battleConfig[forkKey]; - delete eng.globalKV[forkKey]; - delete eng.globalKVKeySlots[forkKey]; -} - -// --------------------------------------------------------------------------------------------- -// applyHypotheticalMove — replay one turn on a fork, return the resulting view. -// --------------------------------------------------------------------------------------------- - -export interface HypotheticalMove { - moveIndex: number; - salt: bigint; - extraData: number; -} - -/** - * Fork `bk`, submit `p0` / `p1` (either may be null on a forced-switch turn where that side doesn't - * act), run ONE turn on the fork, and return a `BattleView` of the result. The original `bk` is left - * completely untouched. - * - * The turn is run by replicating `Engine.executeWithMoves` WITHOUT the moveManager gate: - * 1. Write both moves onto the fork config via `_setMoveInternal` (so config.p{0,1}Move/Salt are set - * exactly as a real submit would leave them). - * 2. Set storageKeyForWrite + the `_turnP0Packed` / `_turnP1Packed` encoded transients the way - * executeWithMoves does (these take precedence inside `_getCurrentTurnMove`). - * 3. Call `_executeInternal(forkKey, forkKey, false)` — `emitEvents=false` to keep the simulation - * silent (no BattleComplete / EngineExecute events leaking into the live event stream). - * 4. `resetCallContext()` to clear the write-context transients. - * - * Boundary note: the runtime resets ALL transient storage at a transaction boundary (the depth - * 0→1 entry) — which would wipe the `_turnP*Packed` we just set. `executeWithMoves` avoids this - * because it sets the transients INSIDE its own already-entered frame. We reproduce that by running - * the whole sequence inside `runNested`, which keeps callDepth > 0 so the inner `_executeInternal` / - * `resetCallContext` calls are seen as nested (no boundary reset). - */ -export function applyHypotheticalMove( - e: any, - bk: string, - p0: HypotheticalMove | null, - p1: HypotheticalMove | null, -): BattleView { - const forkKey = applyHypotheticalMoveKeyed(e, bk, p0, p1); - return captureBattleView(e, forkKey); -} - -/** - * Like {@link applyHypotheticalMove} but returns the FORK KEY instead of a view — the depth-search - * primitive: the caller can read/evaluate the fork, fork AGAIN from it (child plies), and must - * `disposeFork` it when its subtree is scored. `bk` may itself be a fork key (forkBattle resolves - * any live key). - */ -export function applyHypotheticalMoveKeyed( - e: any, - bk: string, - p0: HypotheticalMove | null, - p1: HypotheticalMove | null, -): Hex { - const eng = e as any; - const forkKey = forkBattle(eng, bk); - const config = eng.battleConfig[forkKey]; - - // runNested keeps callDepth > 0 so the transaction-boundary transient reset does NOT - // fire between us setting the turn transients and `_executeInternal` reading them. - runNested(() => { - // Match executeWithMoves: BOTH write transients (the inlined stat-boost path gates external - // engine writes — ability/effect re-entry — on battleKeyForWrite being set). - eng.__mutateStorageKeyForWrite(forkKey); - eng.__mutateBattleKeyForWrite(forkKey); - - // (1) write both moves onto the fork config, and (2) set the encoded turn transients that - // executeWithMoves would set (these win in _getCurrentTurnMove / _getCurrentTurnSalt). - if (p0) { - eng._setMoveInternal(config, 0n, BigInt(p0.moveIndex), p0.salt, BigInt(p0.extraData)); - eng.__mutate_turnP0Packed(packTurn(p0)); - } - if (p1) { - eng._setMoveInternal(config, 1n, BigInt(p1.moveIndex), p1.salt, BigInt(p1.extraData)); - eng.__mutate_turnP1Packed(packTurn(p1)); - } - - try { - // (3) run the turn on the fork (silent; singles — slotPacked=false, explicit because the - // doubles-era engine's WrongBattleMode guard compares it against the battle's mode byte). - eng._executeInternal(forkKey, forkKey, false, false, false); - } finally { - // (4) clear write-context transients. - eng.resetCallContext(); - } - }); - - return forkKey; -} - -/** - * Build a side's raw slot-turn word for `executeWithSlotMoves` (doubles): slot-0 = (m0, e0) in - * bits 0-23, slot-1 = (m1, e1) in bits 24-47, salt in bits 48+. extraData carries the target - * nibble in bits 12-15. Mirrors the Rust `pack_side`. - */ -export function packSide(m0: number, e0: number, m1: number, e1: number, salt: bigint): bigint { - return BigInt(m0) | (BigInt(e0) << 8n) | (BigInt(m1) << 24n) | (BigInt(e1) << 32n) | (salt << 48n); -} - -/** - * DOUBLES analogue of {@link applyHypotheticalMoveKeyed}: fork `bk`, run ONE silent slot-turn - * from each side's packed word (see {@link packSide}), return the fork key. Mirrors - * `executeWithSlotMoves` (turns via `_packSideTurn` transients + `slotPacked=true`); `bk` may be - * the live key or a fork (depth search). Caller must `disposeFork`. - */ -export function applyHypotheticalSlotMoveKeyed(e: any, bk: string, side0Packed: bigint, side1Packed: bigint): Hex { - const eng = e as any; - const forkKey = forkBattle(eng, bk); - runNested(() => { - eng.__mutateStorageKeyForWrite(forkKey); - eng.__mutateBattleKeyForWrite(forkKey); - eng.__mutate_turnP0Packed(eng._packSideTurn(side0Packed)); - eng.__mutate_turnP1Packed(eng._packSideTurn(side1Packed)); - try { - eng._executeInternal(forkKey, forkKey, false, false, true); // slotPacked=true, silent - } finally { - eng.resetCallContext(); - } - }); - return forkKey; -} - -// Mirror Engine.executeWithMoves's transient packing: storedIndex = moveIndex (+offset if a real -// move slot), then `(storedIndex | IS_REAL_TURN_BIT) | (extraData << 8)` shifted into the high bits -// by `_packTurn` (salt in the low 104 bits). We read the engine's own constants off the instance to -// avoid duplicating magic numbers. -function packTurn(m: HypotheticalMove): bigint { - // _packTurn(encoded, salt) = salt | (encoded << 104). We replicate inline rather than call the - // protected helper through the proxy (which is fine, but inlining keeps the math visible/auditable). - const mi = BigInt(m.moveIndex); - const stored = mi < SWITCH_MOVE_INDEX ? mi + MOVE_INDEX_OFFSET : mi; - const encoded = (stored | IS_REAL_TURN_BIT) | (BigInt(m.extraData) << 8n); - return m.salt | (encoded << 104n); -} diff --git a/sims/src/cpu/heuristic-native.ts b/sims/src/cpu/heuristic-native.ts deleted file mode 100644 index 7291469a..00000000 --- a/sims/src/cpu/heuristic-native.ts +++ /dev/null @@ -1,549 +0,0 @@ -import { Hex } from './hex'; -import { MoveClass, MonStateIndexName, Type } from '../../../transpiler/ts-output/Enums'; -import { moveSlotLib } from '../../../transpiler/ts-output/moves/MoveSlotLib'; -import { DamageCalcContext, MoveMeta } from '../../../transpiler/ts-output/Structs'; -import { NO_OP_INDEX, SWITCH_MOVE_INDEX } from './constants'; -import { BattleView } from './battle-view'; -import { RevealedMove, monCurrentHp, monCurrentSpeed, monCurrentStamina } from './engine-view'; -import { applyHypotheticalMove, disposeFork, HypotheticalMove } from './forward-model'; -import { scoreState } from './evaluator'; -import { - SIMILAR_DAMAGE_THRESHOLD, - SWITCH_THRESHOLD, - buildDamageCalcContext, - estimateDamage, - estimateDamageMeta, - findBestDamageMove, - loadMonMetas, - offensiveMatchupScore, -} from './heuristic-shared'; - -/** - * TS-NATIVE shared strategy helpers — NOT Solidity ports. `heuristic-shared.ts` is the faithful - * HeuristicCPUBase.sol port and stays 1:1; helpers invented client-side that more than one strategy - * uses live here instead. Two families: - * - band/anti-wall picks (any strategy); - * - FAIR-INFO pool analysis: worst case over the opponent's AFFORDABLE move pool (no peeking; - * stamina is public info, so an unaffordable nuke is not a threat). Shared by Medium and Stall. - */ - -const CPU_PLAYER_INDEX = 1n; -const OPP_PLAYER_INDEX = 0n; - -// A matchup is "walled" if our best move does LESS than this % of the opponent active mon's CURRENT HP -// (a near-useless attack). Tuned low so the breaker fires only on a true wall, never on a slow-but-OK -// matchup — keeping switches rare and preventing oscillation. -export const WALL_DAMAGE_PCT = 5; - -/** - * Uniform pick among the moves inside the similar-damage band (within {@link SIMILAR_DAMAGE_THRESHOLD}% - * of the best damage). The deterministic `findBestDamageMove` always resolves the band to the - * cheapest-stamina member, which makes a strategy's default move scriptable game after game; sampling - * the band trades a little stamina efficiency for unpredictability at near-equal damage. Returns an - * index into `moves`, or -1 if no move deals damage. - */ -export function pickSimilarDamageMove(damages: number[], rng: () => number): number { - let bestDamage = 0; - for (let i = 0; i < damages.length; i++) { - if (damages[i] > bestDamage) bestDamage = damages[i]; - } - if (bestDamage === 0) return -1; - - const threshold = Math.floor((bestDamage * SIMILAR_DAMAGE_THRESHOLD) / 100); - const band: number[] = []; - for (let i = 0; i < damages.length; i++) { - if (damages[i] >= threshold) band.push(i); - } - return band[Math.floor(rng() * band.length)]; -} - -/** - * Anti-wall stalemate-breaker. Returns the index (into `switches`) of a bench mon to pivot to when the - * active mon is WALLED — its best move can't meaningfully damage the opponent's active mon — and a bench - * mon has a strictly better offensive type matchup; else -1. - * - * This fills the gap a threat-based defensive switch leaves: it only fires on a THREAT, so a passive - * opponent that we also can't hurt produces an endless flail at a wall. The breaker pivots to a mon that - * can actually do something. It only fires when we're NOT making progress and a STRICTLY better attacker - * exists, so a productive matchup is never abandoned and it can't oscillate (the better mon, by - * definition, is the new best — nothing beats it next turn). - */ -export function antiWallSwitch( - view: BattleView, - metas: MoveMeta[], - moves: RevealedMove[], - damages: number[], - switches: RevealedMove[], - forkPick?: { revealIdx: number; revealExtra: number; salt: bigint }, -): number { - if (switches.length === 0) return -1; - const e = view.engine; - const bk = view.bk as Hex; - const opponentMonIndex = view.oppActive; - const oppHp = monCurrentHp(e, bk, OPP_PLAYER_INDEX, opponentMonIndex); - if (oppHp <= 0) return -1; - - // Progress check: best move does >= WALL_DAMAGE_PCT% of the opp's current HP => productive, stay in. - const bestIdx = findBestDamageMove(metas, moves, damages); - const bestDmg = bestIdx >= 0 ? damages[bestIdx] : 0; - if (bestDmg * 100 >= oppHp * WALL_DAMAGE_PCT) return -1; - - // Walled: only bench mons whose offensive matchup STRICTLY beats the current one qualify — each - // pivot strictly improves the active matchup, so pivots can't oscillate. - const oppStats = e.getMonStatsForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex)); - const ourStats = e.getMonStatsForBattle(bk, CPU_PLAYER_INDEX, BigInt(view.cpuActive)); - const currentScore = offensiveMatchupScore( - ourStats.type1 as Type, ourStats.type2 as Type, oppStats.type1 as Type, oppStats.type2 as Type, - ); - const qualified: { idx: number; matchup: number }[] = []; - for (let i = 0; i < switches.length; i++) { - const candStats = e.getMonStatsForBattle(bk, CPU_PLAYER_INDEX, BigInt(switches[i].extraData)); - const score = offensiveMatchupScore( - candStats.type1 as Type, candStats.type2 as Type, oppStats.type1 as Type, oppStats.type2 as Type, - ); - if (score > currentScore) qualified.push({ idx: i, matchup: score }); - } - if (qualified.length === 0) return -1; - - // Among the qualified candidates, pick the pivot TARGET by fork score when a reveal is available - // (type matchup alone repeatedly picked the wrong mon); without one, keep the best-matchup pick. - if (forkPick && qualified.length > 1) { - let best = qualified[0].idx; - let bestScore = -Infinity; - for (const q of qualified) { - const s = forkScoreAction(e, bk, forkPick.revealIdx, forkPick.revealExtra, switches[q.idx], forkPick.salt); - if (s > bestScore) { - bestScore = s; - best = q.idx; - } - } - return best; - } - let best = qualified[0]; - for (const q of qualified) if (q.matchup > best.matchup) best = q; - return best.idx; -} - -// --------------------------------------------------------------------------------------------- -// Fair-info pool analysis (no peek): worst case over the opponent's AFFORDABLE move pool. -// --------------------------------------------------------------------------------------------- - -/** - * Max damage the opponent could deal to a defender given a prepared `defendCtx` (opp -> defender) and - * their decoded metas, over the Physical/Special moves they can afford this turn. - */ -export function maxPoolDamage(defendCtx: DamageCalcContext, oppMetas: MoveMeta[], oppStamina: number): number { - let maxDmg = 0; - for (let i = 0; i < oppMetas.length; i++) { - const meta = oppMetas[i]; - if (Number(meta.stamina) > oppStamina) continue; - // Only Physical/Special moves deal damage. - if (meta.moveClass === MoveClass.Physical || meta.moveClass === MoveClass.Special) { - const dmg = estimateDamageMeta(defendCtx, meta); - if (dmg > maxDmg) maxDmg = dmg; - } - } - return maxDmg; -} - -/** - * Max priority across the opponent's AFFORDABLE move pool. Default-zero metas contribute priority 0 — - * the correct pessimistic floor. - */ -export function maxPoolPriority(oppMetas: MoveMeta[], oppStamina: number): number { - let maxPrio = 0; - for (let i = 0; i < oppMetas.length; i++) { - if (Number(oppMetas[i].stamina) > oppStamina) continue; - const p = Number(oppMetas[i].priority); - if (p > maxPrio) maxPrio = p; - } - return maxPrio; -} - -/** - * Evaluate every switch candidate against the opponent's worst-case-over-affordable-pool damage — - * max(static model, sim-measured) per candidate, apples-to-apples with a measured - * `worstIncomingDmgToActive`. Switch when (a) staying in is lethal AND a candidate survives the - * worst case, OR (b) staying-in damage% exceeds the best candidate's by >= SWITCH_THRESHOLD. - * Candidate forks only run past the severity gate, so safe turns cost nothing. - */ -export function evaluateDefensiveSwitchFair( - e: any, - bk: Hex, - activeMonIndex: number, - opponentMonIndex: number, - oppMetas: MoveMeta[], - worstIncomingDmgToActive: number, - switches: RevealedMove[], - severeDamagePct: number, - oppStamina: number, - salt: bigint, -): { shouldSwitch: boolean; switchIdx: number } { - // Our active mon's max + current HP. - const ourMaxHp = Number(e.getMonValueForBattle(bk, CPU_PLAYER_INDEX, BigInt(activeMonIndex), MonStateIndexName.Hp)); - const ourCurrentHp = monCurrentHp(e, bk, CPU_PLAYER_INDEX, activeMonIndex); - - // Damage% taken and whether staying in is lethal. - const damagePctToUs = ourMaxHp > 0 ? Math.floor((worstIncomingDmgToActive * 100) / ourMaxHp) : 0; - const lethalToUs = worstIncomingDmgToActive >= ourCurrentHp; - - // Below the severe threshold and not lethal => never switch defensively. - if (damagePctToUs < severeDamagePct && !lethalToUs) { - return { shouldSwitch: false, switchIdx: 0 }; - } - - let bestDamagePct = Number.MAX_SAFE_INTEGER; - let bestSwitchIdx = 0; - let bestSurvives = false; - - for (let i = 0; i < switches.length; i++) { - const candidateMonIndex = switches[i].extraData; - // opp(active) -> candidate: static worst over the affordable pool, raised by the sim measurement. - const ctx = buildDamageCalcContext(e, bk, OPP_PLAYER_INDEX, opponentMonIndex, CPU_PLAYER_INDEX, candidateMonIndex); - const candWorst = Math.max( - maxPoolDamage(ctx, oppMetas, oppStamina), - forkMeasurePoolThreat(e, bk, opponentMonIndex, oppMetas, oppStamina, candidateMonIndex, salt), - ); - - const maxHp = Number(e.getMonValueForBattle(bk, CPU_PLAYER_INDEX, BigInt(candidateMonIndex), MonStateIndexName.Hp)); - const candCurrentHp = monCurrentHp(e, bk, CPU_PLAYER_INDEX, candidateMonIndex); - - // damage% (huge sentinel when maxHp is 0 so it never wins the min). - const dmgPct = maxHp > 0 ? Math.floor((candWorst * 100) / maxHp) : Number.MAX_SAFE_INTEGER; - const survives = candWorst < candCurrentHp; - - if (dmgPct < bestDamagePct) { - // Track the candidate taking the least damage%. - bestDamagePct = dmgPct; - bestSwitchIdx = i; - bestSurvives = survives; - } - } - - // Lethal staying in and a candidate survives => switch. - if (lethalToUs && bestSurvives) return { shouldSwitch: true, switchIdx: bestSwitchIdx }; - // Staying-in damage% clears best by >= SWITCH_THRESHOLD => switch. - if (damagePctToUs >= bestDamagePct + SWITCH_THRESHOLD) return { shouldSwitch: true, switchIdx: bestSwitchIdx }; - - return { shouldSwitch: false, switchIdx: 0 }; -} - -/** - * Forced/fallback switch under fair info: the best worst-case sponge — least max-affordable-pool - * damage taken across the opp's whole move pool. Static only: this runs on forced-switch turns, - * where the engine doesn't execute the opponent's move, so a fork has nothing to measure. - */ -export function selectBestSwitchFair(e: any, bk: Hex, opponentMonIndex: number, switches: RevealedMove[]): RevealedMove { - const oppMetas = loadMonMetas(e, bk, OPP_PLAYER_INDEX, opponentMonIndex); - const oppStamina = monCurrentStamina(e, bk, OPP_PLAYER_INDEX, opponentMonIndex); - let bestIdx = 0; - let leastWorst = Number.MAX_SAFE_INTEGER; - for (let i = 0; i < switches.length; i++) { - const candidateMonIndex = switches[i].extraData; - const ctx = buildDamageCalcContext(e, bk, OPP_PLAYER_INDEX, opponentMonIndex, CPU_PLAYER_INDEX, candidateMonIndex); - const candWorst = maxPoolDamage(ctx, oppMetas, oppStamina); - if (candWorst < leastWorst) { - leastWorst = candWorst; - bestIdx = i; - } - } - return switches[bestIdx]; -} - -/** - * Is our KO move GUARANTEED to resolve — i.e. the opponent's revealed move cannot KO us first? - * True when the reveal is a switch/rest/non-damaging move, when its damage leaves us standing, or - * when we win the priority/speed race outright (ties play it safe and return false). - */ -export function koIsGuaranteed( - e: any, - bk: Hex, - view: BattleView, - metas: MoveMeta[], - koMove: RevealedMove, - playerMoveIndex: number, -): boolean { - if (playerMoveIndex >= SWITCH_MOVE_INDEX) return true; - - let oppSlot: bigint; - let oppClass: MoveClass; - try { - oppSlot = e.getMoveForMonForBattle(bk, OPP_PLAYER_INDEX, BigInt(view.oppActive), BigInt(playerMoveIndex)); - oppClass = moveSlotLib.moveClass(oppSlot, e, bk) as MoveClass; - } catch { - return true; // unreadable reveal can't be a damage threat - } - if (oppClass !== MoveClass.Physical && oppClass !== MoveClass.Special) return true; - - const ctxToUs = e.getDamageCalcContext(bk, OPP_PLAYER_INDEX, BigInt(view.oppActive), CPU_PLAYER_INDEX, BigInt(view.cpuActive)); - const damageToUs = estimateDamage(e, bk, ctxToUs, oppSlot, oppClass); - if (damageToUs < monCurrentHp(e, bk, CPU_PLAYER_INDEX, view.cpuActive)) return true; - - // They could KO us back — only guaranteed if we act first. - const ourPriority = Number(metas[koMove.moveIndex].priority); - const oppPriority = Number(moveSlotLib.priority(oppSlot, e, bk, OPP_PLAYER_INDEX)); - if (ourPriority !== oppPriority) return ourPriority > oppPriority; - return monCurrentSpeed(e, bk, CPU_PLAYER_INDEX, view.cpuActive) > monCurrentSpeed(e, bk, OPP_PLAYER_INDEX, view.oppActive); -} - -/** - * Measure OUR moves' damage by actually stepping the local sim forward, instead of trusting the - * static damage model. Differential measurement: fork the turn with (reveal, rest) as the baseline, - * then (reveal, move_i) per candidate — the defender's HP gap is move_i's REAL damage, with the - * engine adjudicating everything the static estimator can't see (variable-power moves, ability - * procs like Adaptor, the post-switch defender, speed races). Effects that tick in both forks - * cancel out of the differential. - * - * `revealIdx`/`revealExtra` is the opponent's (possibly voided) revealed action; NO_OP measures - * against the current defender at rest. One fixed `salt` drives every fork, so a sub-100-accuracy - * roll can miss and read 0 — callers should take max(static, measured) for offense and never use - * this for threat assessment (threats need the always-hit worst case). - */ -export function forkMeasureMoveDamages( - e: any, - bk: Hex, - revealIdx: number, - revealExtra: number, - moves: RevealedMove[], - salt: bigint, -): { damages: number[]; scores: number[]; defenderMonIndex: number } { - const p0: HypotheticalMove = - revealIdx === NO_OP_INDEX - ? { moveIndex: NO_OP_INDEX, salt, extraData: 0 } - : { moveIndex: revealIdx, salt, extraData: revealExtra }; - - const base = applyHypotheticalMove(e, bk, p0, { moveIndex: NO_OP_INDEX, salt, extraData: 0 }); - const defenderMonIndex = base.oppActive; - const baseHp = base.mons.p0[defenderMonIndex].hp; - disposeFork(e, base.bk); - - // Each move's fork yields its damage AND the resulting position's score — the same forks feed both - // the damage table and the eval-veto, so the veto is nearly free. - const scores: number[] = new Array(moves.length); - const damages = moves.map((m, i) => { - const child = applyHypotheticalMove(e, bk, p0, { moveIndex: m.moveIndex, salt, extraData: m.extraData }); - const dealt = baseHp - child.mons.p0[defenderMonIndex].hp; - scores[i] = scoreState(child); - disposeFork(e, child.bk); - return dealt > 0 ? dealt : 0; - }); - return { damages, scores, defenderMonIndex }; -} - -// The tree's pick must be beaten by at least this much (scoreState units; > one KO swing) before the -// eval-veto overrides it. The margin keeps the decision tree as the policy — with its long-horizon -// heuristics and persona — and only strips egregious single-turn blunders. Lower margins measurably -// underperform: frequent 1-ply overrides break the tree's cross-turn coherence even though each -// override scores better in isolation. -export const EVAL_OVERRIDE_MARGIN = 200; - -/** - * Eval-veto: fork-score every legal alternative against the same reveal and return one that beats the - * tree's `chosen` by >= {@link EVAL_OVERRIDE_MARGIN}, else null. Move forks were already paid for by - * {@link forkMeasureMoveDamages} (pass their `scores`); only switches and rest fork here. - */ -export function pickEvalOverride( - e: any, - bk: Hex, - revealIdx: number, - revealExtra: number, - chosen: RevealedMove, - moves: RevealedMove[], - moveScores: number[], - switches: RevealedMove[], - noOp: RevealedMove[], - salt: bigint, -): RevealedMove | null { - const p0: HypotheticalMove = - revealIdx === NO_OP_INDEX - ? { moveIndex: NO_OP_INDEX, salt, extraData: 0 } - : { moveIndex: revealIdx, salt, extraData: revealExtra }; - - let chosenScore: number | undefined; - let best: RevealedMove | null = null; - let bestScore = -Infinity; - - const consider = (m: RevealedMove, score: number) => { - if (m.moveIndex === chosen.moveIndex && m.extraData === chosen.extraData) { - chosenScore = score; - return; - } - if (score > bestScore) { - bestScore = score; - best = m; - } - }; - - for (let i = 0; i < moves.length; i++) consider(moves[i], moveScores[i]); - for (const m of [...switches, ...noOp]) { - const child = applyHypotheticalMove(e, bk, p0, { moveIndex: m.moveIndex, salt, extraData: m.extraData }); - const s = scoreState(child); - disposeFork(e, child.bk); - consider(m, s); - } - - if (chosenScore === undefined) { - // Chosen wasn't among the enumerated candidates (rng-drawn extraData target) — score it directly. - const child = applyHypotheticalMove(e, bk, p0, { moveIndex: chosen.moveIndex, salt, extraData: chosen.extraData }); - chosenScore = scoreState(child); - disposeFork(e, child.bk); - } - - return best !== null && bestScore >= chosenScore + EVAL_OVERRIDE_MARGIN ? best : null; -} - -/** - * EV-scale damages by each move's accuracy: a 120-power 85%-accuracy move is worth 102 expected - * damage, and an "85% guaranteed KO" correctly stops reading as guaranteed. Inline attacks always - * run DEFAULT_ACCURACY (100); external moves expose `accuracy(battleKey)` (unreadable => 100). - */ -export function evScaleDamages( - e: any, - bk: Hex, - monIndex: number, - moves: RevealedMove[], - damages: number[], -): number[] { - return damages.map((d, i) => { - if (d === 0) return 0; - const slot = e.getMoveForMonForBattle(bk, CPU_PLAYER_INDEX, BigInt(monIndex), BigInt(moves[i].moveIndex)); - if (slot === undefined || slot === null || moveSlotLib.isInline(slot)) return d; - try { - const move = moveSlotLib.toIMoveSet(slot) as any; - if (typeof move.accuracy !== 'function') return d; - const acc = Number(move.accuracy(bk)); - return acc >= 100 || acc <= 0 ? d : Math.floor((d * acc) / 100); - } catch { - return d; - } - }); -} - -/** - * Measured worst-case incoming damage from the opponent's AFFORDABLE pool, by stepping the sim: - * for each affordable opponent move, fork (oppMove, ourAction) against the (rest, ourAction) - * baseline and read OUR mon's HP gap. `switchTarget` is null to measure the current active staying - * in, or a bench index to measure a switch-in's entry damage. Catches threats the static model - * can't price (variable-power moves, damaging Self/Other utility moves, ability interactions). - * - * Deliberately NOT EV-scaled and meant to be combined as max(static, measured): threats assume the - * hit lands, and a fork's fixed-salt miss must never understate one. Useless on a forced-switch - * turn (the engine doesn't execute the opponent's move there — nothing to measure). - */ -export function forkMeasurePoolThreat( - e: any, - bk: Hex, - oppMonIndex: number, - oppMetas: MoveMeta[], - oppStamina: number, - switchTarget: number | null, - salt: bigint, -): number { - const ourAction: HypotheticalMove = - switchTarget === null - ? { moveIndex: NO_OP_INDEX, salt, extraData: 0 } - : { moveIndex: SWITCH_MOVE_INDEX, salt, extraData: switchTarget }; - - const baseline = applyHypotheticalMove(e, bk, { moveIndex: NO_OP_INDEX, salt, extraData: 0 }, ourAction); - const ourMon = switchTarget ?? baseline.cpuActive; - const baseHp = baseline.mons.p1[ourMon].hp; - disposeFork(e, baseline.bk); - - let worst = 0; - for (let j = 0; j < oppMetas.length; j++) { - if (Number(oppMetas[j].stamina) > oppStamina) continue; - const slot = e.getMoveForMonForBattle(bk, OPP_PLAYER_INDEX, BigInt(oppMonIndex), BigInt(j)); - if (slot === undefined || slot === null) continue; // padded slot past the mon's real move count - const child = applyHypotheticalMove(e, bk, { moveIndex: j, salt, extraData: 0 }, ourAction); - const dealt = baseHp - child.mons.p1[ourMon].hp; - disposeFork(e, child.bk); - if (dealt > worst) worst = dealt; - } - return worst; -} - -/** - * Measure ONE opponent move's damage to our side by stepping the sim: differential between the - * (rest, ourAction) baseline and (oppMove, ourAction). `switchTarget` null measures the current - * active staying in; a bench index measures that candidate's entry damage. The peek-side single-move - * sibling of {@link forkMeasurePoolThreat}: it sees what the static model can't (variable-power - * reveals, our own ability mitigation, switch-in procs). A fixed-salt fork can roll a miss, so - * threat-side callers keep the static estimate as the floor/fallback. - */ -export function forkMeasureIncomingDamage( - e: any, - bk: Hex, - oppMoveIndex: number, - oppExtraData: number, - switchTarget: number | null, - salt: bigint, -): number { - if (oppMoveIndex >= SWITCH_MOVE_INDEX) return 0; // a switch or rest deals nothing - - const ourAction: HypotheticalMove = - switchTarget === null - ? { moveIndex: NO_OP_INDEX, salt, extraData: 0 } - : { moveIndex: SWITCH_MOVE_INDEX, salt, extraData: switchTarget }; - - const baseline = applyHypotheticalMove(e, bk, { moveIndex: NO_OP_INDEX, salt, extraData: 0 }, ourAction); - const ourMon = switchTarget ?? baseline.cpuActive; - const baseHp = baseline.mons.p1[ourMon].hp; - disposeFork(e, baseline.bk); - - const child = applyHypotheticalMove(e, bk, { moveIndex: oppMoveIndex, salt, extraData: oppExtraData }, ourAction); - const dealt = baseHp - child.mons.p1[ourMon].hp; - disposeFork(e, child.bk); - return dealt > 0 ? dealt : 0; -} - -/** Fork one candidate action against the reveal and return the resulting position's score. */ -export function forkScoreAction( - e: any, - bk: Hex, - revealIdx: number, - revealExtra: number, - action: RevealedMove, - salt: bigint, -): number { - const p0: HypotheticalMove = - revealIdx === NO_OP_INDEX - ? { moveIndex: NO_OP_INDEX, salt, extraData: 0 } - : { moveIndex: revealIdx, salt, extraData: revealExtra }; - const child = applyHypotheticalMove(e, bk, p0, { moveIndex: action.moveIndex, salt, extraData: action.extraData }); - const s = scoreState(child); - disposeFork(e, child.bk); - return s; -} - -// --------------------------------------------------------------------------------------------- -// Risk-sensitive sampling: every fork is ONE RNG realization (crits, accuracy, branch moves), so an -// eval-driven root can sample several salts per candidate and choose by expected score — and tilt: -// ahead, prefer certainty (lock the win in); behind, prefer variance (the gamble is free). -// --------------------------------------------------------------------------------------------- - -// Distinct salt streams per turn — offsets just need to be co-prime-ish so the engine's per-salt -// RNG derivations don't correlate. -export const RISK_SALT_OFFSETS = [1n, 1009n, 2017n] as const; - -// Posture flips outside ±RISK_THRESHOLD (≈ a KO swing); λ scales how hard the tilt leans on spread. -const RISK_THRESHOLD = 120; -const RISK_LAMBDA = 0.5; - -/** +1 = ahead (prefer low variance), -1 = behind (prefer high variance), 0 = play the mean. */ -export function riskPosture(currentScore: number): -1 | 0 | 1 { - if (currentScore >= RISK_THRESHOLD) return 1; - if (currentScore <= -RISK_THRESHOLD) return -1; - return 0; -} - -/** Mean of the sampled fork scores, tilted by ±λ·σ per the posture. */ -export function riskAdjustedScore(samples: number[], posture: -1 | 0 | 1): number { - let mean = 0; - for (const s of samples) mean += s; - mean /= samples.length; - if (posture === 0 || samples.length < 2) return mean; - let variance = 0; - for (const s of samples) variance += (s - mean) * (s - mean); - const sd = Math.sqrt(variance / samples.length); - return mean + (posture > 0 ? -RISK_LAMBDA * sd : RISK_LAMBDA * sd); -} diff --git a/sims/src/cpu/heuristic-shared.ts b/sims/src/cpu/heuristic-shared.ts deleted file mode 100644 index b30cda24..00000000 --- a/sims/src/cpu/heuristic-shared.ts +++ /dev/null @@ -1,718 +0,0 @@ -import { Hex } from './hex'; -import { MoveClass, MonStateIndexName, Type } from '../../../transpiler/ts-output/Enums'; -import { moveSlotLib } from '../../../transpiler/ts-output/moves/MoveSlotLib'; -import { attackCalculator } from '../../../transpiler/ts-output/moves/AttackCalculator'; -import { typeCalcLib } from '../../../transpiler/ts-output/types/TypeCalcLib'; -import { createDefaultDamageCalcContext, createDefaultMoveMeta, DamageCalcContext, MoveMeta } from '../../../transpiler/ts-output/Structs'; -import { SWITCH_MOVE_INDEX } from './constants'; -import { RevealedMove, typeEffectiveness } from './engine-view'; - -/** - * Idiomatic TS port of `HeuristicCPUBase.sol` — the shared scoring / decision machinery that - * FairCPU and BetterCPU both build on. Heuristic-free candidate enumeration + readers live in - * `engine-view.ts`; THIS module is where the actual strategy helpers live (damage estimation, KO / - * best-damage move finders, lead selection, switch selection, per-mon-strategy helpers, the adaptive - * Hell/Tartarus/Diyu state machine, and the random-pick / momentum utilities). - * - * The CPU is ALWAYS p1 (playerIndex 1n); the human opponent is p0 (0n). `e` is the LOCAL transpiled - * engine. Solidity storage-packing / context-struct / MoveMeta-caching scaffolding is dropped per the - * FIDELITY MANDATE — but EVERY decision branch, threshold, probability, ordering and formula is - * reproduced exactly, each cited to its `HeuristicCPUBase.sol` source line. - * - * State that the Solidity contract kept in storage mappings is, in idiomatic TS, passed in / returned - * out explicitly: - * - `playerState[p0]` (the adaptive mode ladder) -> a plain `number` threaded through {@link recordResult}. - * - `monConfig[monIndex][key]` -> a {@link MonConfig} lookup the subclass owns. - * - `cpuMoveUsedBitmap[battleKey]` -> a plain `number` threaded through the configured-move helpers. - */ - -const CPU_PLAYER_INDEX = 1n; -const OPP_PLAYER_INDEX = 0n; - -// HeuristicCPUBase.sol:28 — moves within 15% of the best damage are "similar" (85% threshold). -export const SIMILAR_DAMAGE_THRESHOLD = 85; -// HeuristicCPUBase.sol:29 — a candidate must beat the current mon by >= 30 (matchup-score units) -// to justify switching. -export const SWITCH_THRESHOLD = 30; - -// HeuristicCPUBase.sol:32-34 — per-mode severe-damage thresholds (Hell/Tartarus/Diyu ladder). -export const SEVERE_DAMAGE_PCT_HELL = 30; -export const SEVERE_DAMAGE_PCT_TARTARUS = 50; -export const SEVERE_DAMAGE_PCT_DIYU = 60; - -// HeuristicCPUBase.sol:37-39 — adaptive state-machine modes. -export const MODE_HELL = 0; -export const MODE_TARTARUS = 1; -export const MODE_DIYU = 2; - -// HeuristicCPUBase.sol:42-45 — per-mon strategy config keys (values stored as moveIndex+1; 0 = unset). -export const CONFIG_PREFERRED_MOVE = 0; -export const CONFIG_SWITCH_IN_MOVE = 1; -export const CONFIG_SETUP_MOVE = 2; - -// --------------------------------------------------------------------------------------------- -// Adaptive state machine (HeuristicCPUBase.sol:66-100) -// --------------------------------------------------------------------------------------------- -// -// In Solidity this is `playerState[p0]`, a packed uint256 (bits 8-9 = mode, bit 10 = diyuPriorLoss). -// Here it is a plain `number` the subclass persists per-opponent. `recordResult` is the faithful port -// of `_recordResult` (+ the `_afterTurn` guard that only records once the battle has a winner). - -/** Read the current mode out of a packed playerState value. HeuristicCPUBase.sol:72 */ -export function modeOf(state: number): number { - return (state >> 8) & 0x3; -} - -/** Read the diyuPriorLoss flag out of a packed playerState value. HeuristicCPUBase.sol:73 */ -export function diyuPriorLossOf(state: number): boolean { - return ((state >> 10) & 0x1) !== 0; -} - -/** - * Port of `_recordResult`: advance the Hell/Tartarus/Diyu ladder after a battle. DIYU is sticky for - * one loss then resets to HELL; a DIYU win demotes to TARTARUS. Returns the new packed state. - * HeuristicCPUBase.sol:70-95 - */ -export function recordResult(state: number, cpuWon: boolean): number { - const mode = modeOf(state); // HeuristicCPUBase.sol:72 - const diyuPriorLoss = diyuPriorLossOf(state); // HeuristicCPUBase.sol:73 - - let newMode: number; - let newDiyuPriorLoss = false; - - if (mode === MODE_HELL) { - // HeuristicCPUBase.sol:78-79 — HELL: a loss drops to TARTARUS; a win stays HELL. - newMode = cpuWon ? MODE_HELL : MODE_TARTARUS; - } else if (mode === MODE_TARTARUS) { - // HeuristicCPUBase.sol:80-81 — TARTARUS: a win promotes back to HELL; a loss drops to DIYU. - newMode = cpuWon ? MODE_HELL : MODE_DIYU; - } else if (cpuWon) { - // HeuristicCPUBase.sol:82-83 — DIYU win: demote to TARTARUS. - newMode = MODE_TARTARUS; - } else if (diyuPriorLoss) { - // HeuristicCPUBase.sol:84-85 — DIYU second loss: reset to HELL. - newMode = MODE_HELL; - } else { - // HeuristicCPUBase.sol:86-88 — DIYU first loss: stay DIYU, mark the prior-loss flag. - newMode = MODE_DIYU; - newDiyuPriorLoss = true; - } - - // HeuristicCPUBase.sol:91-93 — repack mode (bits 8-9) + diyuPriorLoss (bit 10). - return (state & ~0x700) | (newMode << 8) | (newDiyuPriorLoss ? 1 << 10 : 0); -} - -// --------------------------------------------------------------------------------------------- -// Damage estimation (HeuristicCPUBase.sol:102-182) -// --------------------------------------------------------------------------------------------- - -/** - * Port of `_buildDamageCalcContext`: assemble a DamageCalcContext for ANY attacker/defender pair - * (not just the active mons). The local engine's `getMonStateForBattle` already normalizes the - * CLEARED_MON_STATE_SENTINEL to 0, so the Solidity `delta == SENTINEL ? 0 : delta` guard is a no-op - * here — the reads come back already clean. HeuristicCPUBase.sol:105-143 - */ -export function buildDamageCalcContext( - e: any, - bk: Hex, - attackerIndex: bigint, - attackerMonIndex: number, - defenderIndex: bigint, - defenderMonIndex: number, -): DamageCalcContext { - const attackerStats = e.getMonStatsForBattle(bk, attackerIndex, BigInt(attackerMonIndex)); - const defenderStats = e.getMonStatsForBattle(bk, defenderIndex, BigInt(defenderMonIndex)); - - const ctx = createDefaultDamageCalcContext(); - ctx.attackerMonIndex = BigInt(attackerMonIndex); - ctx.defenderMonIndex = BigInt(defenderMonIndex); - - // HeuristicCPUBase.sol:118-127 — attacker offensive stats (base + delta, sentinel-normalized). - ctx.attackerAttack = attackerStats.attack; - ctx.attackerAttackDelta = e.getMonStateForBattle(bk, attackerIndex, BigInt(attackerMonIndex), MonStateIndexName.Attack); - ctx.attackerSpAtk = attackerStats.specialAttack; - ctx.attackerSpAtkDelta = e.getMonStateForBattle(bk, attackerIndex, BigInt(attackerMonIndex), MonStateIndexName.SpecialAttack); - - // HeuristicCPUBase.sol:129-138 — defender defensive stats. - ctx.defenderDef = defenderStats.defense; - ctx.defenderDefDelta = e.getMonStateForBattle(bk, defenderIndex, BigInt(defenderMonIndex), MonStateIndexName.Defense); - ctx.defenderSpDef = defenderStats.specialDefense; - ctx.defenderSpDefDelta = e.getMonStateForBattle(bk, defenderIndex, BigInt(defenderMonIndex), MonStateIndexName.SpecialDefense); - - // HeuristicCPUBase.sol:141-142 — defender types. - ctx.defenderType1 = defenderStats.type1; - ctx.defenderType2 = defenderStats.type2; - return ctx; -} - -/** - * Port of `_estimateDamage`: deterministic damage estimate for a raw move slot against a prepared - * context. Inline slots read basePower directly; external attack moves expose `basePower(battleKey)` - * (non-attack / non-IAttackMove moves estimate to 0). Damage uses fixed accuracy=100 (always hits), - * volatility=0 (no variance), rng=50, critRate=0 so the estimate is stable. HeuristicCPUBase.sol:146-168 - */ -export function estimateDamage( - e: any, - bk: Hex, - ctx: DamageCalcContext, - rawMoveSlot: bigint, - moveClass: MoveClass, -): number { - let basePower: bigint; - if (moveSlotLib.isInline(rawMoveSlot)) { - basePower = moveSlotLib.basePower(rawMoveSlot, bk); // HeuristicCPUBase.sol:152-153 - } else { - // HeuristicCPUBase.sol:154-160 — external move: try IAttackMove.basePower, fall back to 0. - basePower = externalBasePower(e, bk, rawMoveSlot); - } - if (basePower === 0n) return 0; // HeuristicCPUBase.sol:161 - - const moveType = moveSlotLib.moveType(rawMoveSlot, e, bk) as Type; // HeuristicCPUBase.sol:163 - // HeuristicCPUBase.sol:165-166 — accuracy=100, volatility=0, rng=50, critRate=0. - // `_calculateDamageFromContext` only invokes `.getTypeEffectiveness` on its first arg; TypeCalculator - // delegates straight to typeCalcLib, so passing the lib is the faithful TYPE_CALC. - const [damage] = attackCalculator._calculateDamageFromContext( - typeCalcLib as any, - ctx, - basePower, - 100n, - 0n, - moveType, - moveClass, - 50n, - 0n, - ); - return damage > 0n ? Number(damage) : 0; // HeuristicCPUBase.sol:167 -} - -/** - * Port of `_estimateDamageMeta`: same as {@link estimateDamage} but reading basePower / moveType / - * moveClass off a pre-decoded MoveMeta (no per-call metadata reads). HeuristicCPUBase.sol:172-182 - */ -export function estimateDamageMeta(ctx: DamageCalcContext, meta: MoveMeta): number { - if (meta.basePower === 0n) return 0; // HeuristicCPUBase.sol:177 - const [damage] = attackCalculator._calculateDamageFromContext( - typeCalcLib as any, - ctx, - meta.basePower, - 100n, - 0n, - meta.moveType, - meta.moveClass, - 50n, - 0n, - ); // HeuristicCPUBase.sol:178-180 - return damage > 0n ? Number(damage) : 0; // HeuristicCPUBase.sol:181 -} - -/** - * Port of the `_getMoveBasePower` external branch (and the `_estimateDamage` external try/catch): - * read `basePower(battleKey)` off an external attack move, returning 0 for moves that don't expose it. - * HeuristicCPUBase.sol:483-492 - */ -function externalBasePower(e: any, bk: Hex, rawMoveSlot: bigint): bigint { - try { - const move = moveSlotLib.toIMoveSet(rawMoveSlot) as any; - if (typeof move.basePower !== 'function') return 0n; - const bp = move.basePower(bk); - return bp === undefined || bp === null ? 0n : BigInt(bp); - } catch { - return 0n; // HeuristicCPUBase.sol:157-158 / 489-490 — catch => 0 - } -} - -/** - * Port of `_getMoveBasePower`: basePower of a raw move slot, 0 for non-attack moves. Used by Diyu D4 - * free-turn detection. HeuristicCPUBase.sol:483-492 - */ -export function getMoveBasePower(e: any, bk: Hex, rawMoveSlot: bigint): number { - if (moveSlotLib.isInline(rawMoveSlot)) { - return Number(moveSlotLib.basePower(rawMoveSlot, bk)); // HeuristicCPUBase.sol:484-485 - } - return Number(externalBasePower(e, bk, rawMoveSlot)); // HeuristicCPUBase.sol:487-490 -} - -// --------------------------------------------------------------------------------------------- -// Move selection (HeuristicCPUBase.sol:184-274) -// --------------------------------------------------------------------------------------------- - -/** - * Decode a mon's four move-slot metas (the `metas` array the damage/KO/best-move helpers index by - * moveIndex). A missing slot (<4-move mon) or a decode failure yields a default all-zero meta - * (basePower 0 => contributes nothing). Shared by FairCPU's loaders and the greedy easy CPU; BetterCPU - * keeps its own `buildMetas` for strict 1:1 fidelity with its (non-catching) decode path. - */ -export function loadMonMetas(e: any, bk: Hex, playerIndex: bigint, monIndex: number): MoveMeta[] { - const metas: MoveMeta[] = []; - for (let i = 0; i < 4; i++) { - const slot = e.getMoveForMonForBattle(bk, playerIndex, BigInt(monIndex), BigInt(i)); - if (slot === undefined || slot === null) { - metas.push(createDefaultMoveMeta()); - continue; - } - try { - metas.push(moveSlotLib.decodeMeta(slot, e, bk, playerIndex, BigInt(monIndex))); - } catch { - metas.push(createDefaultMoveMeta()); - } - } - return metas; -} - -/** - * Port of `_computeMoveDamages`: outgoing damage for every Physical/Special move in `moves`, indexed - * parallel to `moves` (non-damaging moves => 0). Built once per turn and threaded through the KO / - * best-damage finders. `metas[moveIndex]` is the decoded MoveMeta for each of the active mon's 4 move - * slots. HeuristicCPUBase.sol:188-203 - */ -export function computeMoveDamages(ctx: DamageCalcContext, metas: MoveMeta[], moves: RevealedMove[]): number[] { - const damages = new Array(moves.length).fill(0); - for (let i = 0; i < moves.length; i++) { - const meta = metas[moves[i].moveIndex]; - // HeuristicCPUBase.sol:196 — only Physical / Special moves deal damage. - if (meta.moveClass === MoveClass.Physical || meta.moveClass === MoveClass.Special) { - damages[i] = estimateDamageMeta(ctx, meta); - } - } - return damages; -} - -/** - * Port of `_findKOMove`: index INTO `moves` of the cheapest-stamina move that KOs the opponent's - * defender, or -1 if none (or the defender is already at <=0 HP). Damages are pre-computed by - * {@link computeMoveDamages}. HeuristicCPUBase.sol:207-235 - */ -export function findKOMove( - e: any, - bk: Hex, - defenderMonIndex: number, - metas: MoveMeta[], - moves: RevealedMove[], - damages: number[], -): number { - // HeuristicCPUBase.sol:214-216 — defender CURRENT hp = base + delta (opponent is p0). - const defenderBaseHp = Number(e.getMonValueForBattle(bk, OPP_PLAYER_INDEX, BigInt(defenderMonIndex), MonStateIndexName.Hp)); - const defenderHpDelta = Number(e.getMonStateForBattle(bk, OPP_PLAYER_INDEX, BigInt(defenderMonIndex), MonStateIndexName.Hp)); - const defenderCurrentHp = defenderBaseHp + defenderHpDelta; - if (defenderCurrentHp <= 0) return -1; // HeuristicCPUBase.sol:217 - - let bestMoveIndex = -1; - let bestStaminaCost = Number.MAX_SAFE_INTEGER; - - for (let i = 0; i < moves.length; i++) { - // HeuristicCPUBase.sol:223-228 — among KO-capable moves, keep the cheapest stamina. - if (damages[i] >= defenderCurrentHp) { - const stamina = Number(metas[moves[i].moveIndex].stamina); - if (stamina < bestStaminaCost) { - bestStaminaCost = stamina; - bestMoveIndex = i; - } - } - } - return bestMoveIndex; -} - -/** - * Port of `_findBestDamageMove`: index INTO `moves` of the best damaging move, then a cheapest-stamina - * tiebreak across all moves within 85% of that best damage. -1 if no move deals damage. Damages - * pre-computed. HeuristicCPUBase.sol:238-274 - */ -export function findBestDamageMove(metas: MoveMeta[], moves: RevealedMove[], damages: number[]): number { - let bestMoveIndex = -1; - let bestDamage = 0; - let bestStaminaCost = Number.MAX_SAFE_INTEGER; - - // HeuristicCPUBase.sol:247-256 — first pass: strictly-greatest damage. - for (let i = 0; i < moves.length; i++) { - if (damages[i] > bestDamage) { - bestDamage = damages[i]; - bestStaminaCost = Number(metas[moves[i].moveIndex].stamina); - bestMoveIndex = i; - } - } - - if (bestDamage === 0) return bestMoveIndex; // HeuristicCPUBase.sol:258 - - // HeuristicCPUBase.sol:261-271 — cheapest-stamina tiebreak among moves within 85% of best damage. - const threshold = Math.floor((bestDamage * SIMILAR_DAMAGE_THRESHOLD) / 100); - for (let i = 0; i < moves.length; i++) { - const stamina = Number(metas[moves[i].moveIndex].stamina); - if (damages[i] >= threshold && stamina < bestStaminaCost) { - bestStaminaCost = stamina; - bestMoveIndex = i; - } - } - return bestMoveIndex; -} - -// --------------------------------------------------------------------------------------------- -// Lead selection (HeuristicCPUBase.sol:276-356) -// --------------------------------------------------------------------------------------------- - -/** - * Port of `_offensiveMatchupScore`: SUM of type-effectiveness (scale 10) for every candidate-type × - * opponent-type pair (candidate offense vs opponent defense). Single-type mons (`Type.None` second - * type) contribute only their real type. HeuristicCPUBase.sol:280-294 - */ -export function offensiveMatchupScore(candType1: Type, candType2: Type, oppType1: Type, oppType2: Type): number { - let score = typeEffectiveness(candType1, oppType1, 10); // HeuristicCPUBase.sol:283 - if (oppType2 !== Type.None) { - score += typeEffectiveness(candType1, oppType2, 10); // HeuristicCPUBase.sol:285 - } - if (candType2 !== Type.None) { - score += typeEffectiveness(candType2, oppType1, 10); // HeuristicCPUBase.sol:288 - if (oppType2 !== Type.None) { - score += typeEffectiveness(candType2, oppType2, 10); // HeuristicCPUBase.sol:290 - } - } - return score; -} - -/** - * Defensive matchup: SUM of type-effectiveness (scale 10) for every opponent-type × candidate-type - * pair (opponent offense vs candidate defense). This is the inline `defensiveScore` block inside - * `_selectLead`. HeuristicCPUBase.sol:319-328 - */ -export function defensiveMatchupScore(oppType1: Type, oppType2: Type, candType1: Type, candType2: Type): number { - let score = typeEffectiveness(oppType1, candType1, 10); // HeuristicCPUBase.sol:319 - if (candType2 !== Type.None) { - score += typeEffectiveness(oppType1, candType2, 10); // HeuristicCPUBase.sol:321 - } - if (oppType2 !== Type.None) { - score += typeEffectiveness(oppType2, candType1, 10); // HeuristicCPUBase.sol:324 - if (candType2 !== Type.None) { - score += typeEffectiveness(oppType2, candType2, 10); // HeuristicCPUBase.sol:326 - } - } - return score; -} - -/** - * Port of `_selectLead`: dual-type-scored lead pick among the turn-0 `switches`. `aggressive` weights - * offense more heavily (`3*off - def` vs balanced `off - def`), producing offensive leads in - * TARTARUS/DIYU vs balanced/defensive in HELL. Returns the chosen switch RevealedMove. - * HeuristicCPUBase.sol:298-356 - */ -export function selectLead( - e: any, - bk: Hex, - opponentMonExtraData: number, - switches: RevealedMove[], - aggressive: boolean, -): RevealedMove { - // HeuristicCPUBase.sol:304-306 — opponent's chosen lead types. - const oppStats = e.getMonStatsForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonExtraData)); - const oppType1 = oppStats.type1 as Type; - const oppType2 = oppStats.type2 as Type; - - let bestScore = -Infinity; // HeuristicCPUBase.sol:308 — type(int256).min - let bestIndex = 0; - - for (let i = 0; i < switches.length; i++) { - const candStats = e.getMonStatsForBattle(bk, CPU_PLAYER_INDEX, BigInt(switches[i].extraData)); - const candType1 = candStats.type1 as Type; - const candType2 = candStats.type2 as Type; - - const defensiveScore = defensiveMatchupScore(oppType1, oppType2, candType1, candType2); // HeuristicCPUBase.sol:318-328 - const offensiveScore = offensiveMatchupScore(candType1, candType2, oppType1, oppType2); // HeuristicCPUBase.sol:330-340 - - // HeuristicCPUBase.sol:342-344 — aggressive triples offense weight. - const score = aggressive ? 3 * offensiveScore - defensiveScore : offensiveScore - defensiveScore; - - if (score > bestScore) { - // HeuristicCPUBase.sol:346-349 — strict >, so ties keep the first (lowest index). - bestScore = score; - bestIndex = i; - } - } - return switches[bestIndex]; // HeuristicCPUBase.sol:355 -} - -// --------------------------------------------------------------------------------------------- -// Switch selection (HeuristicCPUBase.sol:358-427) -// --------------------------------------------------------------------------------------------- - -/** - * Port of `_selectBestSwitch`. Non-aggressive (HELL): the safest sponge — least estimated damage from - * the opponent's revealed move (falls back to `switches[0]` if the opponent is switching, or the - * revealed move isn't a readable Physical/Special attack). Aggressive (TARTARUS/DIYU revenge): the - * best offensive matchup against the opponent's active mon. Returns the chosen switch RevealedMove. - * HeuristicCPUBase.sol:363-427 - */ -export function selectBestSwitch( - e: any, - bk: Hex, - opponentMonIndex: number, - opponentMoveIndex: number, - switches: RevealedMove[], - aggressive: boolean, -): RevealedMove { - if (aggressive) { - // HeuristicCPUBase.sol:370-388 — best offensive matchup vs the opponent's active mon. - const oppStats = e.getMonStatsForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex)); - const oppType1 = oppStats.type1 as Type; - const oppType2 = oppStats.type2 as Type; - - let bestScore = -Infinity; - let bestIdx = 0; - for (let i = 0; i < switches.length; i++) { - const candStats = e.getMonStatsForBattle(bk, CPU_PLAYER_INDEX, BigInt(switches[i].extraData)); - const score = offensiveMatchupScore(candStats.type1 as Type, candStats.type2 as Type, oppType1, oppType2); - if (score > bestScore) { - bestScore = score; - bestIdx = i; - } - } - return switches[bestIdx]; - } - - // HeuristicCPUBase.sol:391-393 — opponent is switching (no readable attack): default to switches[0]. - if (opponentMoveIndex >= SWITCH_MOVE_INDEX) { - return switches[0]; - } - - // HeuristicCPUBase.sol:395-404 — read the opponent's revealed move; only Physical/Special are estimable. - let oppMoveSlot: bigint; - let oppMoveClass: MoveClass; - let canEstimate = false; - try { - oppMoveSlot = e.getMoveForMonForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex), BigInt(opponentMoveIndex)); - oppMoveClass = moveSlotLib.moveClass(oppMoveSlot, e, bk) as MoveClass; - canEstimate = oppMoveClass === MoveClass.Physical || oppMoveClass === MoveClass.Special; - } catch { - canEstimate = false; - } - - if (!canEstimate) { - return switches[0]; // HeuristicCPUBase.sol:406-408 - } - - // HeuristicCPUBase.sol:410-426 — pick the candidate that takes the LEAST damage from that move. - let bestIdx = 0; - let leastDamage = Number.MAX_SAFE_INTEGER; - for (let i = 0; i < switches.length; i++) { - const candidateMonIndex = switches[i].extraData; - const ctx = buildDamageCalcContext(e, bk, OPP_PLAYER_INDEX, opponentMonIndex, CPU_PLAYER_INDEX, candidateMonIndex); - const dmg = estimateDamage(e, bk, ctx, oppMoveSlot!, oppMoveClass!); - if (dmg < leastDamage) { - leastDamage = dmg; - bestIdx = i; - } - } - return switches[bestIdx]; -} - -// --------------------------------------------------------------------------------------------- -// Per-mon strategy (HeuristicCPUBase.sol:429-600) -// --------------------------------------------------------------------------------------------- - -/** - * Per-mon strategy config — the TS stand-in for `monConfig[monIndex][configKey]`. Values follow the - * Solidity convention: stored as `moveIndex + 1`, with `0`/absent meaning unset. - */ -export type MonConfig = { [monIndex: number]: { [configKey: number]: number } }; - -function readConfig(config: MonConfig, monIndex: number, key: number): number { - return config[monIndex]?.[key] ?? 0; -} - -/** - * Port of `_tryConfiguredMove`: try to play a per-mon configured move (switch-in or setup), marking - * the corresponding lane bit in `usedBitmap`. Returns `{ index, usedBitmap }` where `index` is the - * position INTO `moves`, or -1 if unconfigured / already used this switch-in / absent from `moves`. - * The (possibly mutated) bitmap is returned so the caller can persist it (no in-place storage). - * HeuristicCPUBase.sol:434-458 - */ -export function tryConfiguredMove( - config: MonConfig, - usedBitmap: number, - activeMonIndex: number, - moves: RevealedMove[], - configKey: number, - laneBitOffset: number, -): { index: number; usedBitmap: number } { - const configValue = readConfig(config, activeMonIndex, configKey); // HeuristicCPUBase.sol:441 - if (configValue === 0) return { index: -1, usedBitmap }; // HeuristicCPUBase.sol:442 - const targetMoveIndex = configValue - 1; // HeuristicCPUBase.sol:443 - - const laneBit = 1 << (activeMonIndex + laneBitOffset); // HeuristicCPUBase.sol:445 - if ((usedBitmap & laneBit) !== 0) return { index: -1, usedBitmap }; // HeuristicCPUBase.sol:446 — already used - - for (let i = 0; i < moves.length; i++) { - if (moves[i].moveIndex === targetMoveIndex) { - // HeuristicCPUBase.sol:450-451 — mark the lane bit and return the position. - return { index: i, usedBitmap: usedBitmap | laneBit }; - } - } - return { index: -1, usedBitmap }; // HeuristicCPUBase.sol:457 -} - -/** - * Port of `_clearMoveUsedBitsOnSwitchIn`: clear move-used bits on mon re-entry. The switch-in lane - * (bit `monIdx`) clears unconditionally; the setup lane (bit `monIdx + 8`) clears only when current - * HP is strictly above 50% — a low-HP re-entry shouldn't waste a turn setting up. Returns the new - * bitmap. HeuristicCPUBase.sol:463-479 - */ -export function clearMoveUsedBitsOnSwitchIn(e: any, bk: Hex, usedBitmap: number, monIdx: number): number { - const setupBit = 1 << (monIdx + 8); // HeuristicCPUBase.sol:465 - let newBitmap = usedBitmap & ~(1 << monIdx); // HeuristicCPUBase.sol:466 — switch-in lane clears unconditionally - - // HeuristicCPUBase.sol:469-476 — only consult HP if the setup lane bit is actually set. - if ((usedBitmap & setupBit) !== 0) { - const maxHp = Number(e.getMonValueForBattle(bk, CPU_PLAYER_INDEX, BigInt(monIdx), MonStateIndexName.Hp)); - const hpDelta = Number(e.getMonStateForBattle(bk, CPU_PLAYER_INDEX, BigInt(monIdx), MonStateIndexName.Hp)); - const currentHp = maxHp + hpDelta; - // HeuristicCPUBase.sol:473-475 — strictly above 50% (currentHp*2 > maxHp) clears the setup lane. - if (currentHp * 2 > maxHp) { - newBitmap &= ~setupBit; - } - } - return newBitmap; -} - -/** - * Port of `_tryPreferredMove`: use the per-mon CONFIG_PREFERRED_MOVE if set AND within 85% of the best - * damaging move's damage. Returns the position INTO `moves`, or -1 if unconfigured / preferred not - * present / below the threshold. (If no move deals damage, the preferred move is used as-is.) - * HeuristicCPUBase.sol:555-600 - */ -export function tryPreferredMove( - config: MonConfig, - activeMonIndex: number, - ctx: DamageCalcContext, - metas: MoveMeta[], - moves: RevealedMove[], -): number { - const configValue = readConfig(config, activeMonIndex, CONFIG_PREFERRED_MOVE); // HeuristicCPUBase.sol:561 - if (configValue === 0) return -1; // HeuristicCPUBase.sol:563 - const targetMoveIndex = configValue - 1; // HeuristicCPUBase.sol:564 - - let preferredIdx = -1; - let preferredDamage = 0; - let bestDamage = 0; - - for (let i = 0; i < moves.length; i++) { - const meta = metas[moves[i].moveIndex]; - // HeuristicCPUBase.sol:573-578 — skip non-damaging classes. - if (meta.moveClass !== MoveClass.Physical && meta.moveClass !== MoveClass.Special) { - continue; - } - const dmg = estimateDamageMeta(ctx, meta); // HeuristicCPUBase.sol:580 - if (dmg > bestDamage) bestDamage = dmg; // HeuristicCPUBase.sol:581 - if (moves[i].moveIndex === targetMoveIndex) { - preferredIdx = i; // HeuristicCPUBase.sol:584-585 - preferredDamage = dmg; - } - } - - if (preferredIdx < 0) return -1; // HeuristicCPUBase.sol:592 - if (bestDamage === 0) return preferredIdx; // HeuristicCPUBase.sol:593 — no damage reference - - // HeuristicCPUBase.sol:596-598 — preferred is "good enough" if within 85% of best damage. - if (preferredDamage * 100 >= bestDamage * SIMILAR_DAMAGE_THRESHOLD) { - return preferredIdx; - } - return -1; -} - -/** - * Port of `_tryFreeTurnMatchupSwitch`: pick a switch candidate whose offensive matchup against the - * opponent's active mon beats the current mon's by >= SWITCH_THRESHOLD. Returns the position INTO - * `switches`, or -1 when no candidate clears the bar (caller falls through to the best-damage - * default). HeuristicCPUBase.sol:519-552 - */ -export function tryFreeTurnMatchupSwitch( - e: any, - bk: Hex, - activeMonIndex: number, - opponentMonIndex: number, - switches: RevealedMove[], -): number { - const oppStats = e.getMonStatsForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex)); - const oppType1 = oppStats.type1 as Type; - const oppType2 = oppStats.type2 as Type; - - // HeuristicCPUBase.sol:527-531 — current mon's offensive matchup is the bar to beat. - const ourStats = e.getMonStatsForBattle(bk, CPU_PLAYER_INDEX, BigInt(activeMonIndex)); - const currentScore = offensiveMatchupScore(ourStats.type1 as Type, ourStats.type2 as Type, oppType1, oppType2); - - let bestScore = currentScore; // HeuristicCPUBase.sol:533 - let bestIdx = -1; - for (let i = 0; i < switches.length; i++) { - const candStats = e.getMonStatsForBattle(bk, CPU_PLAYER_INDEX, BigInt(switches[i].extraData)); - const score = offensiveMatchupScore(candStats.type1 as Type, candStats.type2 as Type, oppType1, oppType2); - if (score > bestScore) { - bestScore = score; - bestIdx = i; - } - } - - // HeuristicCPUBase.sol:548-550 — require a >= SWITCH_THRESHOLD improvement over current. - if (bestIdx >= 0 && bestScore >= currentScore + SWITCH_THRESHOLD) { - return bestIdx; - } - return -1; -} - -// --------------------------------------------------------------------------------------------- -// Utility (HeuristicCPUBase.sol:494-625) -// --------------------------------------------------------------------------------------------- - -/** Port of `_popcount8`: count set bits in an 8-bit bitmap. HeuristicCPUBase.sol:494-499 */ -export function popcount8(bitmap: number): number { - let count = 0; - for (let i = 0; i < 8; i++) { - if (((bitmap >> i) & 1) === 1) count++; - } - return count; -} - -/** - * Port of `_hasMomentum` (Diyu D4 step 4): the CPU has momentum if it has MORE mons alive, or — on a - * tie — its active mon has at least as much stamina as the opponent's active mon. `cpuActiveCurrentStamina` - * is the CPU active mon's current (base + delta) stamina the caller already has on hand (e.g. off the - * shared BattleView). HeuristicCPUBase.sol:503-514 - */ -export function hasMomentum( - e: any, - bk: Hex, - p1TeamSize: number, - p1KOBitmap: number, - p0TeamSize: number, - p0KOBitmap: number, - p0ActiveMonIndex: number, - cpuActiveCurrentStamina: number, -): boolean { - const ourAlive = p1TeamSize - popcount8(p1KOBitmap); // HeuristicCPUBase.sol:504 - const theirAlive = p0TeamSize - popcount8(p0KOBitmap); // HeuristicCPUBase.sol:505 - if (ourAlive > theirAlive) return true; // HeuristicCPUBase.sol:506 - if (ourAlive < theirAlive) return false; // HeuristicCPUBase.sol:507 - - // HeuristicCPUBase.sol:509-513 — tie: compare active-mon stamina (>= ours wins the tie). - const ourStam = cpuActiveCurrentStamina; - const theirBase = Number(e.getMonValueForBattle(bk, OPP_PLAYER_INDEX, BigInt(p0ActiveMonIndex), MonStateIndexName.Stamina)); - const theirDelta = Number(e.getMonStateForBattle(bk, OPP_PLAYER_INDEX, BigInt(p0ActiveMonIndex), MonStateIndexName.Stamina)); - const theirStam = theirBase + theirDelta; - return ourStam >= theirStam; -} - -/** - * Port of `_pickRandomValidOption`: pick uniformly across `noOp ++ moves ++ switches` (the valid - * action set `_calculateValidMoves` already produced). The Solidity uses the high bits of one rng word - * for the index so a separate trigger roll and this index don't share entropy — replicated here as a - * single fresh uniform draw over the union. HeuristicCPUBase.sol:612-625 - */ -export function pickRandomValidOption( - rng: () => number, - noOp: RevealedMove[], - moves: RevealedMove[], - switches: RevealedMove[], -): RevealedMove { - const total = noOp.length + moves.length + switches.length; // HeuristicCPUBase.sol:618 - let idx = Math.floor(rng() * total); // HeuristicCPUBase.sol:619 — (rng >> 8) % total, uniform over [0,total) - if (idx < noOp.length) return noOp[idx]; // HeuristicCPUBase.sol:620 - idx -= noOp.length; // HeuristicCPUBase.sol:621 - if (idx < moves.length) return moves[idx]; // HeuristicCPUBase.sol:622 - idx -= moves.length; // HeuristicCPUBase.sol:623 - return switches[idx]; // HeuristicCPUBase.sol:624 -} diff --git a/sims/src/cpu/hex.ts b/sims/src/cpu/hex.ts deleted file mode 100644 index db742391..00000000 --- a/sims/src/cpu/hex.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Local Hex type — replaces the `viem` import so the cpu stack has no external deps. -export type Hex = `0x${string}`; diff --git a/sims/src/cpu/index.ts b/sims/src/cpu/index.ts deleted file mode 100644 index f5365132..00000000 --- a/sims/src/cpu/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Hex } from './hex'; -import { makeRng } from './engine-view'; -import { captureBattleView } from './battle-view'; -import { CPU_STRATEGIES } from './registry'; -import { CpuStrategy, StrategyState } from './strategy'; - -export { CPU_STRATEGIES, registerCpuStrategy, getCpuStrategy } from './registry'; -export type { CpuStrategy, CpuMove, PlayerMove, StrategyState } from './strategy'; -export { BaseCpuStrategy } from './strategy'; - -/** - * Difficulty dispatcher for the local CPU strategies. Routes through {@link CPU_STRATEGIES}: - * easy -> EasyCpu (greedy attacker), medium -> MediumCpu, hard -> one of the HARD_POOL profiles. - * Each strategy's bag is lazily created once and threaded back into every `decide` call (hard's setup - * lane bits and stall's status bits persist there). - * - * The signature is UNCHANGED so existing callers (LocalCpuGameService) keep compiling. - * `playerMoveIndex` / `playerExtraData` form the human's (p0's) revealed move the CPU responds to; - * both default to 0 when omitted. The CPU is always p1. - */ - -// The hard difficulty cycles across the strongest peek-seat profiles (a statistical tie in the -// arena, three very different characters) so repeat battles don't face one learnable policy. The -// pick is battleKey-derived: stable for the whole battle and across reloads, no state to persist. -const HARD_POOL = ['hard', 'planner', 'greedy-risk'] as const; - -function resolveStrategyKey(difficulty: 'easy' | 'medium' | 'hard' | 'mirror' | 'greedy', bk: Hex): string { - if (difficulty !== 'hard') return difficulty; - return HARD_POOL[Number(BigInt(bk) % BigInt(HARD_POOL.length))]; -} - -// Lazily-created state bags, keyed by the (shared singleton) strategy instance — one bag per strategy, -// NOT per opponent. A strategy needing per-(opponent, battle) state must key on that itself. -const strategyState = new WeakMap(); - -function getOrInitState(strat: CpuStrategy): StrategyState { - let s = strategyState.get(strat); - if (s === undefined) { - s = strat.createState(); - strategyState.set(strat, s); - } - return s; -} - -export function pickCpuMove( - difficulty: 'easy' | 'medium' | 'hard' | 'mirror' | 'greedy', - e: any, - bk: Hex, - playerMoveIndex: number = 0, - playerExtraData: number = 0, - rng: () => number = makeRng(), -): { moveIndex: number; extraData: number } { - const strat = CPU_STRATEGIES.get(resolveStrategyKey(difficulty, bk)); - if (strat === undefined) { - // Difficulty is a closed union, so this is unreachable in typed callers; guard for safety. - throw new Error(`No CPU strategy registered for difficulty "${difficulty}"`); - } - const state = getOrInitState(strat); - // Capture the shared read-once snapshot ONCE per turn; every strategy reads the same view (it also - // carries `.engine`/`.bk` for readers not projected onto it). captureBattleView consumes no strategy rng. - const view = captureBattleView(e, bk); - return strat.decide(view, { moveIndex: playerMoveIndex, extraData: playerExtraData }, rng, state); -} diff --git a/sims/src/cpu/mon-meta.ts b/sims/src/cpu/mon-meta.ts deleted file mode 100644 index e9d15e58..00000000 --- a/sims/src/cpu/mon-meta.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * MonMetadata-equivalent for the arena, built from chomp's drool CSVs. Provides the fields the arena - * analysis reads: `.id`, `.name`, `.stats` (base stats), and `.moves[i].name` — the mon's full move - * catalog (learnset order) via `monCatalog`. The arena maps each played battle slot back to a catalog - * entry through the draft's `equip`, so `.moves` labels every move a mon could field, not just its - * default four. - */ -import { loadRoster } from '../util/csv-load'; -import { monCatalog } from '../arena/team'; - -export interface MonMeta { - id: number; - name: string; - stats: { - hp: number; - attack: number; - defense: number; - specialAttack: number; - specialDefense: number; - speed: number; - }; - moves: { name: string }[]; -} - -const roster = loadRoster(); - -export const MonMetadata: Record = {}; -for (const mon of roster.mons) { - MonMetadata[mon.id] = { - id: mon.id, - name: mon.name, - stats: { - hp: mon.hp, - attack: mon.attack, - defense: mon.defense, - specialAttack: mon.specialAttack, - specialDefense: mon.specialDefense, - speed: mon.speed, - }, - moves: monCatalog(roster, mon).map((s) => ({ name: s.name })), - }; -} diff --git a/sims/src/cpu/registry.ts b/sims/src/cpu/registry.ts deleted file mode 100644 index eef4e1d0..00000000 --- a/sims/src/cpu/registry.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CpuStrategy } from './strategy'; -import { HardCpu } from './strategies/hard-cpu'; -import { GreedyEvalCpu } from './strategies/greedy-eval'; -import { OverrideCpu } from './strategies/override-cpu'; -import { SearchCpu } from './strategies/search-cpu'; - -/** - * CPU strategy registry — the strategies the balance arena exercises: - * - `hard` -> HardCpu (peek + best-respond, sim-measured damage, guarded setup punishment) - * - `greedy` -> GreedyEvalCpu (1-ply forward-model + evaluator best response) - * - `override` -> OverrideCpu (scripted per-mon plans, else delegates to hard) - * - `search` -> SearchCpu d2, no-peek (maximin matrix game — production reveal semantics) - * - `search-peek` -> SearchCpu d2, peek-at-root (best-respond to the reveal, maximin deeper) - * - * Strategy INSTANCES are shared singletons; per-battle MUTABLE state lives in the `StrategyState` bag - * the caller owns (`createState()` + thread it back into `decide`), so one instance is safe across battles. - */ -export const CPU_STRATEGIES = new Map([ - ['hard', new HardCpu()], - ['greedy', new GreedyEvalCpu()], - ['override', new OverrideCpu()], - ['search', new SearchCpu('search', 2, false)], - ['search-peek', new SearchCpu('search-peek', 2, true)], -]); - -/** Register (or replace) a strategy under `key`. Returns the registry for chaining. */ -export function registerCpuStrategy(key: string, strategy: CpuStrategy): Map { - return CPU_STRATEGIES.set(key, strategy); -} - -/** Look up a strategy by key, or `undefined` if none is registered. */ -export function getCpuStrategy(key: string): CpuStrategy | undefined { - return CPU_STRATEGIES.get(key); -} diff --git a/sims/src/cpu/strategies/greedy-eval.ts b/sims/src/cpu/strategies/greedy-eval.ts deleted file mode 100644 index 3068a8f5..00000000 --- a/sims/src/cpu/strategies/greedy-eval.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Hex } from '../hex'; -import { BaseCpuStrategy, CpuMove, PlayerMove, StrategyState } from '../strategy'; -import { BattleView } from '../battle-view'; -import { RevealedMove } from '../engine-view'; -import { applyHypotheticalMove, disposeFork, HypotheticalMove } from '../forward-model'; -import { DEFAULT_EVAL_WEIGHTS, EvalWeights, scoreStateWith } from '../evaluator'; -import { RISK_SALT_OFFSETS, riskAdjustedScore, riskPosture } from '../heuristic-native'; - -/** - * GREEDY (eval) — 1-ply best response on the search kit (forward-model + evaluator). NOT a faithful - * Solidity port; the only contract is that it returns a LEGAL move. - * - * Algorithm (from the CPU's (p1's) point of view): - * 1. Enumerate every legal CPU action via the shared `validMoves` buckets (moves + switches + noOp). - * 2. For each candidate, fork the live battle and replay ONE turn with the human's REVEALED move as - * p0 and the candidate as p1 (`applyHypotheticalMove`). On a forced-switch turn (p1-only) the - * human doesn't act, so p0 is passed as `null`. - * 3. Score the resulting position (higher = better for the CPU); keep every candidate tied for best - * and break ties uniformly with the injected rng. - * - * Options (the plain `greedy` key uses none): - * - `salts: k` — RISK-AWARE mode: sample each candidate with k salt streams and choose by - * risk-adjusted score (mean, tilted toward certainty when ahead / variance when behind). One fork - * is one RNG realization, so multi-salt sampling also de-noises crits and branchy moves. - * - `weights` — score with a custom {@link EvalWeights} (A/B harness for fitted weights). - * - `evalFn` — replace the position scorer entirely (e.g. the race-matrix `ttkEval`). - * - * The live battle is never mutated — every candidate runs on a throwaway fork. - */ -export class GreedyEvalCpu extends BaseCpuStrategy { - readonly name: string; - private readonly salts: number; - private readonly score: (view: BattleView) => number; - - constructor(opts: { salts?: number; weights?: EvalWeights; evalFn?: (view: BattleView) => number; tag?: string } = {}) { - super(); - this.salts = opts.salts ?? 1; - const weights = opts.weights ?? DEFAULT_EVAL_WEIGHTS; - this.score = opts.evalFn ?? (v => scoreStateWith(v, weights)); - const tags = [ - this.salts > 1 ? `risk k=${this.salts}` : '', - opts.weights ? 'tuned' : '', - opts.tag ?? '', - ].filter(Boolean); - this.name = `Greedy (eval${tags.length ? ', ' + tags.join(', ') : ''})`; - } - - override decide(view: BattleView, pm: PlayerMove, rng: () => number, _state: StrategyState): CpuMove { - const e = view.engine; - const bk = view.bk; - // Candidate CPU actions — pass rng so Self/Opponent-index extraData targets are drawn the same - // way the faithful ports draw them. - const { noOp, moves, switches } = this.validMoves(e, bk as Hex, rng); - const candidates: RevealedMove[] = [...moves, ...switches, ...noOp]; - if (candidates.length === 0) { - return { moveIndex: noOp.length > 0 ? noOp[0].moveIndex : 126, extraData: 0 }; - } - - // Salt streams: the single-sample default keeps the original fixed salt; risk mode draws k - // turn-seeded streams. Posture comes from the CURRENT position (pure-view, no fork). - const turnSalt = BigInt(Number(e.getTurnIdForBattleState(bk)) + 1); - const saltList: bigint[] = - this.salts <= 1 ? [0n] : RISK_SALT_OFFSETS.slice(0, this.salts).map(o => turnSalt * 4099n + o); - const posture = this.salts <= 1 ? 0 : riskPosture(this.score(view)); - - // The human (p0) move this turn. On a forced-switch turn (switchFlag === 1) p0 does not act. - const p0Acts = view.switchFlag !== 1; - - let best: RevealedMove[] = []; - let bestScore = -Infinity; - for (const cand of candidates) { - const samples: number[] = []; - for (const salt of saltList) { - const p0Move: HypotheticalMove | null = p0Acts - ? { moveIndex: pm.moveIndex, salt, extraData: pm.extraData } - : null; - const resultView = applyHypotheticalMove(e, bk as Hex, p0Move, { - moveIndex: cand.moveIndex, - salt, - extraData: cand.extraData, - }); - samples.push(this.score(resultView)); - disposeFork(e, resultView.bk); - } - const score = riskAdjustedScore(samples, posture); - if (score > bestScore) { - bestScore = score; - best = [cand]; - } else if (score === bestScore) { - best.push(cand); - } - } - - const chosen = this.pickUniform(best, rng) ?? best[0]; - return { moveIndex: chosen.moveIndex, extraData: chosen.extraData }; - } -} diff --git a/sims/src/cpu/strategies/hard-cpu.ts b/sims/src/cpu/strategies/hard-cpu.ts deleted file mode 100644 index 0fc52534..00000000 --- a/sims/src/cpu/strategies/hard-cpu.ts +++ /dev/null @@ -1,617 +0,0 @@ -import { Hex } from '../hex'; -import { MoveClass, MonStateIndexName } from '../../../../transpiler/ts-output/Enums'; -import { moveSlotLib } from '../../../../transpiler/ts-output/moves/MoveSlotLib'; -import { MoveMeta } from '../../../../transpiler/ts-output/Structs'; -import { SWITCH_MOVE_INDEX, NO_OP_INDEX } from '../constants'; -import { - RevealedMove, - moveSlot, - monCurrentHp as currentHp, - monCurrentSpeed, -} from '../engine-view'; -import { BattleView } from '../battle-view'; -import { BaseCpuStrategy, CpuMove, PlayerMove, StrategyState } from '../strategy'; -import { - SEVERE_DAMAGE_PCT_HELL, - SWITCH_THRESHOLD, - CONFIG_SWITCH_IN_MOVE, - CONFIG_SETUP_MOVE, - MonConfig, - buildDamageCalcContext, - estimateDamage, - computeMoveDamages, - findKOMove, - findBestDamageMove, - selectLead, - selectBestSwitch, - tryConfiguredMove, - clearMoveUsedBitsOnSwitchIn, - tryPreferredMove, - getMoveBasePower, - hasMomentum, -} from '../heuristic-shared'; -import { - antiWallSwitch, - evScaleDamages, - forkMeasureIncomingDamage, - forkMeasureMoveDamages, - forkScoreAction, - pickEvalOverride, - pickSimilarDamageMove, - WALL_DAMAGE_PCT, -} from '../heuristic-native'; - -/** - * HARD CPU — best-response with foreknowledge. - * - * A TS-native fork of the BetterCPU decision engine, frozen into ONE fixed policy. It PEEKS at the - * human's already-revealed move (the off-chain CPU replies after the commit) and best-responds: KO if - * we can and survive the race, defensive-switch on a real threat, else play the best damage. - * - * Relative to the BetterCPU HELL best-response baseline: - * - drops the Hell/Tartarus/Diyu mode ladder + the Tartarus chaos roll; - * - measures BOTH sides by STEPPING THE SIM instead of trusting the static model alone: its own - * damage (forkMeasureMoveDamages: max(static, measured), accuracy-EV-scaled) and the reveal's - * damage to it / to each switch candidate (forkMeasureIncomingDamage, static as the miss - * fallback) — variable-power moves, ability procs and switch-in effects all read true; - * - voids a zapped opponent's reveal (ShouldSkipTurn): the telegraphed action will never execute, - * so the whole tree plays the free turn against the mon that actually stays in; - * - folds in the >=90% near-KO bypass (stay in to finish a kill instead of switching away); - * - ADDS an anti-wall stalemate-breaker (P5.5): pivot out of a matchup our active mon can't damage, - * which the threat-based defensive switch misses; - * - runs the Diyu free-turn setup punishment (P4.5) always-on, made safe by dropping the matchup - * pivot and guarding setup behind momentum + a productive matchup (see freeTurnPick) — the - * unguarded original stalled whole games; - * - persists the configured-move lane bitmap across turns (StrategyState, keyed by battle) so setup - * fires once per switch-in like the on-chain cpuMoveUsedBitmap, not once per turn; - * - default damage picks sample the 85% similar-damage band instead of always cheapest-stamina, so - * the policy can't be scripted move-for-move across repeat games; - * - an eval-veto (pickEvalOverride) strips egregious single-turn blunders: the tree's pick stands - * unless a forked alternative beats it by more than a KO swing (EVAL_OVERRIDE_MARGIN). - * - * The CPU is ALWAYS p1; the opponent is p0. - */ - -const CPU_PLAYER_INDEX = 1n; -const OPP_PLAYER_INDEX = 0n; - -// SWITCH priority in the Engine's priority comparison. -const SWITCH_PRIORITY = 6; - -/** - * Per-mon setup-move config (the Diyu "free-turn" setup moves the free-turn + P3/P4/P6 logic uses). - * Values follow the on-chain convention: stored as `moveIndex + 1` (0 / absent = unset). Only - * CONFIG_SETUP_MOVE is configured; CONFIG_SWITCH_IN_MOVE / CONFIG_PREFERRED_MOVE are left unset (so - * `tryConfiguredMove(CONFIG_SWITCH_IN_MOVE)` and `tryPreferredMove` are inert unless a caller supplies - * more config). - */ -export const BETTER_CPU_MON_CONFIG: MonConfig = { - 1: { [CONFIG_SETUP_MOVE]: 2 }, // Inutia -> Initialize (slot 1) - 2: { [CONFIG_SETUP_MOVE]: 1 }, // Malalien -> Triple Think (slot 0) - 3: { [CONFIG_SETUP_MOVE]: 2 }, // Iblivion -> Loop (slot 1) - 6: { [CONFIG_SETUP_MOVE]: 2 }, // Pengym -> Deadlift (slot 1) - 7: { [CONFIG_SETUP_MOVE]: 3 }, // Embursa -> Heat Beacon (slot 2) - 9: { [CONFIG_SETUP_MOVE]: 3 }, // Aurox -> Iron Wall (slot 2) - 11: { [CONFIG_SETUP_MOVE]: 3 }, // Ekineki -> Nine Nine Nine (slot 2) - 12: { [CONFIG_SETUP_MOVE]: 1 }, // Nirvamma -> Hard Reset (slot 0) -}; - -const RET = (m: RevealedMove): CpuMove => ({ - moveIndex: m.moveIndex, - extraData: m.extraData, -}); - -// --------------------------------------------------------------------------------------------- -// metas — decode the active mon's four move-slot metas. -// --------------------------------------------------------------------------------------------- - -/** - * Decode the active mon's four move-slot metas exactly as `_calculateValidMoves` does. Reads the four - * slots directly off the engine (slots past the mon's real move count read as undefined → 0n, which - * decodes to a default all-zero meta — basePower 0, MoveClass.Physical(0) — harmless because those - * padded slots never enter the `moves` bucket). Always-length-4 fixed array. - */ -function buildMetas(e: any, bk: Hex, activeMonIndex: number): MoveMeta[] { - const metas: MoveMeta[] = new Array(4); - for (let i = 0; i < 4; i++) { - const slot = moveSlot(e, bk, CPU_PLAYER_INDEX, activeMonIndex, i) ?? 0n; - metas[i] = moveSlotLib.decodeMeta(slot, e, bk, CPU_PLAYER_INDEX, BigInt(activeMonIndex)); - } - return metas; -} - -// ============ SPEED / PRIORITY CHECK ============ - -/** - * `weGoFirst` — mirrors Engine.computePriorityPlayerIndex. Higher priority goes first; on a priority - * tie the faster mon goes first; a speed tie or being slower returns false (play safe). - */ -function weGoFirst( - e: any, - bk: Hex, - metas: MoveMeta[], - ourMonIndex: number, - opponentMonIndex: number, - ourMoveIndex: number, - opponentMoveIndex: number, -): boolean { - // Our priority: SWITCH_PRIORITY for a switch, else the decoded meta priority. - let ourPriority: number; - if (ourMoveIndex >= SWITCH_MOVE_INDEX) { - ourPriority = SWITCH_PRIORITY; - } else { - ourPriority = Number(metas[ourMoveIndex].priority); - } - - // Opponent priority: SWITCH_PRIORITY for a switch, else read their raw slot. - let oppPriority: number; - if (opponentMoveIndex >= SWITCH_MOVE_INDEX) { - oppPriority = SWITCH_PRIORITY; - } else { - const rawOppMove = e.getMoveForMonForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex), BigInt(opponentMoveIndex)); - oppPriority = Number(moveSlotLib.priority(rawOppMove, e, bk, OPP_PLAYER_INDEX)); - } - - if (ourPriority > oppPriority) return true; - if (ourPriority < oppPriority) return false; - - // Same priority: compare speeds (base + delta, sentinel already normalized). - const ourSpeed = monCurrentSpeed(e, bk, CPU_PLAYER_INDEX, ourMonIndex); - const oppSpeed = monCurrentSpeed(e, bk, OPP_PLAYER_INDEX, opponentMonIndex); - - if (ourSpeed > oppSpeed) return true; - return false; // speed tie or slower => play it safe -} - -/** - * `canOpponentKOUs`: does the opponent's specific chosen move KO our active mon? `damageToUs` is the - * pre-hoisted max(static, sim-measured) reveal damage — 0 for a switch/rest/harmless move, and real - * for damaging utility moves the static class gate can't price. - */ -function canOpponentKOUs( - e: any, - bk: Hex, - playerMonIndex: number, - opponentMoveIndex: number, - damageToUs: number, -): boolean { - if (opponentMoveIndex >= SWITCH_MOVE_INDEX) return false; - return damageToUs > 0 && damageToUs >= currentHp(e, bk, CPU_PLAYER_INDEX, playerMonIndex); -} - -// ============ DEFENSIVE SWITCH EVALUATION ============ - -/** - * `checkKOBypass`: true if our best damaging move would deal at least 90% of the opponent's CURRENT HP - * (±10% tolerance) AND we outspeed — in which case we stay in for the kill rather than swap out under - * heavy incoming damage. - */ -function checkKOBypass( - e: any, - bk: Hex, - metas: MoveMeta[], - activeMonIndex: number, - opponentMonIndex: number, - moves: RevealedMove[], - damages: number[], - playerMoveIndex: number, -): boolean { - const bestIdx = findBestDamageMove(metas, moves, damages); - if (bestIdx < 0) return false; - - const bestDmg = damages[bestIdx]; - if (bestDmg === 0) return false; - - const oppCurrentHp = currentHp(e, bk, OPP_PLAYER_INDEX, opponentMonIndex); - if (oppCurrentHp <= 0) return false; - - // ±10% tolerance: bestDmg >= 90% of opp current HP (bestDmg*10 >= oppHp*9). - if (bestDmg * 10 < oppCurrentHp * 9) return false; - - return weGoFirst(e, bk, metas, activeMonIndex, opponentMonIndex, moves[bestIdx].moveIndex, playerMoveIndex); -} - -/** - * `findBestSwitchCandidate`: among the switch candidates, find the one taking the LEAST damage % from - * the opponent's move, returning its index, damage %, and a survives flag. - */ -function findBestSwitchCandidate( - e: any, - bk: Hex, - opponentMonIndex: number, - opponentMoveIndex: number, - opponentExtraData: number, - oppMoveSlot: bigint | undefined, - oppMoveClass: MoveClass | undefined, - switches: RevealedMove[], - salt: bigint, -): { bestIdx: number; bestDamagePct: number; bestSurvives: boolean } { - let bestIdx = 0; - let bestDamagePct = Number.MAX_SAFE_INTEGER; - let bestSurvives = false; - - for (let i = 0; i < switches.length; i++) { - const candidateMonIndex = switches[i].extraData; - // opp (active) attacking the candidate: static estimate when readable, raised by the sim-measured - // entry damage (switch-in procs, variable-power moves the static model reads as 0). - const canEstimate = - oppMoveSlot !== undefined && (oppMoveClass === MoveClass.Physical || oppMoveClass === MoveClass.Special); - const ctx = buildDamageCalcContext(e, bk, OPP_PLAYER_INDEX, opponentMonIndex, CPU_PLAYER_INDEX, candidateMonIndex); - const dmg = Math.max( - canEstimate ? estimateDamage(e, bk, ctx, oppMoveSlot!, oppMoveClass!) : 0, - forkMeasureIncomingDamage(e, bk, opponentMoveIndex, opponentExtraData, candidateMonIndex, salt), - ); - - const maxHp = Number(e.getMonValueForBattle(bk, CPU_PLAYER_INDEX, BigInt(candidateMonIndex), MonStateIndexName.Hp)); - const curHp = currentHp(e, bk, CPU_PLAYER_INDEX, candidateMonIndex); - - // packed damagePct + survives bit; maxHp==0 => a huge sentinel pct. - const damagePct = maxHp > 0 ? Math.floor((dmg * 100) / maxHp) : Number.MAX_SAFE_INTEGER; - const survives = dmg < curHp; - - if (damagePct < bestDamagePct) { - bestDamagePct = damagePct; - bestIdx = i; - bestSurvives = survives; - } - } - return { bestIdx, bestDamagePct, bestSurvives }; -} - -/** - * `evaluateDefensiveSwitch`. `severeDamagePct` is the damage threshold below which incoming damage is - * ignored unless lethal. `koBypassFires` short-circuits (about to KO + outspeed => eat the hit). Takes - * the pre-hoisted opp-threat computation (oppMoveSlot / oppMoveClass / ctxToUs / damageToUs) so they - * aren't recomputed against P2. Returns `{ shouldSwitch, switchIdx }`. - */ -function evaluateDefensiveSwitch( - e: any, - bk: Hex, - activeMonIndex: number, - opponentMonIndex: number, - opponentMoveIndex: number, - opponentExtraData: number, - switches: RevealedMove[], - severeDamagePct: number, - koBypassFires: boolean, - oppMoveSlot: bigint | undefined, - oppMoveClass: MoveClass | undefined, - damageToUs: number, - salt: bigint, -): { shouldSwitch: boolean; switchIdx: number } { - if (koBypassFires) return { shouldSwitch: false, switchIdx: 0 }; - if (opponentMoveIndex >= SWITCH_MOVE_INDEX) return { shouldSwitch: false, switchIdx: 0 }; - - // No threat estimated OR measured => never switch defensively. - if (damageToUs <= 0) return { shouldSwitch: false, switchIdx: 0 }; - - // damage % to our active mon + lethality; bail if not severe and not lethal. - const ourMaxHp = Number(e.getMonValueForBattle(bk, CPU_PLAYER_INDEX, BigInt(activeMonIndex), MonStateIndexName.Hp)); - const ourCurHp = currentHp(e, bk, CPU_PLAYER_INDEX, activeMonIndex); - - const damagePctToUs = Math.floor((damageToUs * 100) / ourMaxHp); - const lethalToUs = damageToUs >= ourCurHp; - - if (damagePctToUs < severeDamagePct && !lethalToUs) return { shouldSwitch: false, switchIdx: 0 }; - - // Find the safest switch-in. - const { bestIdx, bestDamagePct, bestSurvives } = findBestSwitchCandidate( - e, bk, opponentMonIndex, opponentMoveIndex, opponentExtraData, oppMoveSlot, oppMoveClass, switches, salt, - ); - - // Materiality check. - if (lethalToUs && bestSurvives) return { shouldSwitch: true, switchIdx: bestIdx }; - if (damagePctToUs >= bestDamagePct + SWITCH_THRESHOLD) return { shouldSwitch: true, switchIdx: bestIdx }; - return { shouldSwitch: false, switchIdx: 0 }; -} - -// ============ FREE-TURN DETECTION ============ - -/** - * `isFreeTurnReveal`: opponent revealed a 0-power Self/Other move (setup, heal, hazard) — the - * free-turn punishment trigger gate. - */ -function isFreeTurnReveal(e: any, bk: Hex, opponentMonIndex: number, playerMoveIndex: number): boolean { - if (playerMoveIndex >= SWITCH_MOVE_INDEX) return false; - try { - const slot = e.getMoveForMonForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex), BigInt(playerMoveIndex)); - const oppClass = moveSlotLib.moveClass(slot, e, bk) as MoveClass; - // Only Other / Self moves qualify. - if (oppClass !== MoveClass.Other && oppClass !== MoveClass.Self) return false; - return getMoveBasePower(e, bk, slot) === 0; - } catch { - return false; - } -} - -/** - * `freeTurnPick` (free-turn punishment decision tree). Order: configured switch-in move -> 2HKO damage - * move -> guarded configured setup move. Returns `{ picked, move, moveUsedBitmap }`; when `picked` is - * false the caller falls through to P5. - * - * Two deliberate departures from the Diyu original keep this safe to run always-on (the old always-on - * version stalled whole games): the free-turn matchup PIVOT is dropped (trading tempo on every - * telegraphed free turn is what steered games into stalemates; a true wall is P5.5's job), and the - * setup branch only fires when the active mon can actually damage the opponent — setting up in front - * of something we can't hurt converts the free turn into a stall of our own. - */ -function freeTurnPick( - view: BattleView, - config: MonConfig, - moveUsedBitmap: number, - metas: MoveMeta[], - moves: RevealedMove[], - damages: number[], -): { picked: boolean; move?: RevealedMove; moveUsedBitmap: number } { - const e = view.engine; - const bk = view.bk as Hex; - const activeMonIndex = view.cpuActive; - const opponentMonIndex = view.oppActive; - - // Configured switch-in move on this safe turn. - let res = tryConfiguredMove(config, moveUsedBitmap, activeMonIndex, moves, CONFIG_SWITCH_IN_MOVE, 0); - moveUsedBitmap = res.usedBitmap; - if (res.index >= 0) return { picked: true, move: moves[res.index], moveUsedBitmap }; - - const bestIdx = findBestDamageMove(metas, moves, damages); - const bestDmg = bestIdx >= 0 ? damages[bestIdx] : 0; - const oppCurrentHp = currentHp(e, bk, OPP_PLAYER_INDEX, opponentMonIndex); - // 2HKO uses opp CURRENT HP (a damaged opp is easier to finish). - if (bestIdx >= 0 && oppCurrentHp > 0 && bestDmg * 2 >= oppCurrentHp) { - return { picked: true, move: moves[bestIdx], moveUsedBitmap }; - } - - // Setup only with momentum AND a matchup we can actually make progress in. - const productive = oppCurrentHp > 0 && bestDmg * 100 >= oppCurrentHp * WALL_DAMAGE_PCT; - if ( - productive && - hasMomentum(e, bk, view.mons.p1.length, view.cpuKO, view.mons.p0.length, view.oppKO, view.oppActive, view.mons.p1[view.cpuActive].stamina) - ) { - res = tryConfiguredMove(config, moveUsedBitmap, activeMonIndex, moves, CONFIG_SETUP_MOVE, 8); - moveUsedBitmap = res.usedBitmap; - if (res.index >= 0) return { picked: true, move: moves[res.index], moveUsedBitmap }; - } - - // No best-damage flail — fall through to P5/P6. - return { picked: false, moveUsedBitmap }; -} - -// --------------------------------------------------------------------------------------------- -// HardCpu — one fixed deterministic best-response + setup-punishment policy. -// --------------------------------------------------------------------------------------------- - -export class HardCpu extends BaseCpuStrategy { - readonly name = 'Hard (best-response + setup punishment)'; - - override decide(view: BattleView, pm: PlayerMove, rng: () => number, state: StrategyState): CpuMove { - const e = view.engine; - const bk = view.bk as Hex; - - // PEEK at the player's revealed move (we reply after the commit). - let playerMoveIndex = pm.moveIndex; - const playerExtraData = pm.extraData; - - // Configured-move lane bits persist across turns per battle (the on-chain cpuMoveUsedBitmap - // semantics) so a setup move fires once per switch-in, not once per free turn. Every return goes - // through DONE to write the bitmap back. - const bitmaps = (state['moveUsedBitmap'] ??= {}) as Record; - let moveUsedBitmap = bitmaps[bk] ?? 0; - const DONE = (m: RevealedMove, phase: string): CpuMove => { - bitmaps[bk] = moveUsedBitmap; - state['lastPhase'] = phase; // decision-phase trace (read by the arena analyzer) - return RET(m); - }; - // Single fixed policy: HELL severe-damage threshold, no mode branch. - const severeDamagePct = SEVERE_DAMAGE_PCT_HELL; - - // Enumerate valid options (the shared candidate buckets), and decode the active mon's metas. - const { noOp, moves, switches } = this.validMoves(e, bk, rng); - const metas = buildMetas(e, bk, view.cpuActive); - - // ── P0: Turn 0 — Lead Selection ── (a restarted/replayed battle reuses its key: reset the bitmap) - if (Number(e.getTurnIdForBattleState(bk)) === 0) { - moveUsedBitmap = 0; - const lead = selectLead(e, bk, playerExtraData, switches, false); - moveUsedBitmap = clearMoveUsedBitsOnSwitchIn(e, bk, moveUsedBitmap, lead.extraData); - return DONE(lead, 'P0-lead'); - } - - const activeMonIndex = view.cpuActive; - let opponentMonIndex = view.oppActive; - - // A zapped opponent (ShouldSkipTurn) loses its revealed action entirely — switch included. The - // reveal is VOID: treat it as a rest so the whole tree plays the free turn against the mon that - // actually stays in, instead of best-responding to a move that will never execute. - if (view.mons.p0[view.oppActive].skipTurn) { - playerMoveIndex = NO_OP_INDEX; - } - - // ── P1: KO'd / Swap-Out Effect — Forced Switch ── (with no valid target — sole survivor already - // active — the engine accepts a rest) - if (view.switchFlag === 1 || view.mons.p1[view.cpuActive].ko) { - if (switches.length === 0) return DONE({ moveIndex: NO_OP_INDEX, extraData: 0 }, 'P1-forced-switch'); - const sw = selectBestSwitch(e, bk, opponentMonIndex, playerMoveIndex, switches, false); - moveUsedBitmap = clearMoveUsedBitsOnSwitchIn(e, bk, moveUsedBitmap, sw.extraData); - return DONE(sw, 'P1-forced-switch'); - } - - // If the opponent is switching, target the incoming mon. - if (playerMoveIndex === SWITCH_MOVE_INDEX) { - opponentMonIndex = playerExtraData; - } - - // Outgoing damage = max(static model, sim-measured). The static estimate keeps the always-hit - // floor (a fork's fixed-salt accuracy roll can miss); the fork measurement sees everything the - // model can't — variable-power moves, ability procs, the actual post-switch defender. The static - // context getter always builds against CURRENT actives, so on a revealed switch rebuild against - // the INCOMING defender. - const attackCtx = - playerMoveIndex === SWITCH_MOVE_INDEX - ? buildDamageCalcContext(e, bk, CPU_PLAYER_INDEX, activeMonIndex, OPP_PLAYER_INDEX, opponentMonIndex) - : e.getDamageCalcContext(bk, CPU_PLAYER_INDEX, BigInt(activeMonIndex), OPP_PLAYER_INDEX, BigInt(view.oppActive)); - const staticDamages = computeMoveDamages(attackCtx, metas, moves); - const saltSeed = BigInt(Number(e.getTurnIdForBattleState(bk)) + 1); - const measured = forkMeasureMoveDamages(e, bk, playerMoveIndex, playerExtraData, moves, saltSeed); - const damages = evScaleDamages( - e, bk, activeMonIndex, moves, - staticDamages.map((s, i) => Math.max(s, measured.damages[i])), - ); - - // Eval-veto wrapper for every decision from here down: the tree's pick stands unless a forked - // alternative clearly beats it (EVAL_OVERRIDE_MARGIN). Move forks reuse the measurement scores; - // only switches/rest fork extra. Overrides surface as ">eval" in the decision trace. - const ARB = (m: RevealedMove, phase: string): CpuMove => { - const better = pickEvalOverride( - e, bk, playerMoveIndex, playerExtraData, m, moves, measured.scores, switches, noOp, saltSeed, - ); - if (better === null) return DONE(m, phase); - if (better.moveIndex === SWITCH_MOVE_INDEX) { - moveUsedBitmap = clearMoveUsedBitsOnSwitchIn(e, bk, moveUsedBitmap, better.extraData); - } - return DONE(better, `${phase}>eval`); - }; - - // Hoist the opp-threat computation ONCE: read the opp's revealed move slot + class, build the - // opp->us damage context, and estimate damage-to-our-active. P2 (canOpponentKOUs) and P5 - // (evaluateDefensiveSwitch) both consume these instead of recomputing the same numbers. - let oppMoveSlot: bigint | undefined; - let oppMoveClass: MoveClass | undefined; - let damageToUs = 0; - if (playerMoveIndex < SWITCH_MOVE_INDEX) { - try { - oppMoveSlot = e.getMoveForMonForBattle(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex), BigInt(playerMoveIndex)); - oppMoveClass = moveSlotLib.moveClass(oppMoveSlot!, e, bk) as MoveClass; - if (oppMoveClass === MoveClass.Physical || oppMoveClass === MoveClass.Special) { - const ctxToUs = e.getDamageCalcContext(bk, OPP_PLAYER_INDEX, BigInt(opponentMonIndex), CPU_PLAYER_INDEX, BigInt(activeMonIndex)); - damageToUs = estimateDamage(e, bk, ctxToUs, oppMoveSlot!, oppMoveClass); - } - } catch { - oppMoveSlot = undefined; - oppMoveClass = undefined; - } - // True reveal damage from the sim — prices what the static model can't (variable-power moves - // it reads as 0, our own ability mitigation it overestimates). The static estimate stays as - // the floor only when the fork measures nothing (a fixed-salt roll can miss). - const measuredToUs = forkMeasureIncomingDamage(e, bk, playerMoveIndex, playerExtraData, null, saltSeed); - if (measuredToUs > 0) damageToUs = measuredToUs; - } - - // ── P2: Can We KO the Opponent? ── - const koMoveIdx = findKOMove(e, bk, opponentMonIndex, metas, moves, damages); - if (koMoveIdx >= 0) { - const opponentCanKOUs = canOpponentKOUs(e, bk, activeMonIndex, playerMoveIndex, damageToUs); - if ( - !opponentCanKOUs || - weGoFirst(e, bk, metas, activeMonIndex, opponentMonIndex, moves[koMoveIdx].moveIndex, playerMoveIndex) - ) { - return ARB(moves[koMoveIdx], 'P2-ko'); - } - // else: opponent outspeeds us and can KO — fall through to P5. - } - - // ── P3: Opponent is Switching ── A telegraphed free turn: same guarded punish tree as P4.5 - // (configured switch-in -> 2HKO -> momentum+productive setup), then band damage, then rest. - if (playerMoveIndex === SWITCH_MOVE_INDEX) { - const fr = freeTurnPick(view, BETTER_CPU_MON_CONFIG, moveUsedBitmap, metas, moves, damages); - moveUsedBitmap = fr.moveUsedBitmap; - if (fr.picked) return ARB(fr.move!, 'P3-opp-switching'); - if (moves.length > 0) { - const bestMove = pickSimilarDamageMove(damages, rng); - if (bestMove >= 0) return ARB(moves[bestMove], 'P3-opp-switching'); - } - return ARB(noOp[0], 'P3-opp-switching'); // rest on free turn - } - - // ── P4: Opponent is Resting ── (same free-turn punish as P3) - if (playerMoveIndex === NO_OP_INDEX) { - if (moves.length === 0) return ARB(noOp[0], 'P4-opp-resting'); // both rest - const fr = freeTurnPick(view, BETTER_CPU_MON_CONFIG, moveUsedBitmap, metas, moves, damages); - moveUsedBitmap = fr.moveUsedBitmap; - if (fr.picked) return ARB(fr.move!, 'P4-opp-resting'); - const bestMove = pickSimilarDamageMove(damages, rng); - if (bestMove >= 0) return ARB(moves[bestMove], 'P4-opp-resting'); - return ARB(noOp[0], 'P4-opp-resting'); - } - - // ── P4.5: Free-turn setup-punishment — the opponent telegraphed a 0-power Self/Other move. - // Safe always-on via freeTurnPick's guards (no pivot branch; setup needs momentum + progress). - if (isFreeTurnReveal(e, bk, opponentMonIndex, playerMoveIndex)) { - const fr = freeTurnPick(view, BETTER_CPU_MON_CONFIG, moveUsedBitmap, metas, moves, damages); - if (fr.picked) { - moveUsedBitmap = fr.moveUsedBitmap; - return ARB(fr.move!, 'P4.5-free-turn'); - } - moveUsedBitmap = fr.moveUsedBitmap; - } - - // ── P5: Opponent Using a Move — Evaluate Defensive Switch ── - if (switches.length > 0) { - // KO-bypass (ALWAYS-ON): best move near-KOs (±10%) and we outspeed => stay in. - const koBypassFires = - moves.length > 0 && - checkKOBypass(e, bk, metas, activeMonIndex, opponentMonIndex, moves, damages, playerMoveIndex); - const { shouldSwitch, switchIdx } = evaluateDefensiveSwitch( - e, bk, activeMonIndex, opponentMonIndex, playerMoveIndex, playerExtraData, switches, severeDamagePct, - koBypassFires, oppMoveSlot, oppMoveClass, damageToUs, saltSeed, - ); - if (shouldSwitch) { - // The switch costs our turn, and the damage-% thresholds above never weighed our own offense. - // Cross-check: only switch if the fork says it leaves a better position than staying and - // hitting (the stay score was already paid for by the damage measurement). - const stayIdx = findBestDamageMove(metas, moves, damages); - const swScore = forkScoreAction(e, bk, playerMoveIndex, playerExtraData, switches[switchIdx], saltSeed); - if (stayIdx < 0 || swScore >= measured.scores[stayIdx]) { - moveUsedBitmap = clearMoveUsedBitsOnSwitchIn(e, bk, moveUsedBitmap, switches[switchIdx].extraData); - return ARB(switches[switchIdx], 'P5-defensive-switch'); - } - return ARB(moves[stayIdx], 'P5-stay-and-hit'); - } - } - - // ── P5.5: Anti-wall stalemate-breaker — pivot out of a dead matchup (we can't progress + a bench - // mon matches up strictly better). Catches the flail-at-a-wall the threat-based P5 misses. ── - if (switches.length > 0) { - const antiWallIdx = antiWallSwitch(view, metas, moves, damages, switches, { - revealIdx: playerMoveIndex, - revealExtra: playerExtraData, - salt: saltSeed, - }); - if (antiWallIdx >= 0) { - moveUsedBitmap = clearMoveUsedBitsOnSwitchIn(e, bk, moveUsedBitmap, switches[antiWallIdx].extraData); - return ARB(switches[antiWallIdx], 'P5.5-anti-wall'); - } - } - - // ── P6: Default — Best Damaging Move (sampled from the similar-damage band) ── - if (moves.length > 0) { - const r = tryConfiguredMove(BETTER_CPU_MON_CONFIG, moveUsedBitmap, activeMonIndex, moves, CONFIG_SWITCH_IN_MOVE, 0); - moveUsedBitmap = r.usedBitmap; - if (r.index >= 0) return ARB(moves[r.index], 'P6-default'); - - const preferredMove = tryPreferredMove(BETTER_CPU_MON_CONFIG, activeMonIndex, attackCtx, metas, moves); - if (preferredMove >= 0) return ARB(moves[preferredMove], 'P6-default'); - - const bestMove = pickSimilarDamageMove(damages, rng); - if (bestMove >= 0) return ARB(moves[bestMove], 'P6-default'); - } - - // Stuck fallback — no damaging option left. The tree has no opinion here, so take the best - // forked position outright: move scores are already measured; switches/rest fork now. - let fbBest: RevealedMove = noOp[0] ?? { moveIndex: NO_OP_INDEX, extraData: 0 }; - let fbScore = -Infinity; - const fbConsider = (m: RevealedMove, s: number) => { - if (s > fbScore) { - fbScore = s; - fbBest = m; - } - }; - for (let i = 0; i < moves.length; i++) fbConsider(moves[i], measured.scores[i]); - for (const m of [...switches, ...noOp]) { - fbConsider(m, forkScoreAction(e, bk, playerMoveIndex, playerExtraData, m, saltSeed)); - } - if (fbBest.moveIndex === SWITCH_MOVE_INDEX) { - moveUsedBitmap = clearMoveUsedBitsOnSwitchIn(e, bk, moveUsedBitmap, fbBest.extraData); - } - return DONE(fbBest, 'fallback'); - } -} diff --git a/sims/src/cpu/strategies/override-cpu.ts b/sims/src/cpu/strategies/override-cpu.ts deleted file mode 100644 index f4ef17cf..00000000 --- a/sims/src/cpu/strategies/override-cpu.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { Hex } from '../hex'; -import { SWITCH_MOVE_INDEX } from '../constants'; -import { - cpuActiveMonIndex, - oppActiveMonIndex, - monMaxHp, - monCurrentHp, - monCurrentStamina, - monCurrentSpeed, -} from '../engine-view'; -import { forkMeasureIncomingDamage } from '../heuristic-native'; -import { BattleView } from '../battle-view'; -import { BaseCpuStrategy, CpuMove, PlayerMove, StrategyState } from '../strategy'; -import { HardCpu } from './hard-cpu'; - -/** - * OVERRIDE CPU — a scripted-plan pilot for validating setup / resource / self-sacrifice lines the - * heuristic pilots refuse to play ("unpiloted is not weak"). It wraps {@link HardCpu}: for the active - * mon it consults a per-mon script (ordered rules with `when` / `once` / `maxUses` gates); the first - * rule whose move is affordable this turn and whose gate passes is played, otherwise it delegates the - * whole decision to hard. Reusable beyond the design pass — any "force this mon to play this line" - * experiment registers a script here. - * - * Mons are keyed by BASE HP, which is unique per mon (see drool/mons.csv), because the battle view - * exposes stats, not mon ids. - * - * getCpuStrategy('override') // then run via sim-tests/arena/*.ts --strategies override - */ - -const CPU = 1n; // the CPU is always p1 -const OPP = 0n; - -/** Read-only position facts a `when` predicate can gate on. */ -export interface OverrideCtx { - /** CPU active mon current HP / max HP, in [0, 1]. */ - hpFrac: number; - /** CPU active mon current stamina. */ - stamina: number; - /** CPU active mon outspeeds the opponent's active mon. */ - outspeeds: boolean; - /** The opponent's revealed move this turn would KO the CPU active mon if it stays in. */ - incomingLethal: boolean; - /** The opponent's revealed move index (SWITCH_MOVE_INDEX / NO_OP when it isn't attacking). */ - oppMoveIndex: number; - /** Turns elapsed this game. */ - turn: number; - /** How many times THIS rule has already fired this game. */ - uses: number; -} - -export interface OverrideRule { - /** Move slot 0-3 to play. */ - move: number; - /** Optional gate; the rule only fires when this returns true (or when omitted). */ - when?: (c: OverrideCtx) => boolean; - /** Fire at most once per game. */ - once?: boolean; - /** Fire at most N times per game. */ - maxUses?: number; - /** Force a specific extraData target; defaults to the engine-picked valid target. */ - extraData?: number; - /** Human label for logs. */ - label?: string; -} - -/** An ordered list of rules; first affordable + passing rule wins, else fall through to hard. */ -export type OverrideScript = OverrideRule[]; - -/** - * Scripts keyed by base HP. Add a mon by dropping its base HP (from mons.csv) and an ordered rule - * list. Start small — one signature line per mon is enough to test the hypothesis. - */ -export const OVERRIDE_SCRIPTS: Record = { - // Ghouliath: EG-on-lethal tested negative twice in the v4 pass (48.3 / 48.9 vs hard 51.7 at 600 - // games each, ungated and hpFrac<0.4-gated); script removed so later runs have a cleaner field. - - // Aurox (baseHp 400): the tank line — Iron Wall (slot 2) on a fresh, stamina-flush entry, then - // Bull Rush (slot 3) as the default so Up Only ramps behind the regen. - 400: [ - { move: 2, when: (c) => c.hpFrac > 0.9 && c.stamina >= 3, maxUses: 1, label: 'Iron Wall on entry' }, - { move: 3, label: 'Bull Rush' }, - ], - - // Iblivion (baseHp 277): the turn-1 Loop line (designer-requested recheck, v4 annotation) — - // Loop once on entry at the +15% Baselight-1 tier instead of spending two turns on the - // Renormalize battery; Baselight then accrues passively (3 by end of t2 -> empowered Unbounded - // Strike at t3), with Brightback sustain under half HP. The Renormalize-first battery tested - // flat four times (45.7/45.9/48.1 at 600, 45.6 at 1200) and 46.7 at the 2000-game anchor. - 277: [ - { move: 1, once: true, label: 'Loop on entry (+15%)' }, - { move: 2, when: (c) => c.hpFrac < 0.5, label: 'Brightback sustain' }, - { move: 0, label: 'Unbounded Strike' }, - ], - - // Xmon: script REMOVED in the v4 pass — Night Terrors x2 tested negative twice (43.3 pre-change - // and 41.3 post-change vs hard 46.6/45.4), and slot-keyed rules are corrupted for the four - // rotating-catalog mons (the slot-3 rule fired Invoke Taboo 30% of move-turns under rotation). - // Re-script only after the registry can key rules to move identity instead of battle slot. - - // Volthare (baseHp 310): prefer Mega Star Blast (slot 2) whenever affordable — testing whether - // greedy's 7-point edge over hard on this mon reduces to one preferred-move rule (the shape - // munch's MonConfig already supports). - 310: [{ move: 2, when: (c) => c.stamina >= 3, label: 'Mega Star Blast preference' }], - - // Embursa (baseHp 420): arm Q5 (slot 3) once on the first action — a 150-power undodgeable hit - // five round-starts later for 2 stamina — then fall through to hard. Tests whether the delayed - // nuke is unpiloted rather than weak (the fourth delayed-payoff dead slot of the pass). - 420: [{ move: 3, maxUses: 1, label: 'Arm Q5' }], - - // Pengym (baseHp 371): the committed Frostbite combo — Chill Out (slot 0) to tag, Deep Freeze - // (slot 2) whenever affordable, re-tag while saving stamina. Tests design.md's "somewhat slow" - // worry. OverrideCtx cannot see opponent status, so stamina gates approximate the sequence. - 371: [ - { move: 0, maxUses: 1, label: 'Chill Out tag' }, - { move: 2, when: (c) => c.stamina >= 3, maxUses: 2, label: 'Deep Freeze' }, - { move: 0, when: (c) => c.stamina < 3, maxUses: 2, label: 'Chill Out while saving' }, - ], - - // Inutia (baseHp 351): v4 isolation — arm Chain Expansion once, fall through to hard for - // everything else. (The earlier three-rule bundle incl. Initialize/Hit And Dip scored 51.6 vs - // hard 50.3 at 600; this isolates CE's share of that gain.) - 351: [{ move: 0, once: true, label: 'Arm Chain Expansion' }], - - // Ekineki: script REMOVED in the v4 pass — "NNN when stamina <= 2" tested sharply negative - // (50.8 vs fixed hard 56.7 at 2000 games): at <=2 stamina the following turn still can't afford - // Overflow, so the one-turn crit prime expired uncashed twice a game. The payable window is - // roughly "stamina exactly 3 with Overflow planned", which is too thin to script profitably. -}; - -export class OverrideCpu extends BaseCpuStrategy { - readonly name = 'override'; - private readonly fallback = new HardCpu(); - - createState(): StrategyState { - return { fallback: this.fallback.createState(), turn: 0, uses: {} }; - } - - decide(view: BattleView, playerMove: PlayerMove, rng: () => number, state: StrategyState): CpuMove { - const e = view.engine; - const bk = view.bk as Hex; - state.turn = (state.turn as number) + 1; - - const activeIdx = cpuActiveMonIndex(e, bk); - const script = OVERRIDE_SCRIPTS[monMaxHp(e, bk, CPU, activeIdx)]; - - if (script) { - const { moves } = this.validMoves(e, bk, rng); // affordable slots, each with a valid target - const uses = state.uses as Record; - let ctx: OverrideCtx | undefined; - - for (let i = 0; i < script.length; i++) { - const rule = script[i]; - const key = `${activeIdx}:${i}`; - const fired = uses[key] ?? 0; - if (rule.once && fired >= 1) continue; - if (rule.maxUses !== undefined && fired >= rule.maxUses) continue; - - const affordable = moves.find((m) => m.moveIndex === rule.move); - if (!affordable) continue; // not castable this turn (stamina / forced-switch / etc.) - - if (rule.when) { - if (!ctx) ctx = buildCtx(e, bk, activeIdx, playerMove, state.turn as number); - if (!rule.when({ ...ctx, uses: fired })) continue; - } - - uses[key] = fired + 1; - return { moveIndex: affordable.moveIndex, extraData: rule.extraData ?? affordable.extraData }; - } - } - - return this.fallback.decide(view, playerMove, rng, state.fallback as StrategyState); - } -} - -function buildCtx(e: any, bk: Hex, activeIdx: number, playerMove: PlayerMove, turn: number): OverrideCtx { - const curHp = monCurrentHp(e, bk, CPU, activeIdx); - const maxHp = monMaxHp(e, bk, CPU, activeIdx); - const oppIdx = oppActiveMonIndex(e, bk); - - // Incoming lethal: only meaningful when the opponent actually attacks this turn. - let incomingLethal = false; - if (playerMove.moveIndex < SWITCH_MOVE_INDEX) { - try { - incomingLethal = - forkMeasureIncomingDamage(e, bk, playerMove.moveIndex, playerMove.extraData, null, 0n) >= curHp; - } catch { - incomingLethal = false; // a fork that fails on an odd state just reads as non-lethal - } - } - - return { - hpFrac: maxHp > 0 ? curHp / maxHp : 0, - stamina: monCurrentStamina(e, bk, CPU, activeIdx), - outspeeds: monCurrentSpeed(e, bk, CPU, activeIdx) > monCurrentSpeed(e, bk, OPP, oppIdx), - incomingLethal, - oppMoveIndex: playerMove.moveIndex, - turn, - uses: 0, - }; -} diff --git a/sims/src/cpu/strategies/search-cpu.ts b/sims/src/cpu/strategies/search-cpu.ts deleted file mode 100644 index 4db312f0..00000000 --- a/sims/src/cpu/strategies/search-cpu.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * SEARCH CPU — TS port of the Rust depth-limited simultaneous-move maximin search - * (`transpiler/strategies-rs/src/search.rs`). - * - * At each ply both sides' candidate actions (moves + switches + rest — rest is ALWAYS a - * candidate, the rest-bug lesson) form a matrix game; the backup is maximin (worst-case - * opponent), leaves score through the linear evaluator. Two variants: - * - no-peek: full my×opp grid at the root (production commit-reveal semantics); - * - peek-at-root: best-respond to the revealed `playerMove` (single opponent column), - * maximin only deeper (arena/p1 reveal semantics). - * Terminal wins are mate-distance discounted (faster wins / later losses score better). - * - * Determinism: fixed salt 0 on every hypothetical; earliest-candidate tie-break. A throwing - * hypothetical (an unreachable/reverting line) is contained to the decision via try/catch → - * first legal fallback. Forks are disposed depth-first (only O(depth) live at once). - */ -import { Hex } from '../hex'; -import { captureBattleView, type BattleView } from '../battle-view'; -import { calculateValidMoves, makeRng, type RevealedMove } from '../engine-view'; -import { applyHypotheticalMoveKeyed, disposeFork, type HypotheticalMove } from '../forward-model'; -import { scoreState } from '../evaluator'; -import { transposeEngine } from '../../arena/transpose'; -import { NO_OP_INDEX } from '../constants'; -import { BaseCpuStrategy, type CpuMove, type PlayerMove, type StrategyState } from '../strategy'; - -const MAX_DEPTH = 3; -const MAX_ACTIONS = 8; // 4 moves + 3 switches + rest — never truncates in singles -const WIN = 1e9; -const LOSS = -1e9; -const SALT = 0n; - -function hypo(m: RevealedMove | PlayerMove): HypotheticalMove { - return { moveIndex: m.moveIndex, salt: SALT, extraData: m.extraData }; -} - -// Candidate enumeration draws payload targets from a NODE-LOCAL fixed-seed rng, so the tree is -// independent of visit order — that's what makes the row prune truly argmax-invariant (a shared -// stream would shift every later node's picks when a branch is skipped). -const ENUM_SEED = 0x5eed; - -/** All candidate actions for the engine-perspective CPU at `key`: moves + switches + rest. */ -function candidates(e: any, key: Hex): RevealedMove[] { - const v = calculateValidMoves(e, key, makeRng(ENUM_SEED)); - return [...v.moves, ...v.switches, ...v.noOp].slice(0, MAX_ACTIONS); -} - -/** Both sides' action lists given the (seat-relative) switch flag; a non-acting side is [null]. */ -function actionLists( - e: any, - key: Hex, - flag: number, -): { my: Array; opp: Array } { - const my: Array = flag !== 0 ? candidates(e, key) : [null]; - const opp: Array = flag !== 1 ? candidates(transposeEngine(e), key) : [null]; - return { my: my.length ? my : [null], opp: opp.length ? opp : [null] }; -} - -/** Recursive maximin value of the position at `key`, CPU-perspective. */ -function value(e: any, key: Hex, depth: number): number { - const view = captureBattleView(e, key); - const cpuAlive = view.mons.p1.filter((m) => !m.ko).length; - const oppAlive = view.mons.p0.filter((m) => !m.ko).length; - // Mate-distance discounting: more remaining depth = terminal reached sooner. - if (oppAlive <= 0) return WIN + depth; - if (cpuAlive <= 0) return LOSS - depth; - if (depth <= 0) return scoreState(view); - - const { my, opp } = actionLists(e, key, view.switchFlag); - let best = -Infinity; - for (const a of my) { - let worst = Infinity; - for (const o of opp) { - const child = applyHypotheticalMoveKeyed(e, key, o ? hypo(o) : null, a ? hypo(a) : null); - const v = value(e, child, depth - 1); - disposeFork(e, child); - if (v < worst) worst = v; - if (worst <= best) break; // row can no longer beat the best row — argmax-invariant prune - } - if (worst > best) best = worst; - } - return best; -} - -function fallback(e: any, bk: Hex, view: BattleView, rng: () => number): CpuMove { - if (view.switchFlag === 0) return { moveIndex: NO_OP_INDEX, extraData: 0 }; - const v = calculateValidMoves(e, bk, rng); - const first = v.moves[0] ?? v.switches[0] ?? v.noOp[0]; - return first ?? { moveIndex: NO_OP_INDEX, extraData: 0 }; -} - -/** Depth-`depth` maximin search; `peek` = best-respond to the revealed move at the root. */ -export class SearchCpu extends BaseCpuStrategy { - constructor( - readonly name: string, - private readonly depth: number, - private readonly peek: boolean, - ) { - super(); - } - - decide(view: BattleView, playerMove: PlayerMove, rng: () => number, _state: StrategyState): CpuMove { - try { - return this.decideInner(view, playerMove, rng); - } catch { - return fallback(view.engine, view.bk as Hex, view, rng); - } - } - - private decideInner(view: BattleView, playerMove: PlayerMove, _rng: () => number): CpuMove { - const depth = Math.max(1, Math.min(MAX_DEPTH, this.depth)); - const e = view.engine; - const bk = view.bk as Hex; - - // CPU passive (opp forced-switch) — nothing to choose. - if (view.switchFlag === 0) return { moveIndex: NO_OP_INDEX, extraData: 0 }; - - const { my, opp: oppFull } = actionLists(e, bk, view.switchFlag); - // Peek-at-root: the opponent's move IS revealed → a single opponent column. - const opp = this.peek && view.switchFlag !== 1 ? [playerMove as RevealedMove] : oppFull; - - let best: CpuMove = my[0] ?? { moveIndex: NO_OP_INDEX, extraData: 0 }; - let bestVal = -Infinity; - for (const a of my) { - let worst = Infinity; - for (const o of opp) { - const child = applyHypotheticalMoveKeyed(e, bk, o ? hypo(o) : null, a ? hypo(a) : null); - const v = value(e, child, depth - 1); - disposeFork(e, child); - if (v < worst) worst = v; - if (worst <= bestVal) break; // this action can no longer win the argmax — prune - } - // Strict >: ties keep the earliest candidate (moves before switches) — deterministic. - if (worst > bestVal && a) { - bestVal = worst; - best = a; - } - } - return best; - } -} diff --git a/sims/src/cpu/strategy.ts b/sims/src/cpu/strategy.ts deleted file mode 100644 index b709fc8d..00000000 --- a/sims/src/cpu/strategy.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Hex } from './hex'; -import { BattleView } from './battle-view'; -import { RevealedMove, calculateValidMoves, pickUniform } from './engine-view'; - -/** - * CPU STRATEGY FRAMEWORK — the interface + minimal base class every strategy implements. - * - * Every strategy — the difficulty heuristics, personas, and the eval/search tier — implements - * {@link CpuStrategy}. Strategies read state through the free functions in `engine-view` / - * `heuristic-shared` / `heuristic-native` and the read-once {@link BattleView} snapshot they're - * handed — there is no fat wrapper surface; import the helper you need directly. - * - * The CPU is ALWAYS p1; the human opponent is p0. The `view` carries `.engine` / `.bk` for the readers - * not projected onto it. - */ - -/** The human (p0) move the CPU is responding to this turn. */ -export interface PlayerMove { - moveIndex: number; - extraData: number; -} - -/** What every strategy returns: the CPU's (p1) chosen move + its extraData target. */ -export type CpuMove = { moveIndex: number; extraData: number }; - -/** - * Per-(strategy) mutable state bag a strategy may persist across turns. The dispatcher owns it - * (`createState()` once per strategy, threaded back into every `decide`); hard keys its per-battle - * setup lane bits on it, stall its status-used bits — stateless strategies just ignore it. - */ -export interface StrategyState { - [k: string]: unknown; -} - -/** - * A pluggable CPU strategy. `decide` reads the position (via the `view` + free helpers), consults its - * own `state`, and returns the move. `playerMove` is p0's revealed move (BetterCPU peeks at it). - */ -export interface CpuStrategy { - readonly name: string; - /** Build the initial per-strategy state bag. */ - createState(): StrategyState; - /** Choose the CPU's move for the current turn. */ - decide(view: BattleView, playerMove: PlayerMove, rng: () => number, state: StrategyState): CpuMove; -} - -/** - * Minimal base: a `name`, a default empty `createState`, the abstract `decide`, and the two engine-view - * helpers strategies actually reach for via `this.` (candidate enumeration + uniform tie-break). Every - * other engine read is a direct free-function import from `engine-view` / `heuristic-shared`. - */ -export abstract class BaseCpuStrategy implements CpuStrategy { - abstract readonly name: string; - - createState(): StrategyState { - return {}; - } - - abstract decide(view: BattleView, playerMove: PlayerMove, rng: () => number, state: StrategyState): CpuMove; - - /** Port of `CPU._calculateValidMoves` — the candidate buckets a strategy chooses from. */ - protected validMoves( - e: any, - bk: string, - rng?: () => number, - ): { noOp: RevealedMove[]; moves: RevealedMove[]; switches: RevealedMove[] } { - return calculateValidMoves(e, bk as Hex, rng); - } - - /** Uniform pick from `arr` with the injected rng (tie-breaks). */ - protected pickUniform(arr: T[], rng: () => number): T | undefined { - return pickUniform(arr, rng); - } -} diff --git a/snapshots/DoublesGasBenchmark.json b/snapshots/DoublesGasBenchmark.json index 94b52077..c4677533 100644 --- a/snapshots/DoublesGasBenchmark.json +++ b/snapshots/DoublesGasBenchmark.json @@ -1,32 +1,64 @@ { - "Doubles_Batch12_coldGas": "829428", - "Doubles_Batch12_coldSload": "36", + "DoublesFloor_AllAttack8_rawGas": "912581", + "DoublesFloor_AllAttack_perTurn": "114072", + "DoublesFloor_AllRest8_rawGas": "375887", + "DoublesFloor_AllRest_perTurn": "46985", + "DoublesFloor_OneAttack8_rawGas": "682389", + "DoublesFloor_OneAttack_perTurn": "85298", + "Doubles_Batch12_afterDamageCalls": "0", + "Doubles_Batch12_afterMoveCalls": "0", + "Doubles_Batch12_coldGas": "747703", + "Doubles_Batch12_coldSload": "34", + "Doubles_Batch12_effectDataReads": "0", + "Doubles_Batch12_effectDataWrites": "0", + "Doubles_Batch12_effectHeaderReads": "5", + "Doubles_Batch12_effectHeaderWrites": "2", "Doubles_Batch12_extraAccounts": "6", + "Doubles_Batch12_lifecycleCalls": "1", + "Doubles_Batch12_moveDecisionCallbacks": "0", + "Doubles_Batch12_nonHookHeaderReadUpperBound": "4", "Doubles_Batch12_noop": "23", - "Doubles_Batch12_nzToNz": "33", - "Doubles_Batch12_prodStorageGas": "543300", - "Doubles_Batch12_totalSload": "1698", - "Doubles_Batch12_totalSstore": "67", - "Doubles_Batch12_zToNz": "9", - "Doubles_Drain6_coldGas": "538845", - "Doubles_Drain6_coldSload": "42", + "Doubles_Batch12_nzToNz": "32", + "Doubles_Batch12_preDamageCalls": "0", + "Doubles_Batch12_prodStorageGas": "486500", + "Doubles_Batch12_roundEndCalls": "1", + "Doubles_Batch12_roundStartCalls": "0", + "Doubles_Batch12_totalSload": "1570", + "Doubles_Batch12_totalSstore": "65", + "Doubles_Batch12_updateStateCalls": "0", + "Doubles_Batch12_zToNz": "7", + "Doubles_Drain6_coldGas": "473844", + "Doubles_Drain6_coldSload": "40", "Doubles_Drain6_extraAccounts": "7", "Doubles_Drain6_noop": "11", - "Doubles_Drain6_nzToNz": "28", - "Doubles_Drain6_prodStorageGas": "462600", - "Doubles_Drain6_totalSload": "902", - "Doubles_Drain6_totalSstore": "50", - "Doubles_Drain6_zToNz": "9", - "Doubles_KitBatch10_coldGas": "1091153", - "Doubles_KitBatch10_coldSload": "29", + "Doubles_Drain6_nzToNz": "27", + "Doubles_Drain6_prodStorageGas": "411800", + "Doubles_Drain6_totalSload": "834", + "Doubles_Drain6_totalSstore": "48", + "Doubles_Drain6_zToNz": "7", + "Doubles_KitBatch10_afterDamageCalls": "0", + "Doubles_KitBatch10_afterMoveCalls": "0", + "Doubles_KitBatch10_coldGas": "960450", + "Doubles_KitBatch10_coldSload": "26", + "Doubles_KitBatch10_effectDataReads": "0", + "Doubles_KitBatch10_effectDataWrites": "0", + "Doubles_KitBatch10_effectHeaderReads": "9", + "Doubles_KitBatch10_effectHeaderWrites": "1", "Doubles_KitBatch10_extraAccounts": "3", + "Doubles_KitBatch10_lifecycleCalls": "9", + "Doubles_KitBatch10_moveDecisionCallbacks": "0", + "Doubles_KitBatch10_nonHookHeaderReadUpperBound": "0", "Doubles_KitBatch10_noop": "21", - "Doubles_KitBatch10_nzToNz": "61", - "Doubles_KitBatch10_prodStorageGas": "658000", - "Doubles_KitBatch10_totalSload": "2061", - "Doubles_KitBatch10_totalSstore": "93", - "Doubles_KitBatch10_zToNz": "10", - "Doubles_Stage6_coldGas": "174477", + "Doubles_KitBatch10_nzToNz": "59", + "Doubles_KitBatch10_preDamageCalls": "0", + "Doubles_KitBatch10_prodStorageGas": "576200", + "Doubles_KitBatch10_roundEndCalls": "9", + "Doubles_KitBatch10_roundStartCalls": "0", + "Doubles_KitBatch10_totalSload": "1740", + "Doubles_KitBatch10_totalSstore": "89", + "Doubles_KitBatch10_updateStateCalls": "0", + "Doubles_KitBatch10_zToNz": "8", + "Doubles_Stage6_coldGas": "174531", "Doubles_Stage6_coldSload": "18", "Doubles_Stage6_extraAccounts": "6", "Doubles_Stage6_noop": "0", diff --git a/snapshots/EngineOptimizationTest.json b/snapshots/EngineOptimizationTest.json index d0824947..16698b9d 100644 --- a/snapshots/EngineOptimizationTest.json +++ b/snapshots/EngineOptimizationTest.json @@ -1,4 +1,4 @@ { - "ExternalStaminaRegen": "340993", - "InlineStaminaRegen": "798337" + "ExternalStaminaRegen": "337784", + "InlineStaminaRegen": "801929" } \ No newline at end of file diff --git a/snapshots/FullyOptimizedInlineGasTest.json b/snapshots/FullyOptimizedInlineGasTest.json index 0d94466d..1e62e786 100644 --- a/snapshots/FullyOptimizedInlineGasTest.json +++ b/snapshots/FullyOptimizedInlineGasTest.json @@ -1,29 +1,29 @@ { - "Fast_Battle1_coldGas": "1245198", - "Fast_Battle1_coldSload": "215", + "Fast_Battle1_coldGas": "1077548", + "Fast_Battle1_coldSload": "199", "Fast_Battle1_extraAccounts": "52", - "Fast_Battle1_noop": "28", + "Fast_Battle1_noop": "23", "Fast_Battle1_nzToNz": "68", - "Fast_Battle1_prodStorageGas": "1423700", - "Fast_Battle1_totalSload": "1017", - "Fast_Battle1_totalSstore": "123", - "Fast_Battle1_zToNz": "27", - "Fast_Battle2_coldGas": "1111541", - "Fast_Battle2_coldSload": "225", + "Fast_Battle1_prodStorageGas": "1273500", + "Fast_Battle1_totalSload": "977", + "Fast_Battle1_totalSstore": "112", + "Fast_Battle1_zToNz": "21", + "Fast_Battle2_coldGas": "1008744", + "Fast_Battle2_coldSload": "209", "Fast_Battle2_extraAccounts": "52", - "Fast_Battle2_noop": "35", - "Fast_Battle2_nzToNz": "87", - "Fast_Battle2_prodStorageGas": "1273700", - "Fast_Battle2_totalSload": "1109", - "Fast_Battle2_totalSstore": "138", - "Fast_Battle2_zToNz": "15", - "Fast_Battle3_coldGas": "781622", - "Fast_Battle3_coldSload": "215", + "Fast_Battle2_noop": "25", + "Fast_Battle2_nzToNz": "88", + "Fast_Battle2_prodStorageGas": "1206300", + "Fast_Battle2_totalSload": "1052", + "Fast_Battle2_totalSstore": "127", + "Fast_Battle2_zToNz": "13", + "Fast_Battle3_coldGas": "751629", + "Fast_Battle3_coldSload": "199", "Fast_Battle3_extraAccounts": "52", - "Fast_Battle3_noop": "39", - "Fast_Battle3_nzToNz": "81", - "Fast_Battle3_prodStorageGas": "965400", - "Fast_Battle3_totalSload": "1017", - "Fast_Battle3_totalSstore": "123", - "Fast_Battle3_zToNz": "2" + "Fast_Battle3_noop": "28", + "Fast_Battle3_nzToNz": "80", + "Fast_Battle3_prodStorageGas": "951700", + "Fast_Battle3_totalSload": "977", + "Fast_Battle3_totalSstore": "112", + "Fast_Battle3_zToNz": "3" } \ No newline at end of file diff --git a/snapshots/MainnetReplayGasTest.json b/snapshots/MainnetReplayGasTest.json new file mode 100644 index 00000000..d1688caf --- /dev/null +++ b/snapshots/MainnetReplayGasTest.json @@ -0,0 +1,192 @@ +{ + "MainnetDoublesReplay1_Reused_afterDamageCalls": "8", + "MainnetDoublesReplay1_Reused_afterMoveCalls": "5", + "MainnetDoublesReplay1_Reused_effectDataReads": "0", + "MainnetDoublesReplay1_Reused_effectDataWrites": "0", + "MainnetDoublesReplay1_Reused_effectHeaderReads": "45", + "MainnetDoublesReplay1_Reused_effectHeaderWrites": "14", + "MainnetDoublesReplay1_Reused_effectResolverCalls": "0", + "MainnetDoublesReplay1_Reused_engineCallbackStorageReads": "251", + "MainnetDoublesReplay1_Reused_engineCallbackStorageWrites": "43", + "MainnetDoublesReplay1_Reused_engineRootStorageReads": "1819", + "MainnetDoublesReplay1_Reused_engineRootStorageWrites": "118", + "MainnetDoublesReplay1_Reused_engineStorageLoadCommitFloorOps": "83", + "MainnetDoublesReplay1_Reused_engineStorageReads": "2070", + "MainnetDoublesReplay1_Reused_engineStorageRemovableOps": "2148", + "MainnetDoublesReplay1_Reused_engineStorageUniqueTouchedSlots": "55", + "MainnetDoublesReplay1_Reused_engineStorageUniqueWrittenSlots": "28", + "MainnetDoublesReplay1_Reused_engineStorageWrites": "161", + "MainnetDoublesReplay1_Reused_execGas": "1206172", + "MainnetDoublesReplay1_Reused_externalStorageReads": "6", + "MainnetDoublesReplay1_Reused_externalStorageWrites": "0", + "MainnetDoublesReplay1_Reused_headerFrameCallbackPassthroughOps": "144", + "MainnetDoublesReplay1_Reused_headerFrameCleanInvalidationBoundaries": "31", + "MainnetDoublesReplay1_Reused_headerFrameCommitsWithLegacy": "27", + "MainnetDoublesReplay1_Reused_headerFrameDirtyFlushBoundaries": "13", + "MainnetDoublesReplay1_Reused_headerFrameLoadsWithLegacy": "191", + "MainnetDoublesReplay1_Reused_headerFrameReloadedWords": "184", + "MainnetDoublesReplay1_Reused_headerFrameRemovableStorageOpsWithLegacy": "909", + "MainnetDoublesReplay1_Reused_headerFrameStorageOpsWithLegacy": "362", + "MainnetDoublesReplay1_Reused_legacyAbilityBoundaries": "2", + "MainnetDoublesReplay1_Reused_legacyBoundaryUpperBound": "44", + "MainnetDoublesReplay1_Reused_legacyEffectBoundaries": "35", + "MainnetDoublesReplay1_Reused_legacyMoveBoundaries": "7", + "MainnetDoublesReplay1_Reused_lifecycleCalls": "34", + "MainnetDoublesReplay1_Reused_monStateCleanInvalidationBoundaries": "14", + "MainnetDoublesReplay1_Reused_monStateDirtyFlushBoundaries": "30", + "MainnetDoublesReplay1_Reused_monStateFrameCommitsWithLegacy": "55", + "MainnetDoublesReplay1_Reused_monStateFrameLoadsWithLegacy": "145", + "MainnetDoublesReplay1_Reused_monStateFrameReloadedLanes": "137", + "MainnetDoublesReplay1_Reused_monStateFrameStorageOps": "16", + "MainnetDoublesReplay1_Reused_monStateFrameStorageOpsWithLegacy": "200", + "MainnetDoublesReplay1_Reused_monStateReads": "611", + "MainnetDoublesReplay1_Reused_monStateRemovableStorageOps": "668", + "MainnetDoublesReplay1_Reused_monStateRemovableStorageOpsWithLegacy": "484", + "MainnetDoublesReplay1_Reused_monStateUniqueTouchedLanes": "8", + "MainnetDoublesReplay1_Reused_monStateUniqueWrittenLanes": "8", + "MainnetDoublesReplay1_Reused_monStateWrites": "73", + "MainnetDoublesReplay1_Reused_moveDecisionCallbacks": "0", + "MainnetDoublesReplay1_Reused_moveResolverCalls": "0", + "MainnetDoublesReplay1_Reused_moveResolverContinuations": "0", + "MainnetDoublesReplay1_Reused_nonHookHeaderReadUpperBound": "11", + "MainnetDoublesReplay1_Reused_preDamageCalls": "0", + "MainnetDoublesReplay1_Reused_roundEndCalls": "19", + "MainnetDoublesReplay1_Reused_roundStartCalls": "0", + "MainnetDoublesReplay1_Reused_statusClassCallbacks": "0", + "MainnetDoublesReplay1_Reused_storage_battleDataReads": "788", + "MainnetDoublesReplay1_Reused_storage_battleDataUniqueTouched": "2", + "MainnetDoublesReplay1_Reused_storage_battleDataUniqueWritten": "2", + "MainnetDoublesReplay1_Reused_storage_battleDataWrites": "38", + "MainnetDoublesReplay1_Reused_storage_boostsReads": "0", + "MainnetDoublesReplay1_Reused_storage_boostsUniqueTouched": "2", + "MainnetDoublesReplay1_Reused_storage_boostsUniqueWritten": "2", + "MainnetDoublesReplay1_Reused_storage_boostsWrites": "2", + "MainnetDoublesReplay1_Reused_storage_configHeaderReads": "415", + "MainnetDoublesReplay1_Reused_storage_configHeaderUniqueTouched": "5", + "MainnetDoublesReplay1_Reused_storage_configHeaderUniqueWritten": "5", + "MainnetDoublesReplay1_Reused_storage_configHeaderWrites": "30", + "MainnetDoublesReplay1_Reused_storage_effectsReads": "45", + "MainnetDoublesReplay1_Reused_storage_effectsUniqueTouched": "8", + "MainnetDoublesReplay1_Reused_storage_effectsUniqueWritten": "8", + "MainnetDoublesReplay1_Reused_storage_effectsWrites": "14", + "MainnetDoublesReplay1_Reused_storage_firstOtherSlot": "0", + "MainnetDoublesReplay1_Reused_storage_globalKVReads": "7", + "MainnetDoublesReplay1_Reused_storage_globalKVUniqueTouched": "3", + "MainnetDoublesReplay1_Reused_storage_globalKVUniqueWritten": "3", + "MainnetDoublesReplay1_Reused_storage_globalKVWrites": "4", + "MainnetDoublesReplay1_Reused_storage_monStateReads": "611", + "MainnetDoublesReplay1_Reused_storage_monStateUniqueTouched": "8", + "MainnetDoublesReplay1_Reused_storage_monStateUniqueWritten": "8", + "MainnetDoublesReplay1_Reused_storage_monStateWrites": "73", + "MainnetDoublesReplay1_Reused_storage_otherReads": "0", + "MainnetDoublesReplay1_Reused_storage_otherUniqueTouched": "0", + "MainnetDoublesReplay1_Reused_storage_otherUniqueWritten": "0", + "MainnetDoublesReplay1_Reused_storage_otherWrites": "0", + "MainnetDoublesReplay1_Reused_storage_shellReads": "1", + "MainnetDoublesReplay1_Reused_storage_shellUniqueTouched": "1", + "MainnetDoublesReplay1_Reused_storage_shellUniqueWritten": "0", + "MainnetDoublesReplay1_Reused_storage_shellWrites": "0", + "MainnetDoublesReplay1_Reused_storage_staticCatalogReads": "203", + "MainnetDoublesReplay1_Reused_storage_staticCatalogUniqueTouched": "26", + "MainnetDoublesReplay1_Reused_storage_staticCatalogUniqueWritten": "0", + "MainnetDoublesReplay1_Reused_storage_staticCatalogWrites": "0", + "MainnetDoublesReplay1_Reused_updateStateCalls": "2", + "MainnetReplay_Reused_afterDamageCalls": "9", + "MainnetReplay_Reused_afterMoveCalls": "19", + "MainnetReplay_Reused_effectDataReads": "0", + "MainnetReplay_Reused_effectDataWrites": "0", + "MainnetReplay_Reused_effectHeaderReads": "191", + "MainnetReplay_Reused_effectHeaderWrites": "39", + "MainnetReplay_Reused_effectResolverCalls": "19", + "MainnetReplay_Reused_engineCallbackStorageReads": "1052", + "MainnetReplay_Reused_engineCallbackStorageWrites": "199", + "MainnetReplay_Reused_engineRootStorageReads": "1956", + "MainnetReplay_Reused_engineRootStorageWrites": "212", + "MainnetReplay_Reused_engineStorageLoadCommitFloorOps": "135", + "MainnetReplay_Reused_engineStorageReads": "3008", + "MainnetReplay_Reused_engineStorageRemovableOps": "3284", + "MainnetReplay_Reused_engineStorageUniqueTouchedSlots": "84", + "MainnetReplay_Reused_engineStorageUniqueWrittenSlots": "51", + "MainnetReplay_Reused_engineStorageWrites": "411", + "MainnetReplay_Reused_execGas": "2102467", + "MainnetReplay_Reused_externalStorageReads": "18", + "MainnetReplay_Reused_externalStorageWrites": "0", + "MainnetReplay_Reused_headerFrameCallbackPassthroughOps": "535", + "MainnetReplay_Reused_headerFrameCleanInvalidationBoundaries": "68", + "MainnetReplay_Reused_headerFrameCommitsWithLegacy": "60", + "MainnetReplay_Reused_headerFrameDirtyFlushBoundaries": "29", + "MainnetReplay_Reused_headerFrameLoadsWithLegacy": "318", + "MainnetReplay_Reused_headerFrameReloadedWords": "310", + "MainnetReplay_Reused_headerFrameRemovableStorageOpsWithLegacy": "975", + "MainnetReplay_Reused_headerFrameStorageOpsWithLegacy": "913", + "MainnetReplay_Reused_legacyAbilityBoundaries": "2", + "MainnetReplay_Reused_legacyBoundaryUpperBound": "97", + "MainnetReplay_Reused_legacyEffectBoundaries": "78", + "MainnetReplay_Reused_legacyMoveBoundaries": "17", + "MainnetReplay_Reused_lifecycleCalls": "84", + "MainnetReplay_Reused_monStateCleanInvalidationBoundaries": "28", + "MainnetReplay_Reused_monStateDirtyFlushBoundaries": "69", + "MainnetReplay_Reused_monStateFrameCommitsWithLegacy": "98", + "MainnetReplay_Reused_monStateFrameLoadsWithLegacy": "158", + "MainnetReplay_Reused_monStateFrameReloadedLanes": "150", + "MainnetReplay_Reused_monStateFrameStorageOps": "16", + "MainnetReplay_Reused_monStateFrameStorageOpsWithLegacy": "256", + "MainnetReplay_Reused_monStateReads": "779", + "MainnetReplay_Reused_monStateRemovableStorageOps": "911", + "MainnetReplay_Reused_monStateRemovableStorageOpsWithLegacy": "671", + "MainnetReplay_Reused_monStateUniqueTouchedLanes": "8", + "MainnetReplay_Reused_monStateUniqueWrittenLanes": "8", + "MainnetReplay_Reused_monStateWrites": "148", + "MainnetReplay_Reused_moveDecisionCallbacks": "0", + "MainnetReplay_Reused_moveResolverCalls": "4", + "MainnetReplay_Reused_moveResolverContinuations": "2", + "MainnetReplay_Reused_nonHookHeaderReadUpperBound": "107", + "MainnetReplay_Reused_preDamageCalls": "0", + "MainnetReplay_Reused_roundEndCalls": "56", + "MainnetReplay_Reused_roundStartCalls": "0", + "MainnetReplay_Reused_statusClassCallbacks": "0", + "MainnetReplay_Reused_storage_battleDataReads": "814", + "MainnetReplay_Reused_storage_battleDataUniqueTouched": "2", + "MainnetReplay_Reused_storage_battleDataUniqueWritten": "1", + "MainnetReplay_Reused_storage_battleDataWrites": "92", + "MainnetReplay_Reused_storage_boostsReads": "46", + "MainnetReplay_Reused_storage_boostsUniqueTouched": "18", + "MainnetReplay_Reused_storage_boostsUniqueWritten": "18", + "MainnetReplay_Reused_storage_boostsWrites": "39", + "MainnetReplay_Reused_storage_configHeaderReads": "899", + "MainnetReplay_Reused_storage_configHeaderUniqueTouched": "6", + "MainnetReplay_Reused_storage_configHeaderUniqueWritten": "5", + "MainnetReplay_Reused_storage_configHeaderWrites": "83", + "MainnetReplay_Reused_storage_effectsReads": "191", + "MainnetReplay_Reused_storage_effectsUniqueTouched": "14", + "MainnetReplay_Reused_storage_effectsUniqueWritten": "14", + "MainnetReplay_Reused_storage_effectsWrites": "39", + "MainnetReplay_Reused_storage_firstOtherSlot": "0", + "MainnetReplay_Reused_storage_globalKVReads": "27", + "MainnetReplay_Reused_storage_globalKVUniqueTouched": "3", + "MainnetReplay_Reused_storage_globalKVUniqueWritten": "2", + "MainnetReplay_Reused_storage_globalKVWrites": "7", + "MainnetReplay_Reused_storage_monStateReads": "779", + "MainnetReplay_Reused_storage_monStateUniqueTouched": "8", + "MainnetReplay_Reused_storage_monStateUniqueWritten": "8", + "MainnetReplay_Reused_storage_monStateWrites": "148", + "MainnetReplay_Reused_storage_otherReads": "0", + "MainnetReplay_Reused_storage_otherUniqueTouched": "0", + "MainnetReplay_Reused_storage_otherUniqueWritten": "0", + "MainnetReplay_Reused_storage_otherWrites": "0", + "MainnetReplay_Reused_storage_shellReads": "2", + "MainnetReplay_Reused_storage_shellUniqueTouched": "3", + "MainnetReplay_Reused_storage_shellUniqueWritten": "3", + "MainnetReplay_Reused_storage_shellWrites": "3", + "MainnetReplay_Reused_storage_staticCatalogReads": "250", + "MainnetReplay_Reused_storage_staticCatalogUniqueTouched": "30", + "MainnetReplay_Reused_storage_staticCatalogUniqueWritten": "0", + "MainnetReplay_Reused_storage_staticCatalogWrites": "0", + "MainnetReplay_Reused_updateStateCalls": "0", + "SinglesFloor_25Noops_rawGas": "529900", + "SinglesFloor_BatchFixed": "253", + "SinglesFloor_Noop_perTurn": "21196", + "SinglesFloor_OneNoop_rawGas": "21449", + "SinglesFloor_SendIn_rawGas": "164580", + "SinglesFloor_Whole27_rawGas": "668477" +} \ No newline at end of file diff --git a/snapshots/MatchmakerTest.json b/snapshots/MatchmakerTest.json index 7569d0fa..b90b54b9 100644 --- a/snapshots/MatchmakerTest.json +++ b/snapshots/MatchmakerTest.json @@ -1,5 +1,5 @@ { - "Accept1": "331714", - "Accept2": "33834", - "Propose1": "175456" + "Accept1": "334700", + "Accept2": "33755", + "Propose1": "175377" } \ No newline at end of file diff --git a/snapshots/ProdPvPGasBenchmark.json b/snapshots/ProdPvPGasBenchmark.json index d019bc97..68fb2ed2 100644 --- a/snapshots/ProdPvPGasBenchmark.json +++ b/snapshots/ProdPvPGasBenchmark.json @@ -1,32 +1,32 @@ { - "BuiltinBare_5Turns_coldGas": "290006", - "BuiltinBare_5Turns_coldSload": "70", + "BuiltinBare_5Turns_coldGas": "291816", + "BuiltinBare_5Turns_coldSload": "75", "BuiltinBare_5Turns_extraAccounts": "15", "BuiltinBare_5Turns_noop": "10", "BuiltinBare_5Turns_nzToNz": "33", - "BuiltinBare_5Turns_prodStorageGas": "359200", + "BuiltinBare_5Turns_prodStorageGas": "369200", "BuiltinBare_5Turns_totalSload": "435", "BuiltinBare_5Turns_totalSstore": "45", "BuiltinBare_5Turns_zToNz": "2", - "BuiltinHooked_5Turns_coldGas": "290824", - "BuiltinHooked_5Turns_coldSload": "70", + "BuiltinHooked_5Turns_coldGas": "292634", + "BuiltinHooked_5Turns_coldSload": "75", "BuiltinHooked_5Turns_extraAccounts": "15", "BuiltinHooked_5Turns_noop": "10", "BuiltinHooked_5Turns_nzToNz": "33", - "BuiltinHooked_5Turns_prodStorageGas": "359200", + "BuiltinHooked_5Turns_prodStorageGas": "369200", "BuiltinHooked_5Turns_totalSload": "435", "BuiltinHooked_5Turns_totalSstore": "45", "BuiltinHooked_5Turns_zToNz": "2", - "Builtin_Drain3_coldGas": "144889", - "Builtin_Drain3_coldSload": "20", + "Builtin_Drain3_coldGas": "146299", + "Builtin_Drain3_coldSload": "21", "Builtin_Drain3_extraAccounts": "3", "Builtin_Drain3_noop": "7", "Builtin_Drain3_nzToNz": "15", - "Builtin_Drain3_prodStorageGas": "155400", - "Builtin_Drain3_totalSload": "234", + "Builtin_Drain3_prodStorageGas": "157600", + "Builtin_Drain3_totalSload": "236", "Builtin_Drain3_totalSstore": "24", "Builtin_Drain3_zToNz": "2", - "Builtin_Stage3_coldGas": "90154", + "Builtin_Stage3_coldGas": "90049", "Builtin_Stage3_coldSload": "9", "Builtin_Stage3_extraAccounts": "3", "Builtin_Stage3_noop": "0", diff --git a/snapshots/StartBattleGasTest.json b/snapshots/StartBattleGasTest.json index 51d9e509..24893086 100644 --- a/snapshots/StartBattleGasTest.json +++ b/snapshots/StartBattleGasTest.json @@ -1,29 +1,29 @@ { - "StartBattle_Cold_coldGas": "1278992", + "StartBattle_Cold_coldGas": "1282524", "StartBattle_Cold_coldSload": "119", "StartBattle_Cold_extraAccounts": "3", - "StartBattle_Cold_noop": "8", - "StartBattle_Cold_nzToNz": "2", - "StartBattle_Cold_prodStorageGas": "1494600", - "StartBattle_Cold_totalSload": "140", - "StartBattle_Cold_totalSstore": "67", + "StartBattle_Cold_noop": "9", + "StartBattle_Cold_nzToNz": "18", + "StartBattle_Cold_prodStorageGas": "1544800", + "StartBattle_Cold_totalSload": "156", + "StartBattle_Cold_totalSstore": "84", "StartBattle_Cold_zToNz": "57", - "StartBattle_WarmSame_coldGas": "225986", + "StartBattle_WarmSame_coldGas": "229518", "StartBattle_WarmSame_coldSload": "129", "StartBattle_WarmSame_extraAccounts": "3", - "StartBattle_WarmSame_noop": "56", + "StartBattle_WarmSame_noop": "73", "StartBattle_WarmSame_nzToNz": "5", - "StartBattle_WarmSame_prodStorageGas": "473700", - "StartBattle_WarmSame_totalSload": "146", - "StartBattle_WarmSame_totalSstore": "66", + "StartBattle_WarmSame_prodStorageGas": "479100", + "StartBattle_WarmSame_totalSload": "162", + "StartBattle_WarmSame_totalSstore": "83", "StartBattle_WarmSame_zToNz": "4", - "StartBattle_WarmSteady_coldGas": "225986", + "StartBattle_WarmSteady_coldGas": "229518", "StartBattle_WarmSteady_coldSload": "129", "StartBattle_WarmSteady_extraAccounts": "3", - "StartBattle_WarmSteady_noop": "32", - "StartBattle_WarmSteady_nzToNz": "29", - "StartBattle_WarmSteady_prodStorageGas": "540900", - "StartBattle_WarmSteady_totalSload": "146", - "StartBattle_WarmSteady_totalSstore": "66", + "StartBattle_WarmSteady_noop": "41", + "StartBattle_WarmSteady_nzToNz": "37", + "StartBattle_WarmSteady_prodStorageGas": "568700", + "StartBattle_WarmSteady_totalSload": "162", + "StartBattle_WarmSteady_totalSstore": "83", "StartBattle_WarmSteady_zToNz": "4" } \ No newline at end of file diff --git a/src/Constants.sol b/src/Constants.sol index 48170223..f419117e 100644 --- a/src/Constants.sol +++ b/src/Constants.sol @@ -47,15 +47,6 @@ uint256 constant EFFECT_COUNT_MASK = 0x3F; // 6 bits = max count of 63 address constant TOMBSTONE_ADDRESS = address(0xdead); -// Sentinel effect address used for inlined stat-boost entries. Boost sources are stored in the -// normal per-mon effect mappings under this address; the Engine recognizes it and runs the -// inlined stat-boost switch-out logic instead of making an external IEffect call (mirrors the -// address(0) StaminaRegen inline path). It is never a real deployed contract. -address constant STAT_BOOST_ADDRESS = address(0x57B); // "STB" - stat boost -// Steps bitmap stored on inlined stat-boost effect entries: ALWAYS_APPLIES | OnMonSwitchOut (bit 5). -// Matches the legacy StatBoosts.getStepsBitmap() (0x8020) so view/round-trip behavior is unchanged. -uint16 constant STAT_BOOST_STEPS = 0x8020; - // Sentinel ruleset address: when passed as battle.ruleset, the Engine adds // inline StaminaRegen as a global effect without calling an external contract. address constant INLINE_STAMINA_REGEN_RULESET = address(0x57A); // "STA"mina @@ -68,6 +59,23 @@ address constant BUILTIN_DUAL_SIGNED_MANAGER = address(0x5165); // "SIGS" - buil // Bit 15 of stepsBitmap: when set, Engine skips the external shouldApply() call uint16 constant ALWAYS_APPLIES_BIT = 0x8000; +// Bits 10-13 of stepsBitmap: exclusive-status class (0 = not a status; 1-14 = a deployable +// status class; 15 is reserved for test-only mocks and rejected in src/ by the validator). +// Each status declares its own id (an internal STATUS_CLASS constant folded into its +// getStepsBitmap()); validateEffectBitmaps.py asserts uniqueness across src/. The Engine keys +// the per-mon status lane (BattleConfig.monStatusLanes) off these bits — it holds no status list. +uint256 constant STATUS_CLASS_SHIFT = 10; +uint256 constant STATUS_CLASS_MASK = 0xF; + +// Bit 14 of stepsBitmap: when set, Engine calls onReapply() on a same-class re-apply +// (escalating statuses, e.g. Burn). Clear = a same-class re-apply is a zero-call no-op. +uint16 constant HAS_REAPPLY_BIT = 0x4000; + +// getStepsBitmap metadata: lifecycle steps remain in the low 16 bits; the high 16 bits opt an +// effect into fresh per-step hook context. The EVM selector is unchanged from the legacy uint16 +// return, and legacy deployed effects decode as metadata with a zero context half. +uint256 constant EFFECT_CONTEXT_SHIFT = 16; + uint256 constant MAX_BATTLE_DURATION = 1 hours; bytes32 constant MOVE_MISS_EVENT_TYPE = sha256(abi.encode("MoveMiss")); @@ -116,12 +124,14 @@ uint8 constant BATTLE_MODE_DOUBLES = 1; uint8 constant BATTLE_MODE_MULTI = 2; // Deployed-move word with packed static metadata: [address 0-159 | tag bit 160 | -// stamina 236-239 | priority 244-247]. The nibble value 0xF means "battle/state-dependent — +// context capabilities 161+ | stamina 236-239 | priority 244-247]. The nibble value 0xF means "battle/state-dependent — // staticcall the move live" (Rock Pull's punisher priority, HeatBeacon-boosted casts, // Unbounded Strike's stamina). Inline move words never set bit 160 (their 160-227 range is // unused), so the tag cleanly three-ways the discriminator: tagged = deployed+meta, // untagged with high bits = inline, bare address = deployed without meta (legacy/tests). uint256 constant MOVE_META_TAG = 1 << 160; +uint256 constant MOVE_CONTEXT_STATUS_LANES = 1 << 161; +uint256 constant MOVE_RESOLVER_TAG = 1 << 162; uint256 constant MOVE_META_DYNAMIC = 0xF; // The 16-bit move extraData splits [targetBits 4 | movePayload 12]. The nibble is a bitmask diff --git a/src/Engine.sol b/src/Engine.sol index ad10bd99..df32546c 100644 --- a/src/Engine.sol +++ b/src/Engine.sol @@ -10,6 +10,9 @@ import "./moves/IMoveSet.sol"; import {IEngine} from "./IEngine.sol"; import {IAbility} from "./abilities/IAbility.sol"; import {SignedCommitLib} from "./commit-manager/SignedCommitLib.sol"; +import {EffectCommandLib} from "./effects/EffectCommandLib.sol"; +import {IEffectResolver} from "./effects/IEffectResolver.sol"; +import {IStatusEffect} from "./effects/status/IStatusEffect.sol"; import {ECDSA} from "./lib/ECDSA.sol"; import {EIP712} from "./lib/EIP712.sol"; import {MappingAllocator} from "./lib/MappingAllocator.sol"; @@ -18,6 +21,8 @@ import {StatBoostLib} from "./lib/StatBoostLib.sol"; import {TargetLib} from "./lib/TargetLib.sol"; import {ValidatorLogic} from "./lib/ValidatorLogic.sol"; import {AttackCalculator} from "./moves/AttackCalculator.sol"; +import {IMoveResolver} from "./moves/IMoveResolver.sol"; +import {MoveCommandLib} from "./moves/MoveCommandLib.sol"; import {TypeCalcLib} from "./types/TypeCalcLib.sol"; contract Engine is IEngine, MappingAllocator, EIP712 { @@ -56,6 +61,9 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // signal — and the salt read collapses from two transient loads to one. uint256 private transient _turnP0Packed; uint256 private transient _turnP1Packed; + // Two-slot action scheduler invalidation. Speed-mutating API chokepoints mark active lanes; + // the next greedy pick refreshes only those lanes from mon storage. + uint256 private transient _speedDirtySlots; // Errors error NoWriteAllowed(); @@ -130,6 +138,10 @@ contract Engine is IEngine, MappingAllocator, EIP712 { /// @param _DEFAULT_MONS_PER_TEAM Default mons per team for inline validation /// @param _DEFAULT_MOVES_PER_MON Default moves per mon for inline validation constructor(uint256 _DEFAULT_MONS_PER_TEAM, uint256 _DEFAULT_MOVES_PER_MON) { + // Hard cap shared by koBitmaps (8 KO bits/side) and monStatusLanes (8 nibbles/side). + if (_DEFAULT_MONS_PER_TEAM > 8) { + revert InvalidBattleConfig(); + } DEFAULT_MONS_PER_TEAM = _DEFAULT_MONS_PER_TEAM; DEFAULT_MOVES_PER_MON = _DEFAULT_MOVES_PER_MON; } @@ -380,9 +392,11 @@ contract Engine is IEngine, MappingAllocator, EIP712 { (IEffect[] memory effects, bytes32[] memory data) = battle.ruleset.getInitialGlobalEffects(); uint256 numEffects = effects.length; for (uint256 i = 0; i < numEffects;) { - config.globalEffects[i].effect = effects[i]; - config.globalEffects[i].stepsBitmap = effects[i].getStepsBitmap(); - config.globalEffects[i].data = data[i]; + IEffect effect = effects[i]; + uint32 metadata = effect.getStepsBitmap(); + _writeEffectInstance( + config.globalEffects[i], effect, uint16(metadata), data[i], uint16(metadata >> EFFECT_CONTEXT_SHIFT) + ); unchecked { ++i; } @@ -417,6 +431,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } config.koBitmaps = 0; config.globalKVCount = 0; + config.p0BoostCounts = 0; config.teamSizes = newTeamSizes; config.hasInlineStaminaRegen = hasInlineRegen; config.globalEffectsLength = newGlobalEffectsLength; @@ -431,6 +446,9 @@ contract Engine is IEngine, MappingAllocator, EIP712 { config.playerEffectStepsUnion = 0; config.engineHookStepsUnion = hookStepsUnion; config.battleMode = battleMode; + config.monStatusLanes = 0; + config.p1BoostCounts = 0; + config.playerEffectStepsByMon = 0; if ((hookStepsUnion & (1 << uint8(EngineHookStep.OnBattleStart))) != 0) { for (uint256 i = 0; i < numHooks;) { @@ -556,7 +574,6 @@ contract Engine is IEngine, MappingAllocator, EIP712 { if (msg.sender != config.moveManager) { revert WrongCaller(); } - for (uint256 i = 0; i < entries.length; i++) { uint256 entry = entries[i]; uint8 p0Move = uint8(entry); @@ -1127,6 +1144,17 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return playerIndex == 0 ? config.p0Move : config.p1Move; } + /// @dev Allocation-free current-turn lane with the same transient/storage fallback as + /// _getCurrentTurnMove. Used to build fresh external hook context. + function _currentTurnMoveWord(BattleConfig storage config, uint256 playerIndex) private view returns (uint256) { + uint256 encoded = (playerIndex == 0 ? _turnP0Packed : _turnP1Packed) >> 104; + if (encoded != 0) { + return encoded & 0xFFFFFF; + } + MoveDecision storage move = playerIndex == 0 ? config.p0Move : config.p1Move; + return uint256(move.packedMoveIndex) | (uint256(move.extraData) << 8); + } + /// @notice Internal execution logic shared by execute() and executeWithMoves() /// @dev Two independent event controls. `emitMonMoves`: per-turn MonMoves at the top (legacy /// per-turn execute only — the batched/buffer paths announce moves elsewhere). `emitBattleComplete`: @@ -1366,13 +1394,14 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // Stamina regen decision is encapsulated in _inlineStaminaRegen, which reads the move FRESH // — required because effects (SleepStatus) can rewrite the move to a resting NO_OP mid-turn. if (inlineStaminaRegen) { - _inlineStaminaRegen( + playerSwitchForTurnFlag = _inlineStaminaRegen( config, EffectStep.AfterMove, priorityPlayerIndex, _unpackActiveMonIndex(battle.activeMonIndex, priorityPlayerIndex), 0, - 0 + 0, + playerSwitchForTurnFlag ); } @@ -1433,13 +1462,14 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } if (inlineStaminaRegen) { - _inlineStaminaRegen( + playerSwitchForTurnFlag = _inlineStaminaRegen( config, EffectStep.AfterMove, otherPlayerIndex, _unpackActiveMonIndex(battle.activeMonIndex, otherPlayerIndex), 0, - 0 + 0, + playerSwitchForTurnFlag ); } @@ -1491,7 +1521,8 @@ contract Engine is IEngine, MappingAllocator, EIP712 { if (inlineStaminaRegen) { uint256 p0Mon = _unpackActiveMonIndex(battle.activeMonIndex, 0); uint256 p1Mon = _unpackActiveMonIndex(battle.activeMonIndex, 1); - _inlineStaminaRegen(config, EffectStep.RoundEnd, 0, 0, p0Mon, p1Mon); + playerSwitchForTurnFlag = + _inlineStaminaRegen(config, EffectStep.RoundEnd, 0, 0, p0Mon, p1Mon, playerSwitchForTurnFlag); } } @@ -1567,6 +1598,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { koOccurredFlag = 0; tempPreDamage = 0; effectsDirtyBitmap = 0; + _speedDirtySlots = 0; } /// @notice Forcibly end a stalled battle once it has run past MAX_BATTLE_DURATION. @@ -1675,6 +1707,9 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } else if (stateVarIndex == MonStateIndexName.Speed) { monState.speedDelta = (monState.speedDelta == CLEARED_MON_STATE_SENTINEL) ? valueToAdd : monState.speedDelta + valueToAdd; + if (config.battleMode != BATTLE_MODE_SINGLES) { + _markActiveMonSpeedDirty(playerIndex, monIndex); + } } else if (stateVarIndex == MonStateIndexName.Attack) { monState.attackDelta = (monState.attackDelta == CLEARED_MON_STATE_SENTINEL) ? valueToAdd : monState.attackDelta + valueToAdd; @@ -1706,19 +1741,9 @@ contract Engine is IEngine, MappingAllocator, EIP712 { monState.shouldSkipTurn = (valueToAdd % 2) == 1; } - // Trigger OnUpdateMonState lifecycle hook only if some player effect actually listens at it - // (battle-wide union) AND this mon has effects. OnUpdateMonState has a single listener in the - // whole game (Dreamcatcher), so the union bit is unset in almost every battle — skipping the - // abi.encode(4-tuple) + _runEffects shell entirely. Stat-boost delta writes hit this path a lot. - // Union bit FIRST: OnUpdateMonState has a single listener game-wide, so the bit is clear - // in almost every battle — short-circuiting on it skips the per-mon count SLOAD entirely - // (this path fires on every stat-boost delta write, up to 5 per boost application). - if ( - (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnUpdateMonState))) != 0 - && (playerIndex == 0 - ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) - : _getMonEffectCount(config.packedP1EffectsCount, monIndex)) > 0 - ) { + // Exact per-mon step lane skips abi.encode + the whole effect shell unless THIS mon has an + // OnUpdateMonState listener. This path is hot for stamina and stat-boost delta writes. + if (_monListensAt(config, playerIndex, monIndex, EffectStep.OnUpdateMonState)) { _runEffectsPipeline( config, playerIndex, @@ -1840,8 +1865,28 @@ contract Engine is IEngine, MappingAllocator, EIP712 { function _addEffectInternal(uint256 targetIndex, uint256 monIndex, IEffect effect, bytes32 extraData) internal { bytes32 battleKey = battleKeyForWrite; - // Fetch steps bitmap once (reused for storage and ALWAYS_APPLIES check) - uint16 stepsBitmap = effect.getStepsBitmap(); + // Fetch steps bitmap once (reused for the status gate, ALWAYS_APPLIES check, and storage) + uint32 effectMetadata = effect.getStepsBitmap(); + uint16 stepsBitmap = uint16(effectMetadata); + uint16 hookContextBitmap = uint16(effectMetadata >> EFFECT_CONTEXT_SHIFT); + BattleConfig storage config = battleConfig[storageKeyForWrite]; + + // Exclusive-status gate (class-bearing player effects only), ahead of any external call: + // a different status blocks, and a same-class re-apply is a no-op unless HAS_REAPPLY + // routes it to the existing entry's onReapply. Both blocked paths cost zero external + // calls; the free-lane path falls through to the normal gate below. + { + uint256 statusClass = (stepsBitmap >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK; + if (statusClass != 0 && targetIndex != 2) { + uint256 lane = (config.monStatusLanes >> _statusLaneShift(targetIndex, monIndex)) & 0xF; + if (lane != 0) { + if (lane == statusClass && (stepsBitmap & HAS_REAPPLY_BIT) != 0) { + _reapplyStatus(config, battleKey, targetIndex, monIndex, statusClass); + } + return; + } + } + } // Skip external shouldApply() call if ALWAYS_APPLIES_BIT is set bool applies; @@ -1857,39 +1902,44 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // Check if we have to run an onApply state update (use bitmap instead of external call) if ((stepsBitmap & (1 << uint8(EffectStep.OnApply))) != 0) { + uint256 applyContext = _hookActivesWord(battleData[battleKey]); + if ((hookContextBitmap & (1 << uint8(EffectStep.OnApply))) != 0 && targetIndex < 2) { + // OnApply is executed before the effect is stored, so its exact request bit is + // still available. Max HP is immutable for the battle and occupies bits 32..63. + applyContext |= uint256(_getTeamMon(config, targetIndex, monIndex).stats.hp) << 32; + } // If so, we run the effect first, and get updated extraData if necessary (extraDataToUse, removeAfterRun) = effect.onApply( - IEngine(address(this)), - battleKey, - tempRNG, - extraData, - targetIndex, - monIndex, - _hookActivesWord(battleData[battleKey]) + IEngine(address(this)), battleKey, tempRNG, extraData, targetIndex, monIndex, applyContext ); } if (!removeAfterRun) { - // Add to the appropriate effects mapping based on targetIndex - BattleConfig storage config = battleConfig[storageKeyForWrite]; - - // INVARIANT: every path that adds a player effect MUST fold its steps bitmap into - // playerEffectStepsUnion here. The step-skip guards in _executeInternal / _handleSwitch - // treat a clear union bit as "no player effect listens at this step" and skip the whole - // _runEffects shell, so a missed update would silently drop live effects. This is the - // canonical chokepoint (addEffect -> _addEffectInternal); the ONLY other player-effect - // writer is _addStatBoostEffectSlot, which mirrors this with `|= STAT_BOOST_STEPS`. + // INVARIANT: every player-effect add folds its bitmap into BOTH routing caches here: + // the over-approximate battle-wide outer union and this mon's exact lane. A missed + // update would silently skip a live hook. This is the sole player-effect add chokepoint + // (stat boosts use their own store). // Global effects (targetIndex == 2) are gated by globalEffectsLength instead, not the union. if (targetIndex != 2) { config.playerEffectStepsUnion |= stepsBitmap; + uint256 stepLaneShift = _effectStepLaneShift(targetIndex, monIndex); + config.playerEffectStepsByMon |= uint256(stepsBitmap) << stepLaneShift; + // Status-lane set: same slot as the union write, so both coalesce into one + // SSTORE. INVARIANT: lane nonzero ⇔ exactly one class-bearing entry on the + // mon; the only clears are _removeEffectAtSlot + startBattle's reset. + uint256 statusClass = (stepsBitmap >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK; + if (statusClass != 0) { + uint256 laneShift = _statusLaneShift(targetIndex, monIndex); + config.monStatusLanes = uint64( + (uint256(config.monStatusLanes) & ~(uint256(0xF) << laneShift)) | (statusClass << laneShift) + ); + } } if (targetIndex == 2) { // Global effects use simple sequential indexing uint256 effectIndex = config.globalEffectsLength; EffectInstance storage effectSlot = config.globalEffects[effectIndex]; - effectSlot.effect = effect; - effectSlot.stepsBitmap = stepsBitmap; - effectSlot.data = extraDataToUse; + _writeEffectInstance(effectSlot, effect, stepsBitmap, extraDataToUse, hookContextBitmap); config.globalEffectsLength = uint8(effectIndex + 1); // Set dirty bit 0 for global effects effectsDirtyBitmap |= 1; @@ -1898,9 +1948,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 monEffectCount = _getMonEffectCount(config.packedP0EffectsCount, monIndex); uint256 slotIndex = _getEffectSlotIndex(monIndex, monEffectCount); EffectInstance storage effectSlot = config.p0Effects[slotIndex]; - effectSlot.effect = effect; - effectSlot.stepsBitmap = stepsBitmap; - effectSlot.data = extraDataToUse; + _writeEffectInstance(effectSlot, effect, stepsBitmap, extraDataToUse, hookContextBitmap); config.packedP0EffectsCount = _setMonEffectCount(config.packedP0EffectsCount, monIndex, monEffectCount + 1); // Set dirty bit (1 + monIndex) for P0 effects @@ -1909,9 +1957,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 monEffectCount = _getMonEffectCount(config.packedP1EffectsCount, monIndex); uint256 slotIndex = _getEffectSlotIndex(monIndex, monEffectCount); EffectInstance storage effectSlot = config.p1Effects[slotIndex]; - effectSlot.effect = effect; - effectSlot.stepsBitmap = stepsBitmap; - effectSlot.data = extraDataToUse; + _writeEffectInstance(effectSlot, effect, stepsBitmap, extraDataToUse, hookContextBitmap); config.packedP1EffectsCount = _setMonEffectCount(config.packedP1EffectsCount, monIndex, monEffectCount + 1); // Set dirty bit (9 + monIndex) for P1 effects @@ -1921,6 +1967,49 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } } + /// @dev Same-class re-apply with HAS_REAPPLY set: find the live entry and let it rewrite + /// (or remove) itself. The scan is bounded by the mon's short effect list and only + /// runs for escalating statuses (Burn) — plain re-applies never get here. + function _reapplyStatus( + BattleConfig storage config, + bytes32 battleKey, + uint256 targetIndex, + uint256 monIndex, + uint256 statusClass + ) private { + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + for (uint256 i; i < monEffectCount;) { + EffectInstance storage e = effects[baseSlot + i]; + if ( + address(e.effect) != TOMBSTONE_ADDRESS + && ((e.stepsBitmap >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK) == statusClass + ) { + (bytes32 newData, bool removeAfterRun) = IStatusEffect(address(e.effect)) + .onReapply( + IEngine(address(this)), + battleKey, + tempRNG, + _effectData(e), + targetIndex, + monIndex, + _hookActivesWord(battleData[battleKey]) + ); + if (removeAfterRun) { + _removeEffectAtSlot(config, battleKey, targetIndex, monIndex, baseSlot + i); + } else { + _setEffectData(e, newData); + } + return; + } + unchecked { + ++i; + } + } + } + function addEffect(uint256 targetIndex, uint256 monIndex, IEffect effect, bytes32 extraData) external { if (battleKeyForWrite == bytes32(0)) { revert NoWriteAllowed(); @@ -1945,7 +2034,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { effectInstance = config.p1Effects[effectIndex]; } - effectInstance.data = newExtraData; + _setEffectData(effectInstance, newExtraData); } function removeEffect(uint256 targetIndex, uint256 monIndex, uint256 indexToRemove) public { @@ -1956,6 +2045,171 @@ contract Engine is IEngine, MappingAllocator, EIP712 { _removeEffectAtSlot(battleConfig[storageKeyForWrite], battleKey, targetIndex, monIndex, indexToRemove); } + /// @dev Bit offset of a mon's 4-bit status-class nibble in monStatusLanes (stride 8/side). + function _statusLaneShift(uint256 targetIndex, uint256 monIndex) private pure returns (uint256) { + return ((targetIndex << 3) | monIndex) << 2; + } + + /// @dev Bit offset of a mon's uint16 exact lifecycle-step union (stride 8/side). + function _effectStepLaneShift(uint256 targetIndex, uint256 monIndex) private pure returns (uint256) { + return ((targetIndex << 3) | monIndex) << 4; + } + + uint16 private constant EFFECT_RESOLVER_METADATA_BIT = uint16(1) << 15; + uint256 private constant EFFECT_COMPACT_DATA_BITS = 78; + uint256 private constant EFFECT_COMPACT_DATA_MASK = (uint256(1) << EFFECT_COMPACT_DATA_BITS) - 1; + uint256 private constant EFFECT_RESOLVER_FLAG = uint256(1) << 254; + uint256 private constant EFFECT_HOOK_CONTEXT_FLAG = uint256(1) << 255; + uint256 private constant EFFECT_HEADER_BASE_MASK = + uint256(type(uint176).max) | EFFECT_RESOLVER_FLAG | EFFECT_HOOK_CONTEXT_FLAG; + uint256 private constant FRESH_HOOK_CONTEXT_REQUEST = uint256(1) << 255; + + /// @dev Effect header layout: address[0:160], steps[160:176], compact-data marker[176:254], + /// resolver capability[254], fresh-hook-context capability[255]. A nonzero marker stores + /// `data + 1`; zero means the full bytes32 lives in slot 1. + function _effectData(EffectInstance storage effectInstance) private view returns (bytes32 data) { + uint256 header; + assembly ("memory-safe") { + header := sload(effectInstance.slot) + } + uint256 encoded = (header >> 176) & EFFECT_COMPACT_DATA_MASK; + if (encoded != 0) { + return bytes32(encoded - 1); + } + return effectInstance.data; + } + + function _setEffectData(EffectInstance storage effectInstance, bytes32 data) private { + uint256 header; + assembly ("memory-safe") { + header := sload(effectInstance.slot) + } + uint256 raw = uint256(data); + if (raw < EFFECT_COMPACT_DATA_MASK) { + header = (header & EFFECT_HEADER_BASE_MASK) | ((raw + 1) << 176); + assembly ("memory-safe") { + sstore(effectInstance.slot, header) + } + } else { + if (((header >> 176) & EFFECT_COMPACT_DATA_MASK) != 0) { + header &= EFFECT_HEADER_BASE_MASK; + assembly ("memory-safe") { + sstore(effectInstance.slot, header) + } + } + effectInstance.data = data; + } + } + + function _writeEffectInstance( + EffectInstance storage effectInstance, + IEffect effect, + uint16 stepsBitmap, + bytes32 data, + uint16 hookContextBitmap + ) private { + uint256 raw = uint256(data); + uint256 encoded = raw < EFFECT_COMPACT_DATA_MASK ? raw + 1 : 0; + // OnApply has already run before installation. Do not persist an OnApply-only request as + // the broad capability flag, which would make multi-hook statuses build unused contexts. + bool usesResolver = hookContextBitmap & EFFECT_RESOLVER_METADATA_BIT != 0; + uint16 storedContextBitmap = hookContextBitmap + & ~uint16((1 << uint8(EffectStep.OnApply)) | EFFECT_RESOLVER_METADATA_BIT); + uint256 header = uint256(uint160(address(effect))) | (uint256(stepsBitmap) << 160) | (encoded << 176) + | (usesResolver ? EFFECT_RESOLVER_FLAG : 0) + | (storedContextBitmap != 0 ? EFFECT_HOOK_CONTEXT_FLAG : 0); + assembly ("memory-safe") { + sstore(effectInstance.slot, header) + } + if (encoded == 0) { + effectInstance.data = data; + } + } + + function _effectDataAndHookContext(EffectInstance storage effectInstance) + private + view + returns (bytes32 data, bool needsFreshContext, bool usesResolver) + { + uint256 header; + assembly ("memory-safe") { + header := sload(effectInstance.slot) + } + uint256 encoded = (header >> 176) & EFFECT_COMPACT_DATA_MASK; + data = encoded == 0 ? effectInstance.data : bytes32(encoded - 1); + needsFreshContext = header & EFFECT_HOOK_CONTEXT_FLAG != 0; + usesResolver = header & EFFECT_RESOLVER_FLAG != 0; + } + + function _monEffectSteps(BattleConfig storage config, uint256 targetIndex, uint256 monIndex) + private + view + returns (uint16) + { + return uint16(config.playerEffectStepsByMon >> _effectStepLaneShift(targetIndex, monIndex)); + } + + function _monListensAt(BattleConfig storage config, uint256 targetIndex, uint256 monIndex, EffectStep step) + private + view + returns (bool) + { + return _stepsWordListens(config.playerEffectStepsByMon, targetIndex, monIndex, step); + } + + function _stepsWordListens(uint256 stepsWord, uint256 targetIndex, uint256 monIndex, EffectStep step) + private + pure + returns (bool) + { + return ((stepsWord >> _effectStepLaneShift(targetIndex, monIndex)) & (1 << uint8(step))) != 0; + } + + /// @notice Remove a mon's exclusive status (running its onRemove) and clear its lane. + /// @param expectedClass 0 = clear any status; nonzero = only a matching class. + /// @return cleared Whether a status entry was removed. + function clearMonStatus(uint256 targetIndex, uint256 monIndex, uint256 expectedClass) + external + returns (bool cleared) + { + if (battleKeyForWrite == bytes32(0)) { + revert NoWriteAllowed(); + } + return _clearMonStatus(targetIndex, monIndex, expectedClass); + } + + function _clearMonStatus(uint256 targetIndex, uint256 monIndex, uint256 expectedClass) + private + returns (bool cleared) + { + bytes32 battleKey = battleKeyForWrite; + BattleConfig storage config = battleConfig[storageKeyForWrite]; + uint256 lane = (config.monStatusLanes >> _statusLaneShift(targetIndex, monIndex)) & 0xF; + if (lane == 0 || (expectedClass != 0 && lane != expectedClass)) { + return false; + } + // Scan the mon's short effect list for the class-bearing entry (cold path; the lane + // answers the hot existence/identity reads without a scan). + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + for (uint256 i; i < monEffectCount;) { + EffectInstance storage e = effects[baseSlot + i]; + if ( + address(e.effect) != TOMBSTONE_ADDRESS + && ((e.stepsBitmap >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK) == lane + ) { + _removeEffectAtSlot(config, battleKey, targetIndex, monIndex, baseSlot + i); + return true; + } + unchecked { + ++i; + } + } + return false; + } + function _removeEffectAtSlot( BattleConfig storage config, bytes32 battleKey, @@ -1976,12 +2230,24 @@ contract Engine is IEngine, MappingAllocator, EIP712 { if (address(effect) == TOMBSTONE_ADDRESS) { return; } + uint16 stepsBitmap = eff.stepsBitmap; - if ((eff.stepsBitmap & (1 << uint8(EffectStep.OnRemove))) != 0) { + // Status-lane clear (free: stepsBitmap shares the slot just loaded). Cleared before + // onRemove so a nested add during the hook sees the lane free. This is the SOLE + // class-bearing removal chokepoint — the direct tombstone writers elsewhere are all + // stat-boost slots, which never carry class bits. + if (targetIndex != 2) { + uint256 statusClass = (stepsBitmap >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK; + if (statusClass != 0) { + config.monStatusLanes &= ~uint64(uint256(0xF) << _statusLaneShift(targetIndex, monIndex)); + } + } + + if ((stepsBitmap & (1 << uint8(EffectStep.OnRemove))) != 0) { effect.onRemove( IEngine(address(this)), battleKey, - eff.data, + _effectData(eff), targetIndex, monIndex, _hookActivesWord(battleData[battleKey]) @@ -2002,7 +2268,30 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } } else { _compactTrailingTombstones(config, targetIndex, monIndex); + _rebuildMonEffectSteps(config, targetIndex, monIndex); + } + } + + /// @dev Exact-clear the changed mon's step lane after a removal. Removal is cold and already + /// scans/compacts this short list; the hot turn and damage paths consume the exact cache. + function _rebuildMonEffectSteps(BattleConfig storage config, uint256 targetIndex, uint256 monIndex) private { + uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; + uint256 count = _getMonEffectCount(packedCounts, monIndex); + mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; + uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + uint16 rebuilt; + for (uint256 i; i < count;) { + EffectInstance storage e = effects[baseSlot + i]; + if (address(e.effect) != TOMBSTONE_ADDRESS) { + rebuilt |= e.stepsBitmap; + } + unchecked { + ++i; + } } + uint256 shift = _effectStepLaneShift(targetIndex, monIndex); + uint256 mask = uint256(type(uint16).max) << shift; + config.playerEffectStepsByMon = (config.playerEffectStepsByMon & ~mask) | (uint256(rebuilt) << shift); } /// @dev Shrink a mon's packed effect count over any trailing tombstones so the count-gated @@ -2031,14 +2320,14 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // --------------------------------------------------------------------------------------------- // Inlined stat boosts // - // Formerly the standalone `StatBoosts` effect contract. Boost sources are stored in the normal - // per-mon effect mappings under the STAT_BOOST_ADDRESS sentinel (stepsBitmap = STAT_BOOST_STEPS), - // and the aggregated multiplier snapshot lives in globalKV — both already recycled across battles - // by the MappingAllocator-managed storageKey, so no new storage is introduced. Callers (moves, - // abilities, shared effects) invoke these directly during execute, so the boost-source key is - // still derived from msg.sender exactly as it was when StatBoosts saw the caller. The math lives - // in StatBoostLib; here we only touch storage and fire the OnUpdateMonState pipeline via - // _updateMonStateInternal (matching the legacy updateMonState path). + // Formerly the standalone `StatBoosts` effect contract. Boost sources live in their own + // per-mon store — one packed word per source in p0/p1BoostWords, 4-bit counts in + // p0/p1BoostCounts, aggregation cache in statBoostAcc — NOT in the effect mappings, so effect + // passes never iterate them; Temp expiry is a direct _inlineStatBoostSwitchOut call in + // _handleSwitch. Callers (moves, abilities, shared effects) invoke these directly during + // execute, so the boost-source key is still derived from msg.sender exactly as it was when + // StatBoosts saw the caller. The math lives in StatBoostLib; here we only touch storage and + // fire the OnUpdateMonState pipeline via _updateMonStateInternal (matching the legacy path). // --------------------------------------------------------------------------------------------- /// @notice Apply a stat-boost source keyed by msg.sender (no salt). Merges into an existing @@ -2072,31 +2361,15 @@ contract Engine is IEngine, MappingAllocator, EIP712 { revert NoWriteAllowed(); } BattleConfig storage config = battleConfig[storageKeyForWrite]; - uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; - mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; - uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); - uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); - - uint256 removeCount; - for (uint256 i; i < monEffectCount; ++i) { - EffectInstance storage eff = effects[baseSlot + i]; - if (address(eff.effect) == STAT_BOOST_ADDRESS) { - eff.effect = IEffect(TOMBSTONE_ADDRESS); - unchecked { - ++removeCount; - } - } - } - if (removeCount == 0) { + if (_boostCountOf(config, targetIndex, monIndex) == 0) { return; } - _compactTrailingTombstones(config, targetIndex, monIndex); + _setBoostCountOf(config, targetIndex, monIndex, 0); + config.statBoostAcc[(targetIndex << 3) | monIndex] = 0; // also clears a DISABLED flag - // Reset to base by applying with empty aggregation. + // Telescope back to base stats. uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + _applyBoostedStats(config, targetIndex, monIndex, baseStats, baseStats); } function _getStatBoostBaseStats(BattleConfig storage config, uint256 targetIndex, uint256 monIndex) @@ -2112,6 +2385,37 @@ contract Engine is IEngine, MappingAllocator, EIP712 { stats[4] = monStats.speed; } + // Boost-source store: one packed word per source at slot monIndex * 16 + i (i < the mon's + // 4-bit count nibble). 15 sources/mon is far above the plausible distinct-caller ceiling. + + function _boostWordsOf(BattleConfig storage config, uint256 targetIndex) + private + view + returns (mapping(uint256 => bytes32) storage) + { + return targetIndex == 0 ? config.p0BoostWords : config.p1BoostWords; + } + + function _boostCountOf(BattleConfig storage config, uint256 targetIndex, uint256 monIndex) + private + view + returns (uint256) + { + uint32 packed = targetIndex == 0 ? config.p0BoostCounts : config.p1BoostCounts; + return (packed >> (monIndex * 4)) & 0xF; + } + + function _setBoostCountOf(BattleConfig storage config, uint256 targetIndex, uint256 monIndex, uint256 newCount) + private + { + uint256 shift = monIndex * 4; + if (targetIndex == 0) { + config.p0BoostCounts = uint32((config.p0BoostCounts & ~(uint256(0xF) << shift)) | (newCount << shift)); + } else { + config.p1BoostCounts = uint32((config.p1BoostCounts & ~(uint256(0xF) << shift)) | (newCount << shift)); + } + } + function _addStatBoostWithKey( uint256 targetIndex, uint256 monIndex, @@ -2121,158 +2425,128 @@ contract Engine is IEngine, MappingAllocator, EIP712 { ) private { BattleConfig storage config = battleConfig[storageKeyForWrite]; uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); + mapping(uint256 => bytes32) storage words = _boostWordsOf(config, targetIndex); + uint256 count = _boostCountOf(config, targetIndex, monIndex); + uint256 baseSlot = monIndex * 16; - uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; - mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; - uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); - uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); - + // Find an existing same-key/same-permanence source (header-only reads — no lane unpack). bool found; - uint256 foundSlot; - bytes32 existingData; - uint256 tombstoneSlot = type(uint256).max; - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - - // Single pass: find the matching key, aggregate every OTHER boost source on the mon, and - // remember the first tombstoned slot — a fresh source reuses it instead of appending, so - // temp-boost churn (Overclock re-applying every switch-in) stops growing the list. - for (uint256 i; i < monEffectCount; ++i) { - uint256 slotIndex = baseSlot + i; - EffectInstance storage eff = effects[slotIndex]; - address effAddr = address(eff.effect); - if (effAddr == TOMBSTONE_ADDRESS && tombstoneSlot == type(uint256).max) { - tombstoneSlot = slotIndex; - continue; - } - if (effAddr != STAT_BOOST_ADDRESS) { - continue; - } - bytes32 data = eff.data; - (bool effIsPerm, uint168 existingKey, uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = - StatBoostLib.unpackBoostData(data); - if (existingKey == key && effIsPerm == isPerm) { + uint256 foundIdx; + bytes32 oldWord; + for (uint256 i; i < count; ++i) { + bytes32 w = words[baseSlot + i]; + (bool wPerm, uint168 wKey) = StatBoostLib.unpackBoostHeader(w); + if (wKey == key && wPerm == isPerm) { found = true; - foundSlot = slotIndex; - existingData = data; - continue; // excluded from aggregation; merged version is added below + foundIdx = i; + oldWord = w; + break; } - StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoostsPerStat, accumulatedNumeratorPerStat); } - // Compute the new/merged source and add its contribution. Fresh sources accumulate - // straight from the caller's entries (no pack -> unpack round-trip). - bytes32 newData; + bytes32 newWord; if (found) { - (,, uint8[5] memory ep, uint8[5] memory ec, bool[5] memory em) = StatBoostLib.unpackBoostData(existingData); + (,, uint8[5] memory ep, uint8[5] memory ec, bool[5] memory em) = StatBoostLib.unpackBoostData(oldWord); (uint8[5] memory fp, uint8[5] memory fc, bool[5] memory fm) = StatBoostLib.mergeExistingAndNewBoosts(ep, ec, em, statBoostsToApply); - newData = StatBoostLib.packBoostDataWithArrays(key, isPerm, fp, fc, fm); - StatBoostLib.accumulateBoosts(baseStats, fp, fc, fm, numBoostsPerStat, accumulatedNumeratorPerStat); + newWord = StatBoostLib.packBoostDataWithArrays(key, isPerm, fp, fc, fm); + words[baseSlot + foundIdx] = newWord; } else { - newData = StatBoostLib.packBoostData(key, isPerm, statBoostsToApply); - StatBoostLib.accumulateBoostsToApply( - baseStats, statBoostsToApply, numBoostsPerStat, accumulatedNumeratorPerStat - ); + if (count == 15) { + revert InvalidBattleConfig(); + } + newWord = StatBoostLib.packBoostData(key, isPerm, statBoostsToApply); + words[baseSlot + count] = newWord; + _setBoostCountOf(config, targetIndex, monIndex, count + 1); } - // Persist the source entry. - if (found) { - effects[foundSlot].data = newData; + // Accumulator update: O(changed lanes), replacing the legacy all-source rescan. A merge + // divides the old word out and multiplies the merged word in (always correct, order-free). + // count-before == 0 initializes — stale recycled-key accumulators are never read. + uint256 lane = (targetIndex << 3) | monIndex; + uint256 acc = count == 0 ? 0 : config.statBoostAcc[lane]; + if (acc & StatBoostLib.ACC_DISABLED_BIT == 0) { + bool ok = true; + if (found) { + (acc, ok) = StatBoostLib.applyWordToAcc(acc, oldWord, baseStats, false); + } + if (ok) { + (acc, ok) = StatBoostLib.applyWordToAcc(acc, newWord, baseStats, true); + } + if (!ok) { + acc = StatBoostLib.ACC_DISABLED_BIT; // overflow: recompute-from-sources from here on + } + config.statBoostAcc[lane] = acc; } else { - _addStatBoostEffectSlot( - config, targetIndex, monIndex, packedCounts, effects, monEffectCount, newData, tombstoneSlot - ); + acc = StatBoostLib.ACC_DISABLED_BIT; } - _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + _applyStatBoostAggregates(config, targetIndex, monIndex, baseStats, acc); } function _removeStatBoostWithKey(uint256 targetIndex, uint256 monIndex, uint168 key, bool isPerm) private { BattleConfig storage config = battleConfig[storageKeyForWrite]; - uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); - - uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; - mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; - uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); - uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); - - bool found; - uint256 foundSlot; - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - - for (uint256 i; i < monEffectCount; ++i) { - uint256 slotIndex = baseSlot + i; - EffectInstance storage eff = effects[slotIndex]; - if (address(eff.effect) != STAT_BOOST_ADDRESS) { + mapping(uint256 => bytes32) storage words = _boostWordsOf(config, targetIndex); + uint256 count = _boostCountOf(config, targetIndex, monIndex); + uint256 baseSlot = monIndex * 16; + + for (uint256 i; i < count; ++i) { + bytes32 w = words[baseSlot + i]; + (bool wPerm, uint168 wKey) = StatBoostLib.unpackBoostHeader(w); + if (wKey != key || wPerm != isPerm) { continue; } - (bool effIsPerm, uint168 existingKey, uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = - StatBoostLib.unpackBoostData(eff.data); - if (existingKey == key && effIsPerm == isPerm) { - found = true; - foundSlot = slotIndex; - continue; // removed: excluded from aggregation + // Swap-remove (aggregation is commutative; source order is meaningless). + uint256 last = count - 1; + if (i != last) { + words[baseSlot + i] = words[baseSlot + last]; + } + _setBoostCountOf(config, targetIndex, monIndex, last); + + uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); + uint256 lane = (targetIndex << 3) | monIndex; + uint256 acc = config.statBoostAcc[lane]; + if (acc & StatBoostLib.ACC_DISABLED_BIT == 0) { + bool ok; + (acc, ok) = StatBoostLib.applyWordToAcc(acc, w, baseStats, false); + if (!ok) { + acc = StatBoostLib.ACC_DISABLED_BIT; + } + config.statBoostAcc[lane] = acc; } - StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoostsPerStat, accumulatedNumeratorPerStat); - } - - if (!found) { + _applyStatBoostAggregates(config, targetIndex, monIndex, baseStats, acc); return; } - effects[foundSlot].effect = IEffect(TOMBSTONE_ADDRESS); - _compactTrailingTombstones(config, targetIndex, monIndex); - _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); } - /// @dev Write a fresh stat-boost source into the next free per-mon effect slot. Mirrors the - /// player-effect storage block in _addEffectInternal (count bump + dirty bit) but writes - /// the sentinel address and fixed steps bitmap directly — no external IEffect calls. - function _addStatBoostEffectSlot( + /// @dev Telescope from either the fast-path accumulator or (when DISABLED) a full + /// recompute over the mon's source words — the recompute IS the legacy aggregation, + /// so overflow behavior degrades to exactly the old math. + function _applyStatBoostAggregates( BattleConfig storage config, uint256 targetIndex, uint256 monIndex, - uint96 packedCounts, - mapping(uint256 => EffectInstance) storage effects, - uint256 monEffectCount, - bytes32 data, - uint256 tombstoneSlot + uint32[5] memory baseStats, + uint256 acc ) private { - // Reuse a tombstoned slot when the caller's scan found one: the effect word goes - // TOMBSTONE -> STAT_BOOST (nz->nz, 2.9k instead of a fresh 20k on virgin keys), the - // count is NOT bumped (the slot is already inside it — no packed-count SSTORE, no dirty - // TSTORE), and the list stops growing monotonically. Position shift is safe: boost - // entries only act at OnMonSwitchOut and aggregation order is commutative; a boost - // landing in an already-passed slot mid-_runEffects-iteration is skipped for that pass - // (only reachable via OnMonSwitchOut listeners adding boosts, and harmless then). - if (tombstoneSlot != type(uint256).max) { - EffectInstance storage reused = effects[tombstoneSlot]; - reused.effect = IEffect(STAT_BOOST_ADDRESS); - reused.stepsBitmap = STAT_BOOST_STEPS; - reused.data = data; - // Same playerEffectStepsUnion invariant as the append path below. - config.playerEffectStepsUnion |= STAT_BOOST_STEPS; - return; - } - uint256 slotIndex = _getEffectSlotIndex(monIndex, monEffectCount); - EffectInstance storage effectSlot = effects[slotIndex]; - effectSlot.effect = IEffect(STAT_BOOST_ADDRESS); - effectSlot.stepsBitmap = STAT_BOOST_STEPS; - effectSlot.data = data; - // Upholds the playerEffectStepsUnion invariant documented in _addEffectInternal: this is the - // one player-effect add-path that bypasses _addEffectInternal, so it must fold its steps in too. - // Without this the union under-reports OnMonSwitchOut and _handleSwitch would skip dropping - // temp boosts on switch-out. - config.playerEffectStepsUnion |= STAT_BOOST_STEPS; - uint96 newCounts = _setMonEffectCount(packedCounts, monIndex, monEffectCount + 1); - if (targetIndex == 0) { - config.packedP0EffectsCount = newCounts; - effectsDirtyBitmap |= (1 << (1 + monIndex)); + uint32[5] memory newBoostedStats; + if (acc & StatBoostLib.ACC_DISABLED_BIT != 0) { + mapping(uint256 => bytes32) storage words = _boostWordsOf(config, targetIndex); + uint256 count = _boostCountOf(config, targetIndex, monIndex); + uint256 baseSlot = monIndex * 16; + uint32[5] memory numBoostsPerStat; + uint256[5] memory accumulatedNumeratorPerStat; + for (uint256 i; i < count; ++i) { + (,, uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = + StatBoostLib.unpackBoostData(words[baseSlot + i]); + StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoostsPerStat, accumulatedNumeratorPerStat); + } + newBoostedStats = + StatBoostLib.finalizeBoostedStats(baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); } else { - config.packedP1EffectsCount = newCounts; - effectsDirtyBitmap |= (1 << (9 + monIndex)); + newBoostedStats = StatBoostLib.finalizeAccStats(acc, baseStats); } + _applyBoostedStats(config, targetIndex, monIndex, baseStats, newBoostedStats); } /// @dev Re-apply a mon's aggregated stat boosts by telescoping its monState deltas. The @@ -2281,17 +2555,13 @@ contract Engine is IEngine, MappingAllocator, EIP712 { /// base + currentDelta. We compute the new boosted stat and feed the difference through /// _updateMonStateInternal, which fires OnUpdateMonState for listeners exactly as before. /// No globalKV snapshot is kept — being inside the Engine we read the delta back directly. - function _applyStatBoosts( + function _applyBoostedStats( BattleConfig storage config, uint256 targetIndex, uint256 monIndex, uint32[5] memory baseStats, - uint32[5] memory numBoostsPerStat, - uint256[5] memory accumulatedNumeratorPerStat + uint32[5] memory newBoostedStats ) private { - uint32[5] memory newBoostedStats = StatBoostLib.finalizeBoostedStats( - baseStats, numBoostsPerStat, accumulatedNumeratorPerStat - ); MonState storage st = _getMonState(config, targetIndex, monIndex); // Listener gate hoisted ONCE per apply (was paid inside _updateMonStateInternal per stat). @@ -2300,10 +2570,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // reverts StatRequiresStatBoost for them), so the stored delta after any telescoped op is // exactly newBoosted - base — writing that directly is bit-identical to the dispatcher's // sentinel-aware add, minus its 2 TLOADs + 2 mapping keccaks + enum chain per stat. - bool hasListener = (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnUpdateMonState))) != 0 - && (targetIndex == 0 - ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) - : _getMonEffectCount(config.packedP1EffectsCount, monIndex)) > 0; + bool hasListener = _monListensAt(config, targetIndex, monIndex, EffectStep.OnUpdateMonState); for (uint256 i; i < 5; ++i) { // old boosted = base + current stat-boost delta (sentinel reads as 0 / "no boost") @@ -2327,6 +2594,9 @@ contract Engine is IEngine, MappingAllocator, EIP712 { st.specialDefenceDelta = newDelta; } else { st.speedDelta = newDelta; + if (config.battleMode != BATTLE_MODE_SINGLES) { + _markActiveMonSpeedDirty(targetIndex, monIndex); + } } } } @@ -2350,36 +2620,45 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return d == CLEARED_MON_STATE_SENTINEL ? int32(0) : d; } - /// @dev Switch-out handling for stat-boost entries: in a single pass drop EVERY temp source on - /// the mon (they all expire on switch-out) and re-aggregate the surviving permanent sources, - /// applying the result once. _runEffects only routes here when it hits a temp entry; that - /// first hit does all the work and tombstones its siblings, so the remaining temp slots are - /// skipped by the loop's tombstone guard (collapses the legacy per-instance O(n^2) recompute). + /// @dev Switch-out expiry: drop EVERY temp source on the mon in one pass (swap-compacting the + /// store) and apply the surviving aggregate once. Called directly from the switch path — + /// boost sources no longer live in the effect list, so no OnMonSwitchOut pass is involved. function _inlineStatBoostSwitchOut(BattleConfig storage config, uint256 targetIndex, uint256 monIndex) private { - mapping(uint256 => EffectInstance) storage effects = targetIndex == 0 ? config.p0Effects : config.p1Effects; - uint96 packedCounts = targetIndex == 0 ? config.packedP0EffectsCount : config.packedP1EffectsCount; - uint256 monEffectCount = _getMonEffectCount(packedCounts, monIndex); - uint256 baseSlot = _getEffectSlotIndex(monIndex, 0); + mapping(uint256 => bytes32) storage words = _boostWordsOf(config, targetIndex); + uint256 count = _boostCountOf(config, targetIndex, monIndex); + uint256 baseSlot = monIndex * 16; uint32[5] memory baseStats = _getStatBoostBaseStats(config, targetIndex, monIndex); - uint32[5] memory numBoostsPerStat; - uint256[5] memory accumulatedNumeratorPerStat; - for (uint256 i; i < monEffectCount; ++i) { - EffectInstance storage eff = effects[baseSlot + i]; - if (address(eff.effect) != STAT_BOOST_ADDRESS) { + uint256 lane = (targetIndex << 3) | monIndex; + uint256 acc = config.statBoostAcc[lane]; + bool live = acc & StatBoostLib.ACC_DISABLED_BIT == 0; + uint256 kept = count; + uint256 i; + while (i < kept) { + bytes32 w = words[baseSlot + i]; + if (StatBoostLib.isPerm(w)) { + ++i; continue; } - (bool effIsPerm,, uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = - StatBoostLib.unpackBoostData(eff.data); - if (!effIsPerm) { - eff.effect = IEffect(TOMBSTONE_ADDRESS); // temp sources all expire on switch-out - continue; + if (live) { + bool ok; + (acc, ok) = StatBoostLib.applyWordToAcc(acc, w, baseStats, false); + if (!ok) { + acc = StatBoostLib.ACC_DISABLED_BIT; + live = false; + } + } + --kept; + if (i != kept) { + words[baseSlot + i] = words[baseSlot + kept]; } - StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoostsPerStat, accumulatedNumeratorPerStat); } - - _compactTrailingTombstones(config, targetIndex, monIndex); - _applyStatBoosts(config, targetIndex, monIndex, baseStats, numBoostsPerStat, accumulatedNumeratorPerStat); + if (kept == count) { + return; // nothing expired: no writes, no telescope + } + _setBoostCountOf(config, targetIndex, monIndex, kept); + config.statBoostAcc[lane] = acc; + _applyStatBoostAggregates(config, targetIndex, monIndex, baseStats, acc); } function setGlobalKV(uint64 key, uint192 value) external { @@ -2459,18 +2738,11 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // PreDamage pipeline: victim-side mon-local effects can mutate the in-flight damage by // calling engine.setPreDamage(). Reuses the standard _runEffects loop; running damage is // threaded through the transient `tempPreDamage` slot so the iteration logic doesn't change. - // Union bits FIRST (PreDamage and AfterDamage each have a single listener game-wide): - // one shared union read covers both gates in the common no-listener case, and the per-mon - // count SLOAD is deferred until a bit actually passes — counts never shrink (tombstones), - // so the lazy >0 check stays fresh-safe. - uint16 stepsUnion = config.playerEffectStepsUnion; + // One exact per-mon lane read covers both gates, preventing another mon's listener from + // routing this victim through a non-matching scan. + uint16 monSteps = _monEffectSteps(config, playerIndex, monIndex); bool ranPreDamage; - if ( - (stepsUnion & uint16(1 << uint8(EffectStep.PreDamage))) != 0 - && (playerIndex == 0 - ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) - : _getMonEffectCount(config.packedP1EffectsCount, monIndex)) > 0 - ) { + if ((monSteps & uint16(1 << uint8(EffectStep.PreDamage))) != 0) { tempPreDamage = damage; _runEffectsPipeline(config, playerIndex, monIndex, EffectStep.PreDamage, abi.encode(source)); damage = tempPreDamage; @@ -2481,11 +2753,22 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return; } - // If sentinel, replace with -damage; otherwise subtract damage - monState.hpDelta = (monState.hpDelta == CLEARED_MON_STATE_SENTINEL) ? -damage : monState.hpDelta - damage; + // If sentinel, replace with -damage; otherwise subtract damage. The damage formula clamps at + // type(int32).max, so a large hit on an already-damaged mon can carry the delta past int32 — + // a revert that would wedge the battle. Do the subtraction unchecked and detect the wrap: + // damage is positive, so a result that did not decrease means it overflowed. + int32 priorDelta = monState.hpDelta; + int32 newDelta; + unchecked { + newDelta = (priorDelta == CLEARED_MON_STATE_SENTINEL) ? -damage : priorDelta - damage; + } // Set KO flag if the total hpDelta is greater than the original mon HP uint32 baseHp = _getTeamMon(config, playerIndex, monIndex).stats.hp; + if (priorDelta != CLEARED_MON_STATE_SENTINEL && newDelta > priorDelta) { + newDelta = -int32(baseHp); // past fully-dead; the exact magnitude carries no meaning + } + monState.hpDelta = newDelta; if (monState.hpDelta + int32(baseHp) <= 0) { monState.isKnockedOut = true; _setMonKO(config, playerIndex, monIndex); @@ -2494,18 +2777,13 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // Lock in winner immediately if this KO ends the game _checkAndSetWinnerIfGameOver(config, playerIndex); } - // AfterDamage gate. The union is re-read ONLY when the PreDamage pipeline actually ran + // AfterDamage gate. The mon lane is re-read ONLY when the PreDamage pipeline actually ran // (its effects may have added an AfterDamage listener mid-call); otherwise the shared // read above is still authoritative — nothing else can mutate it inside this function. if (ranPreDamage) { - stepsUnion = config.playerEffectStepsUnion; + monSteps = _monEffectSteps(config, playerIndex, monIndex); } - if ( - (stepsUnion & uint16(1 << uint8(EffectStep.AfterDamage))) != 0 - && (playerIndex == 0 - ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) - : _getMonEffectCount(config.packedP1EffectsCount, monIndex)) > 0 - ) { + if ((monSteps & uint16(1 << uint8(EffectStep.AfterDamage))) != 0) { _runEffectsPipeline(config, playerIndex, monIndex, EffectStep.AfterDamage, abi.encode(damage, source)); } } @@ -2519,10 +2797,6 @@ contract Engine is IEngine, MappingAllocator, EIP712 { _dealDamageInternal(config, playerIndex, monIndex, damage, uint256(uint160(msg.sender))); } - function getPreDamage() external view returns (int32) { - return tempPreDamage; - } - function setPreDamage(int32 value) external { if (battleKeyForWrite == bytes32(0)) { revert NoWriteAllowed(); @@ -2621,6 +2895,85 @@ contract Engine is IEngine, MappingAllocator, EIP712 { ); } + function _executeResolvedMove( + BattleConfig storage config, + BattleData storage battle, + uint256 rawMoveSlot, + uint256 attackerPlayerIndex, + uint256 attackerMonIndex, + uint256 targetBits, + uint256 activesPacked, + uint16 extraData, + uint256 rng + ) private { + IMoveResolver resolver = IMoveResolver(address(uint160(rawMoveSlot))); + (uint256 command, uint32 continuation) = resolver.resolveMove( + 0, 0, attackerPlayerIndex, attackerMonIndex, targetBits, activesPacked, extraData, rng + ); + int32 result = _executeResolvedMoveCommand( + config, battle, rawMoveSlot, attackerPlayerIndex, attackerMonIndex, targetBits, command, rng + ); + if (continuation != 0) { + (command,) = resolver.resolveMove( + continuation, + result, + attackerPlayerIndex, + attackerMonIndex, + targetBits, + activesPacked, + extraData, + rng + ); + _executeResolvedMoveCommand( + config, battle, rawMoveSlot, attackerPlayerIndex, attackerMonIndex, targetBits, command, rng + ); + } + } + + function _executeResolvedMoveCommand( + BattleConfig storage config, + BattleData storage battle, + uint256 rawMoveSlot, + uint256 attackerPlayerIndex, + uint256 attackerMonIndex, + uint256 targetBits, + uint256 command, + uint256 rng + ) private returns (int32 result) { + uint256 op = uint8(command); + if (op == MoveCommandLib.OP_ATTACK) { + uint256 targetSlot = TargetLib.lowestSlot(targetBits); + if (targetSlot == NO_SLOT) { + return 0; + } + uint256 defenderMonIndex = _slotActive(battle, targetSlot); + if (defenderMonIndex == EMPTY_ACTIVE_LANE) { + return 0; + } + (result,) = _dispatchStandardAttackInternal( + config, + attackerPlayerIndex, + attackerMonIndex, + targetSlot >> 1, + defenderMonIndex, + uint32(command >> 8), + uint8(command >> 40), + uint8(command >> 48), + Type(uint8(command >> 56)), + MoveClass(uint8(command >> 64)), + uint8(command >> 72), + uint8(command >> 80), + IEffect(address(uint160(command >> 88))), + rng, + uint256(uint160(rawMoveSlot)) + ); + } else if (op == MoveCommandLib.OP_SWITCH) { + _switchActiveMonForSlotInternal( + uint8(command >> 8), uint8(command >> 16), uint8(command >> 24), config, battle + ); + } + } + function dispatchStandardAttack( uint256 attackerPlayerIndex, uint256 attackerMonIndex, @@ -2748,6 +3101,17 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } BattleConfig storage config = battleConfig[storageKeyForWrite]; BattleData storage battle = battleData[battleKey]; + _switchActiveMonForSlotInternal(playerIndex, slotIndex, monToSwitchIndex, config, battle); + } + + function _switchActiveMonForSlotInternal( + uint256 playerIndex, + uint256 slotIndex, + uint256 monToSwitchIndex, + BattleConfig storage config, + BattleData storage battle + ) private { + bytes32 battleKey = battleKeyForWrite; // Flow-aware like the other slot surfaces: singles routes through the legacy switch // (whose validation has no ally lane to collide with). if (!battle.isTwoSlotMode) { @@ -2979,16 +3343,22 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // If so, remove the effect and the extra data. // Guard each _runEffects so we don't pay its setup (storage re-resolution + count load) when // there's provably nothing to run: the switching mon has no effects (count == 0) or no effect - // anywhere listens at this step (union bit clear — stat boosts now fold their OnMonSwitchOut - // step into the union via _addStatBoostEffectSlot), and the global list is empty. + // anywhere listens at this step (union bit clear), and the global list is empty. if (!currentMonState.isKnockedOut) { uint256 outCount = playerIndex == 0 ? _getMonEffectCount(config.packedP0EffectsCount, currentActiveMonIndex) : _getMonEffectCount(config.packedP1EffectsCount, currentActiveMonIndex); - if (outCount > 0 && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnMonSwitchOut))) != 0) { + if (outCount > 0 && _monListensAt(config, playerIndex, currentActiveMonIndex, EffectStep.OnMonSwitchOut)) { _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchOut, "", outCount); } + // Temp stat-boost expiry, called directly (boost sources live in their own store, not + // the effect list — mons with only boosts skip the whole OnMonSwitchOut pass above). + // After the pass so switch-out effects observe the outgoing mon's boosted stats. + if (_boostCountOf(config, playerIndex, currentActiveMonIndex) != 0) { + _inlineStatBoostSwitchOut(config, playerIndex, currentActiveMonIndex); + } + // Then run the global on mon switch out hook as well if (config.globalEffectsLength > 0) { _runEffects(battleKey, tempRNG, 2, playerIndex, EffectStep.OnMonSwitchOut, "", type(uint256).max); @@ -3002,7 +3372,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 inCount = playerIndex == 0 ? _getMonEffectCount(config.packedP0EffectsCount, monToSwitchIndex) : _getMonEffectCount(config.packedP1EffectsCount, monToSwitchIndex); - if (inCount > 0 && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnMonSwitchIn))) != 0) { + if (inCount > 0 && _monListensAt(config, playerIndex, monToSwitchIndex, EffectStep.OnMonSwitchIn)) { _runEffects(battleKey, tempRNG, playerIndex, playerIndex, EffectStep.OnMonSwitchIn, "", inCount); } @@ -3159,19 +3529,37 @@ contract Engine is IEngine, MappingAllocator, EIP712 { staminaCost = int32(moveStamina); } _deductStamina(currentMonState, staminaCost); - - moveSet.move( - self, - battleKey, - playerIndex, - activeMonIndex, - TargetLib.impliedSinglesTargetBits(playerIndex), - TargetLib.singlesActives( - _unpackActiveMonIndex(battle.activeMonIndex, 0), _unpackActiveMonIndex(battle.activeMonIndex, 1) - ), - move.extraData & EXTRA_DATA_PAYLOAD_MASK, - tempRNG + uint256 moveContext = TargetLib.singlesActives( + _unpackActiveMonIndex(battle.activeMonIndex, 0), _unpackActiveMonIndex(battle.activeMonIndex, 1) ); + if (rawMoveSlot & MOVE_CONTEXT_STATUS_LANES != 0) { + moveContext |= uint256(config.monStatusLanes) << 132; + } + uint256 targetBits = TargetLib.impliedSinglesTargetBits(playerIndex); + if (rawMoveSlot & MOVE_RESOLVER_TAG != 0) { + _executeResolvedMove( + config, + battle, + rawMoveSlot, + playerIndex, + activeMonIndex, + targetBits, + moveContext, + move.extraData & EXTRA_DATA_PAYLOAD_MASK, + tempRNG + ); + } else { + moveSet.move( + self, + battleKey, + playerIndex, + activeMonIndex, + targetBits, + moveContext, + move.extraData & EXTRA_DATA_PAYLOAD_MASK, + tempRNG + ); + } } } @@ -3262,12 +3650,31 @@ contract Engine is IEngine, MappingAllocator, EIP712 { eff = config.p1Effects[slotIndex]; } - // Skip tombstones AND entries that don't listen at this step. The step filter is - // hoisted here on purpose: stepsBitmap shares slot 0 with the effect address (free - // check), while letting _runSingleEffect do it would first pay the eff.data SLOAD - // (slot 1) + 13-arg call setup. Stat-boost sentinel entries (OnMonSwitchOut-only) - // otherwise tax every RoundStart/RoundEnd/AfterMove/AfterDamage pass over their mon. + // Skip tombstones AND entries that don't listen at this step before reading slot-1 data. if (address(eff.effect) != TOMBSTONE_ADDRESS && (eff.stepsBitmap & (1 << uint8(round))) != 0) { + (bytes32 effectData, bool needsFreshContext, bool usesResolver) = _effectDataAndHookContext(eff); + uint256 hookContext = activesPacked; + if (needsFreshContext) { + if (round == EffectStep.RoundEnd) { + // Built immediately before this effect call, after every earlier effect in + // the same pass, so status mutations cannot stale the snapshot. + hookContext |= uint256(config.monStatusLanes) << 132; + } else if (round == EffectStep.AfterDamage) { + // Damage has landed and KO state is finalized. The loop already has the + // exact effect owner and config, so this needs no dispatch-time lookup. + if (_getMonState(config, playerIndex, monIndex).isKnockedOut) { + hookContext |= uint256(1) << 32; + } + } else if (round == EffectStep.PreDamage) { + // This transient is updated by every earlier PreDamage effect, so reading + // it here gives each callback the current composed signed damage value. + hookContext |= uint256(uint32(tempPreDamage)) << 32; + } else if (round == EffectStep.AfterMove || round == EffectStep.OnUpdateMonState) { + // These hook-specific builders need data resolved at dispatch time. Keep + // the request out of the public low-32 active lanes until then. + hookContext |= FRESH_HOOK_CONTEXT_REQUEST; + } + } _runSingleEffect( config, rng, @@ -3277,10 +3684,10 @@ contract Engine is IEngine, MappingAllocator, EIP712 { round, extraEffectsData, eff.effect, - eff.stepsBitmap, - eff.data, + effectData, uint96(slotIndex), - activesPacked + hookContext, + usesResolver ); // Re-read count if a new effect was added during this iteration @@ -3305,30 +3712,22 @@ contract Engine is IEngine, MappingAllocator, EIP712 { EffectStep round, bytes memory extraEffectsData, IEffect effect, - uint16 stepsBitmap, bytes32 data, uint96 slotIndex, - uint256 activesPacked + uint256 activesPacked, + bool usesResolver ) private { - // Use stored bitmap instead of external call to shouldRunAtStep() - if ((stepsBitmap & (1 << uint8(round))) == 0) { - return; - } - - // Inline execution for stat-boost sentinel entries. Only OnMonSwitchOut is in their steps - // bitmap; a temp boost is dropped on switch-out and the mon's stats recomputed from the - // remaining permanent sources (mirrors the legacy StatBoosts.onMonSwitchOut path). - if (address(effect) == STAT_BOOST_ADDRESS) { - if (!StatBoostLib.isPerm(data)) { - _inlineStatBoostSwitchOut(config, effectIndex, monIndex); - } - return; - } - // Run the effect and get result - (bytes32 updatedExtraData, bool removeAfterRun) = _executeEffectHook( - battleKeyForWrite, effect, rng, data, playerIndex, monIndex, round, extraEffectsData, activesPacked - ); + bytes32 updatedExtraData; + bool removeAfterRun; + if (round == EffectStep.AfterMove && usesResolver) { + (updatedExtraData, removeAfterRun) = + _executeResolvedEffect(effect, rng, data, playerIndex, monIndex, round, activesPacked); + } else { + (updatedExtraData, removeAfterRun) = _executeEffectHook( + battleKeyForWrite, effect, rng, data, playerIndex, monIndex, round, extraEffectsData, activesPacked + ); + } // If we need to remove or update the effect if (removeAfterRun || updatedExtraData != data) { @@ -3338,6 +3737,59 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } } + function _executeResolvedEffect( + IEffect effect, + uint256 rng, + bytes32 data, + uint256 playerIndex, + uint256 monIndex, + EffectStep round, + uint256 activesPacked + ) private returns (bytes32 updatedExtraData, bool removeAfterRun) { + uint256 command; + (updatedExtraData, removeAfterRun, command) = IEffectResolver(address(effect)).resolveEffect( + round, rng, data, playerIndex, monIndex, _freshHookContext(round, activesPacked) + ); + _applyEffectCommand(command); + } + + /// @dev Builds the dispatch-time portion shared by the legacy hook path and resolver prototype. + function _freshHookContext(EffectStep round, uint256 activesPacked) private view returns (uint256) { + if (activesPacked & FRESH_HOOK_CONTEXT_REQUEST == 0) { + return activesPacked; + } + activesPacked &= ~FRESH_HOOK_CONTEXT_REQUEST; + if (round == EffectStep.AfterMove) { + BattleData storage battle = battleData[battleKeyForWrite]; + if (battle.isTwoSlotMode) { + activesPacked |= _currentTurnMoveWordForSlot(0, 0) << 32; + activesPacked |= _currentTurnMoveWordForSlot(0, 1) << 56; + activesPacked |= _currentTurnMoveWordForSlot(1, 0) << 80; + activesPacked |= _currentTurnMoveWordForSlot(1, 1) << 104; + } else { + BattleConfig storage config = battleConfig[storageKeyForWrite]; + activesPacked |= _currentTurnMoveWord(config, 0) << 32; + activesPacked |= _currentTurnMoveWord(config, 1) << 80; + } + activesPacked |= actedSlotsThisTurnMask << 128; + } + return activesPacked; + } + + function _applyEffectCommand(uint256 command) private { + uint256 op = uint8(command); + if (op == 0) { + return; + } + uint256 targetIndex = uint8(command >> 8); + uint256 monIndex = uint8(command >> 16); + if (op == EffectCommandLib.OP_CLEAR_STATUS) { + _clearMonStatus(targetIndex, monIndex, uint8(command >> 24)); + } else if (op == EffectCommandLib.OP_ADD_EFFECT) { + _addEffectInternal(targetIndex, monIndex, IEffect(address(uint160(command >> 24))), bytes32(0)); + } + } + function _executeEffectHook( bytes32 battleKey, IEffect effect, @@ -3366,10 +3818,44 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 source = abi.decode(extraEffectsData, (uint256)); return effect.onPreDamage(self, battleKey, rng, data, playerIndex, monIndex, activesPacked, source); } else if (round == EffectStep.AfterMove) { + // Enrich the existing active-lane word with a fresh current-turn snapshot immediately + // before each callback. An earlier effect may rewrite a move, so this cannot be cached + // once per pass. Low 32 bits remain the long-standing active-mon ABI; move lanes occupy + // bits 32..127 and the acted-slot mask bits 128..131. + if (activesPacked & FRESH_HOOK_CONTEXT_REQUEST != 0) { + activesPacked &= ~FRESH_HOOK_CONTEXT_REQUEST; + BattleData storage battle = battleData[battleKey]; + if (battle.isTwoSlotMode) { + activesPacked |= _currentTurnMoveWordForSlot(0, 0) << 32; + activesPacked |= _currentTurnMoveWordForSlot(0, 1) << 56; + activesPacked |= _currentTurnMoveWordForSlot(1, 0) << 80; + activesPacked |= _currentTurnMoveWordForSlot(1, 1) << 104; + } else { + BattleConfig storage config = battleConfig[storageKeyForWrite]; + activesPacked |= _currentTurnMoveWord(config, 0) << 32; + activesPacked |= _currentTurnMoveWord(config, 1) << 80; + } + activesPacked |= actedSlotsThisTurnMask << 128; + } return effect.onAfterMove(self, battleKey, rng, data, playerIndex, monIndex, activesPacked); } else if (round == EffectStep.OnUpdateMonState) { (uint256 statePlayerIndex, uint256 stateMonIndex, MonStateIndexName stateVarIndex, int32 valueToAdd) = abi.decode(extraEffectsData, (uint256, uint256, MonStateIndexName, int32)); + // The state mutation has already landed before this lifecycle pass. Build the snapshot + // immediately before each opted-in callback so it observes nested/prior hook writes. + // Hook-specific context can reuse the AfterMove lanes: max HP is bits 32..63 and the + // normalized signed HP delta is bits 64..95. + if (activesPacked & FRESH_HOOK_CONTEXT_REQUEST != 0) { + activesPacked &= ~FRESH_HOOK_CONTEXT_REQUEST; + BattleConfig storage config = battleConfig[storageKeyForWrite]; + uint32 maxHp = _getTeamMon(config, statePlayerIndex, stateMonIndex).stats.hp; + int32 hpDelta = _getMonState(config, statePlayerIndex, stateMonIndex).hpDelta; + if (hpDelta == CLEARED_MON_STATE_SENTINEL) { + hpDelta = 0; + } + activesPacked |= uint256(maxHp) << 32; + activesPacked |= uint256(uint32(hpDelta)) << 64; + } return effect.onUpdateMonState( self, battleKey, rng, data, statePlayerIndex, stateMonIndex, activesPacked, stateVarIndex, valueToAdd ); @@ -3392,11 +3878,11 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } else { // Update the data at the slot if (effectIndex == 2) { - config.globalEffects[slotIndex].data = updatedExtraData; + _setEffectData(config.globalEffects[slotIndex], updatedExtraData); } else if (effectIndex == 0) { - config.p0Effects[slotIndex].data = updatedExtraData; + _setEffectData(config.p0Effects[slotIndex], updatedExtraData); } else { - config.p1Effects[slotIndex].data = updatedExtraData; + _setEffectData(config.p1Effects[slotIndex], updatedExtraData); } } } @@ -3427,6 +3913,12 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } else { uint256 monIndex = _unpackActiveMonIndex(battle.activeMonIndex, playerIndex); + // The battle-wide union is only the cheap outer gate. This exact mon lane prevents + // one listener (including a benched mon's) from routing unrelated active lists. + if (!_monListensAt(config, effectIndex, monIndex, round)) { + return playerSwitchForTurnFlag; + } + // Check if mon is KOed (reuse monIndex we already computed) if (condition == EffectRunCondition.SkipIfGameOverOrMonKO) { if (_getMonState(config, playerIndex, monIndex).isKnockedOut) { @@ -3460,23 +3952,18 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return (actedSlotsThisTurnMask >> absSlot) & 1 != 0; } - /// @notice The roster range a slot may switch within (Multi: the seat's quarter). - function getRosterBoundsForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) + /// @notice The roster range a slot may switch within (Multi: the seat's quarter) plus the + /// side's KO bitmap, so a switch-target search costs one call rather than one per + /// candidate. `koBitmap` is bit-per-roster-index and is kept in lockstep with each + /// mon's `isKnockedOut`. + function getSlotSwitchWindow(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) external view - returns (uint256 lo, uint256 hi) + returns (uint256 lo, uint256 hi, uint256 koBitmap) { BattleConfig storage config = battleConfig[_resolveStorageKey(battleKey)]; - return _slotRosterBounds(config, (playerIndex << 1) | (slotIndex & 1)); - } - - function computePriorityPlayerIndex(bytes32 battleKey, uint256 rng) public view returns (uint256) { - bytes32 storageKey = _resolveStorageKey(battleKey); - BattleConfig storage config = battleConfig[storageKey]; - BattleData storage battle = battleData[battleKey]; - return _computePriorityPlayerIndex( - config, battle, battleKey, rng, _getCurrentTurnMove(config, 0), _getCurrentTurnMove(config, 1) - ); + (lo, hi) = _slotRosterBounds(config, (playerIndex << 1) | (slotIndex & 1)); + koBitmap = _getKOBitmap(config, playerIndex); } /// @dev Internal priority computation that accepts already-resolved storage pointers and @@ -3754,6 +4241,23 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } } + /// @dev Mark any active lane currently occupied by this mon. All authored speed changes route + /// through the Engine's stat-boost/state chokepoints before reaching this helper. + function _markActiveMonSpeedDirty(uint256 side, uint256 monIndex) private { + BattleData storage battle = battleData[battleKeyForWrite]; + uint256 slot = side << 1; + uint256 dirty; + if (_slotActive(battle, slot) == monIndex) { + dirty = 1 << slot; + } + if (_slotActive(battle, slot + 1) == monIndex) { + dirty |= 1 << (slot + 1); + } + if (dirty != 0) { + _speedDirtySlots |= dirty; + } + } + /// @dev The packed actives word passed to effects/moves: one 8-bit lane per absolute slot. /// 2-slot only — singles never writes activeMonExt, so its slot-1 lanes would read /// 0x00 ("mon 0") instead of EMPTY_ACTIVE_LANE; singles paths use TargetLib.singlesActives. @@ -3871,8 +4375,14 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 actedMask; uint256 actionOrder; uint256 numActed; + uint256 cachedSpeeds; + // RoundStart mutations are captured by the first full load; only later mutations need + // invalidation. Clear stale bits from a prior batched sub-turn here. + _speedDirtySlots = 0; for (uint256 pick; pick < 4;) { - uint256 slot = _pickNextSlot(config, battle, lockedPriorities, actedMask, rng, pick, turnZero); + uint256 slot; + (slot, cachedSpeeds) = + _pickNextSlot(config, battle, lockedPriorities, actedMask, rng, pick, turnZero, cachedSpeeds); if (slot == NO_SLOT) { break; } @@ -3892,7 +4402,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 side = slot >> 1; uint256 actorMon = _slotActive(battle, slot); bool actorAlive = actorMon != EMPTY_ACTIVE_LANE && !_getMonState(config, side, actorMon).isKnockedOut; - if (actorAlive && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.AfterMove))) != 0) { + if (actorAlive && _monListensAt(config, side, actorMon, EffectStep.AfterMove)) { uint256 cnt = side == 0 ? _getMonEffectCount(config.packedP0EffectsCount, actorMon) : _getMonEffectCount(config.packedP1EffectsCount, actorMon); @@ -4006,12 +4516,24 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 actedMask, uint256 rng, uint256 pick, - bool turnZero - ) private view returns (uint256 best) { + bool turnZero, + uint256 cachedSpeeds + ) private returns (uint256 best, uint256 newSpeeds) { best = NO_SLOT; + newSpeeds = cachedSpeeds; uint256 bestPrio; uint256 bestSpeed; uint256 bestJitter; + uint256 dirtyMask; + if (pick == 0) { + dirtyMask = 0xF; + } else { + dirtyMask = _speedDirtySlots; + if (dirtyMask != 0) { + _speedDirtySlots = 0; + } + } + uint256 koBitmaps = config.koBitmaps; uint256 h = uint256(keccak256(abi.encode(rng, pick))); for (uint256 s; s < 4;) { if (actedMask & (1 << s) == 0) { @@ -4022,13 +4544,18 @@ contract Engine is IEngine, MappingAllocator, EIP712 { candidate = turnZero; } else { uint256 side = s >> 1; - MonState storage st = _getMonState(config, side, mon); - if (!st.isKnockedOut) { + if ((koBitmaps & (1 << ((side << 3) | mon))) == 0) { candidate = true; - int32 spdDelta = st.speedDelta; - int256 spd = int256(uint256(_getTeamMon(config, side, mon).stats.speed)) - + (spdDelta == CLEARED_MON_STATE_SENTINEL ? int256(0) : int256(spdDelta)); - speed = spd > 0 ? uint256(spd) : 0; + uint256 shift = s << 6; + if (dirtyMask & (1 << s) != 0) { + int32 spdDelta = _getMonState(config, side, mon).speedDelta; + int256 spd = int256(uint256(_getTeamMon(config, side, mon).stats.speed)) + + (spdDelta == CLEARED_MON_STATE_SENTINEL ? int256(0) : int256(spdDelta)); + speed = spd > 0 ? uint256(spd) : 0; + newSpeeds = (newSpeeds & ~(uint256(type(uint64).max) << shift)) | (speed << shift); + } else { + speed = uint64(newSpeeds >> shift); + } } } if (candidate) { @@ -4053,6 +4580,82 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } } + /// @dev Effect-pass picker with the same rank-dependent jitter as `_pickNextSlot`, but it + /// reads each remaining slot's live speed only once while advancing over consecutive + /// non-listeners. The caller invokes it again after every listener runs, preserving D29: + /// effects may change speed, KO a mon, or replace an active before the next hook. + function _pickNextEffectSlot( + BattleConfig storage config, + BattleData storage battle, + uint256 stepsWord, + uint256 doneMask, + uint256 rng, + uint256 pickBase, + uint256 pick, + EffectStep step + ) private view returns (uint256 slot, uint256 newDoneMask, uint256 nextPick) { + uint256 candidateMask; + uint256 listenerMask; + uint256 speeds; + for (uint256 s; s < 4;) { + if (doneMask & (1 << s) == 0) { + uint256 side = s >> 1; + uint256 mon = _slotActive(battle, s); + if (mon != EMPTY_ACTIVE_LANE) { + MonState storage st = _getMonState(config, side, mon); + if (!st.isKnockedOut) { + candidateMask |= 1 << s; + int32 spdDelta = st.speedDelta; + int256 spd = int256(uint256(_getTeamMon(config, side, mon).stats.speed)) + + (spdDelta == CLEARED_MON_STATE_SENTINEL ? int256(0) : int256(spdDelta)); + if (spd > 0) { + speeds |= uint256(spd) << (s << 6); + } + if (_stepsWordListens(stepsWord, side, mon, step)) { + listenerMask |= 1 << s; + } + } + } + } + unchecked { + ++s; + } + } + + newDoneMask = doneMask; + nextPick = pick; + while (nextPick < 4 && candidateMask != 0) { + uint256 best = NO_SLOT; + uint256 bestSpeed; + uint256 bestJitter; + uint256 h = uint256(keccak256(abi.encode(rng, pickBase + nextPick))); + for (uint256 s; s < 4;) { + if (candidateMask & (1 << s) != 0) { + uint256 speed = uint64(speeds >> (s << 6)); + uint256 jitter = uint64(h >> (s << 6)); + if (best == NO_SLOT || speed > bestSpeed || (speed == bestSpeed && jitter > bestJitter)) { + best = s; + bestSpeed = speed; + bestJitter = jitter; + } + } + unchecked { + ++s; + } + } + uint256 bit = 1 << best; + candidateMask &= ~bit; + newDoneMask |= bit; + unchecked { + ++nextPick; + } + if (listenerMask & bit != 0) { + return (best, newDoneMask, nextPick); + } + } + return (NO_SLOT, newDoneMask, nextPick); + } + /// @dev Resolve one slot's action: coercion (turn-0 / KO'd -> switch), switch legality /// (bounds, KO'd target, ally-slot collision), NO_OP, or move execution with the /// engine-level fizzle rule (D2: stamina spent, dead/empty chosen target skips the move). @@ -4107,8 +4710,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // turn — can't be left vacant by an illegal pick: a colliding switch would re-mask // the lane every turn (an infinite-stall vector). Coerce to the first legal target; // a genuinely empty bench (one survivor) finds none and falls to the no-op guard. - bool laneMustFill = activeMon == EMPTY_ACTIVE_LANE - || _getMonState(config, side, activeMon).isKnockedOut; + bool laneMustFill = activeMon == EMPTY_ACTIVE_LANE || _getMonState(config, side, activeMon).isKnockedOut; if (laneMustFill && !_isLegalSlotSwitchTarget(config, battle, absSlot, monToSwitchIndex)) { (monToSwitchIndex,) = _firstLegalSwitchTarget(config, battle, absSlot); } @@ -4185,17 +4787,34 @@ contract Engine is IEngine, MappingAllocator, EIP712 { if (isInlineAttack) { _inlineStandardAttack(config, rawMoveSlot, side, activeMon, tSlot >> 1, tMon, actionRng); } else { - IMoveSet(address(uint160(rawMoveSlot))) - .move( + uint256 moveContext = _buildActivesWord(battle); + if (rawMoveSlot & MOVE_CONTEXT_STATUS_LANES != 0) { + moveContext |= uint256(config.monStatusLanes) << 132; + } + if (rawMoveSlot & MOVE_RESOLVER_TAG != 0) { + _executeResolvedMove( + config, + battle, + rawMoveSlot, + side, + activeMon, + targetBits, + moveContext, + uint16(moveExtra & EXTRA_DATA_PAYLOAD_MASK), + actionRng + ); + } else { + IMoveSet(address(uint160(rawMoveSlot))).move( IEngine(address(this)), battleKey, side, activeMon, targetBits, - _buildActivesWord(battle), + moveContext, uint16(moveExtra & EXTRA_DATA_PAYLOAD_MASK), actionRng ); + } } } @@ -4276,7 +4895,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 outCount = side == 0 ? _getMonEffectCount(config.packedP0EffectsCount, currentActive) : _getMonEffectCount(config.packedP1EffectsCount, currentActive); - if (outCount > 0 && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnMonSwitchOut))) != 0) { + if (outCount > 0 && _monListensAt(config, side, currentActive, EffectStep.OnMonSwitchOut)) { _runEffectsForMon( config, battle, rng, side, side, currentActive, EffectStep.OnMonSwitchOut, "", outCount ); @@ -4292,7 +4911,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 inCount = side == 0 ? _getMonEffectCount(config.packedP0EffectsCount, monToSwitchIndex) : _getMonEffectCount(config.packedP1EffectsCount, monToSwitchIndex); - if (inCount > 0 && (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnMonSwitchIn))) != 0) { + if (inCount > 0 && _monListensAt(config, side, monToSwitchIndex, EffectStep.OnMonSwitchIn)) { _runEffectsForMon(config, battle, rng, side, side, monToSwitchIndex, EffectStep.OnMonSwitchIn, "", inCount); } uint256 inGlobals = config.globalEffectsLength; @@ -4317,31 +4936,28 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 rng, EffectStep step ) private { - // The steps union only says SOME mon (possibly benched or replaced) listens at this - // step; when none of the four current actives carries any effect the ordering pass has - // nothing to run. Empty lanes (0xFF) shift out to a zero count. - { - uint96 c0 = config.packedP0EffectsCount; - uint96 c1 = config.packedP1EffectsCount; - if ( - _getMonEffectCount(c0, _slotActive(battle, 0)) == 0 - && _getMonEffectCount(c0, _slotActive(battle, 1)) == 0 - && _getMonEffectCount(c1, _slotActive(battle, 2)) == 0 - && _getMonEffectCount(c1, _slotActive(battle, 3)) == 0 - ) { - return; - } + // One exact word answers both "has effects" and "listens at this step" for all actives. + // Empty lanes (0xFF) naturally shift beyond the word and read false. + uint256 stepsWord = config.playerEffectStepsByMon; + if ( + !_stepsWordListens(stepsWord, 0, _slotActive(battle, 0), step) + && !_stepsWordListens(stepsWord, 0, _slotActive(battle, 1), step) + && !_stepsWordListens(stepsWord, 1, _slotActive(battle, 2), step) + && !_stepsWordListens(stepsWord, 1, _slotActive(battle, 3), step) + ) { + return; } uint256 doneMask; // Distinct jitter-seed ranges per pass (and from the action scheduler's 0-3) so the // RoundStart and RoundEnd orderings roll independently. uint256 pickBase = step == EffectStep.RoundStart ? 16 : 24; - for (uint256 pick; pick < 4;) { - uint256 slot = _pickNextSlot(config, battle, 0, doneMask, rng, pickBase + pick, false); + uint256 pick; + while (pick < 4) { + uint256 slot; + (slot, doneMask, pick) = _pickNextEffectSlot(config, battle, stepsWord, doneMask, rng, pickBase, pick, step); if (slot == NO_SLOT) { break; } - doneMask |= (1 << slot); if (battle.winnerIndex != 2) { return; } @@ -4350,12 +4966,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 cnt = side == 0 ? _getMonEffectCount(config.packedP0EffectsCount, mon) : _getMonEffectCount(config.packedP1EffectsCount, mon); - if (cnt > 0) { - _runEffectsForMon(config, battle, rng, side, side, mon, step, "", cnt); - } - unchecked { - ++pick; - } + _runEffectsForMon(config, battle, rng, side, side, mon, step, "", cnt); } } @@ -4482,11 +5093,13 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 playerIndex, uint256 monIndex, uint256 p0ActiveMonIndex, - uint256 p1ActiveMonIndex - ) private { + uint256 p1ActiveMonIndex, + uint256 prevPlayerSwitchForTurnFlag + ) private returns (uint256 playerSwitchForTurnFlag) { + playerSwitchForTurnFlag = prevPlayerSwitchForTurnFlag; if (round == EffectStep.RoundEnd) { if (!StaminaRegenLogic._shouldRegenOnRoundEnd(battleData[battleKeyForWrite].playerSwitchForTurnFlag)) { - return; + return playerSwitchForTurnFlag; } _inlineRegenStaminaForMon(config, 0, p0ActiveMonIndex); _inlineRegenStaminaForMon(config, 1, p1ActiveMonIndex); @@ -4494,10 +5107,20 @@ contract Engine is IEngine, MappingAllocator, EIP712 { // Fetch packedMoveIndex via helper - resolves to transient during executeWithMoves, storage otherwise. uint8 packedMoveIndex = _getCurrentTurnMove(config, playerIndex).packedMoveIndex; if (!StaminaRegenLogic._isRestingMove(packedMoveIndex)) { - return; + return playerSwitchForTurnFlag; } _inlineRegenStaminaForMon(config, playerIndex, monIndex); } + + // A regen tick fires OnUpdateMonState, which an effect (e.g. Somniphobia's nightmare) can + // turn into dealDamage and KO the mon that just gained stamina. Mirror the koOccurredFlag + // handling in _handleMove / _handleEffects: without it, a KO landing on the round-end regen + // (the last thing in the turn) is never observed, so playerSwitchForTurnFlag stays 2 and the + // engine wrongly runs the next turn as a normal both-sides-act turn instead of a forced switch. + if (koOccurredFlag != 0) { + koOccurredFlag = 0; + (playerSwitchForTurnFlag,) = _checkForGameOverOrKO(config, battleData[battleKeyForWrite], 2); + } } /// @dev Mirrors the storage write that StaminaRegenLogic used to do, then fires @@ -4509,14 +5132,8 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return; } monState.staminaDelta += 1; - // Union bit first (single OnUpdateMonState listener game-wide) — skips the count SLOAD - // on the up-to-two regen ticks every round end. - if ( - (config.playerEffectStepsUnion & uint16(1 << uint8(EffectStep.OnUpdateMonState))) != 0 - && (playerIndex == 0 - ? _getMonEffectCount(config.packedP0EffectsCount, monIndex) - : _getMonEffectCount(config.packedP1EffectsCount, monIndex)) > 0 - ) { + // Exact lane skips unrelated mons without loading their effect count. + if (_monListensAt(config, playerIndex, monIndex, EffectStep.OnUpdateMonState)) { _runEffectsPipeline( config, playerIndex, @@ -4618,8 +5235,13 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256[] memory globalIndices = new uint256[](globalEffectsLength); uint256 globalIdx = 0; for (uint256 i = 0; i < globalEffectsLength;) { - if (address(config.globalEffects[i].effect) != TOMBSTONE_ADDRESS) { - globalResult[globalIdx] = config.globalEffects[i]; + EffectInstance storage storedEffect = config.globalEffects[i]; + if (address(storedEffect.effect) != TOMBSTONE_ADDRESS) { + globalResult[globalIdx] = EffectInstance({ + effect: storedEffect.effect, + stepsBitmap: storedEffect.stepsBitmap, + data: _effectData(storedEffect) + }); globalIndices[globalIdx] = i; unchecked { ++globalIdx; @@ -4648,8 +5270,11 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 idx = 0; for (uint256 i = 0; i < monEffectCount;) { uint256 slotIndex = baseSlot + i; - if (address(effects[slotIndex].effect) != TOMBSTONE_ADDRESS) { - result[idx] = effects[slotIndex]; + EffectInstance storage storedEffect = effects[slotIndex]; + if (address(storedEffect.effect) != TOMBSTONE_ADDRESS) { + result[idx] = EffectInstance({ + effect: storedEffect.effect, stepsBitmap: storedEffect.stepsBitmap, data: _effectData(storedEffect) + }); indices[idx] = slotIndex; unchecked { ++idx; @@ -4681,8 +5306,11 @@ contract Engine is IEngine, MappingAllocator, EIP712 { EffectInstance[] memory globalEffects = new EffectInstance[](globalLen); uint256 gIdx = 0; for (uint256 i = 0; i < globalLen;) { - if (address(config.globalEffects[i].effect) != TOMBSTONE_ADDRESS) { - globalEffects[gIdx] = config.globalEffects[i]; + EffectInstance storage storedEffect = config.globalEffects[i]; + if (address(storedEffect.effect) != TOMBSTONE_ADDRESS) { + globalEffects[gIdx] = EffectInstance({ + effect: storedEffect.effect, stepsBitmap: storedEffect.stepsBitmap, data: _effectData(storedEffect) + }); unchecked { ++gIdx; } @@ -4781,9 +5409,13 @@ contract Engine is IEngine, MappingAllocator, EIP712 { p1Salt: config.p1Salt, p0TeamIndex: data.p0TeamIndex, p1TeamIndex: data.p1TeamIndex, + monStatusLanes: config.monStatusLanes, + playerEffectStepsByMon: config.playerEffectStepsByMon, p0Move: config.p0Move, p1Move: config.p1Move, globalEffects: globalEffects, + p0StatBoosts: _boostWordsView(config, 0), + p1StatBoosts: _boostWordsView(config, 1), p0Effects: p0Effects, p1Effects: p1Effects, teams: teams, @@ -4834,8 +5466,13 @@ contract Engine is IEngine, MappingAllocator, EIP712 { EffectInstance[] memory monEffects = new EffectInstance[](monCount); uint256 idx = 0; for (uint256 i = 0; i < monCount;) { - if (address(effects[baseSlot + i].effect) != TOMBSTONE_ADDRESS) { - monEffects[idx] = effects[baseSlot + i]; + EffectInstance storage storedEffect = effects[baseSlot + i]; + if (address(storedEffect.effect) != TOMBSTONE_ADDRESS) { + monEffects[idx] = EffectInstance({ + effect: storedEffect.effect, + stepsBitmap: storedEffect.stepsBitmap, + data: _effectData(storedEffect) + }); unchecked { ++idx; } @@ -4979,12 +5616,6 @@ contract Engine is IEngine, MappingAllocator, EIP712 { } } - function getTeamSize(bytes32 battleKey, uint256 playerIndex) external view returns (uint256) { - bytes32 storageKey = _resolveStorageKey(battleKey); - uint8 teamSizes = battleConfig[storageKey].teamSizes; - return (playerIndex == 0) ? (teamSizes & 0x0F) : (teamSizes >> 4); - } - function getMoveForMonForBattle(bytes32 battleKey, uint256 playerIndex, uint256 monIndex, uint256 moveIndex) external view @@ -5009,15 +5640,6 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return _getCurrentTurnMove(battleConfig[_resolveStorageKey(battleKey)], playerIndex); } - function getMoveDecisionForBattleState(bytes32 battleKey, uint256 playerIndex) - external - view - returns (MoveDecision memory) - { - BattleConfig storage config = battleConfig[_resolveStorageKey(battleKey)]; - return _getCurrentTurnMove(config, playerIndex); - } - function getMonStatsForBattle(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external view @@ -5038,6 +5660,19 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return _readMonStateDelta(config, playerIndex, monIndex, stateVarIndex); } + function getMonHpState(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) + external + view + returns (uint32 maxHp, int32 hpDelta) + { + BattleConfig storage config = battleConfig[_resolveStorageKey(battleKey)]; + maxHp = _getTeamMon(config, playerIndex, monIndex).stats.hp; + hpDelta = _getMonState(config, playerIndex, monIndex).hpDelta; + if (hpDelta == CLEARED_MON_STATE_SENTINEL) { + hpDelta = 0; + } + } + /// @notice Current (base + delta, sentinel-aware) value of a mon stat in one call — the /// merged form of the getMonValueForBattle + getMonStateForBattle pair. Only /// meaningful for Hp/Stamina and the five stats (booleans have no base value). @@ -5107,14 +5742,6 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return battleData[battleKey].turnId; } - function getActiveMonIndexForBattleState(bytes32 battleKey) external view returns (uint256[] memory) { - uint16 packed = battleData[battleKey].activeMonIndex; - uint256[] memory result = new uint256[](2); - result[0] = _unpackActiveMonIndex(packed, 0); - result[1] = _unpackActiveMonIndex(packed, 1); - return result; - } - function getGlobalKV(bytes32 battleKey, uint64 key) external view returns (uint192) { return _getGlobalKVValue(_resolveStorageKey(battleKey), key); } @@ -5141,6 +5768,32 @@ contract Engine is IEngine, MappingAllocator, EIP712 { return _getEffectsForTarget(storageKey, targetIndex, monIndex); } + /// @dev View shape of a side's stat-boost store: per-mon arrays of packed source words + /// (StatBoostLib layout — clients decode key/perm/lanes off-chain). + function _boostWordsView(BattleConfig storage config, uint256 side) private view returns (bytes32[][] memory out) { + uint256 teamSize = side == 0 ? (config.teamSizes & 0x0F) : (config.teamSizes >> 4); + mapping(uint256 => bytes32) storage words = _boostWordsOf(config, side); + out = new bytes32[][](teamSize); + for (uint256 m; m < teamSize; ++m) { + uint256 cnt = _boostCountOf(config, side, m); + bytes32[] memory ws = new bytes32[](cnt); + for (uint256 i; i < cnt; ++i) { + ws[i] = words[m * 16 + i]; + } + out[m] = ws; + } + } + + /// @notice Class id (stepsBitmap bits 10-13) of the mon's exclusive status; 0 = none. + function getMonStatusClass(bytes32 battleKey, uint256 targetIndex, uint256 monIndex) + external + view + returns (uint256) + { + bytes32 storageKey = _resolveStorageKey(battleKey); + return (battleConfig[storageKey].monStatusLanes >> _statusLaneShift(targetIndex, monIndex)) & 0xF; + } + /// @notice Targeted single-effect lookup. Scans a mon's (or the global) effect list for /// `effectAddr` and returns its slot index + data, WITHOUT materializing the full /// `EffectInstance[]` array. For abilities / move-effects that only need one known @@ -5158,7 +5811,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { for (uint256 i; i < len;) { EffectInstance storage e = config.globalEffects[i]; if (address(e.effect) == effectAddr) { - return (true, i, e.data); + return (true, i, _effectData(e)); } unchecked { ++i; @@ -5174,7 +5827,7 @@ contract Engine is IEngine, MappingAllocator, EIP712 { uint256 slotIndex = baseSlot + i; EffectInstance storage e = effects[slotIndex]; if (address(e.effect) == effectAddr) { - return (true, slotIndex, e.data); + return (true, slotIndex, _effectData(e)); } unchecked { ++i; diff --git a/src/IEngine.sol b/src/IEngine.sol index f4e2a535..6fd84442 100644 --- a/src/IEngine.sol +++ b/src/IEngine.sol @@ -12,8 +12,8 @@ interface IEngine { function battleKeyForWrite() external view returns (bytes32); function tempRNG() external view returns (uint256); - // PreDamage threading: hooks read the running damage and call setPreDamage to mutate it. - function getPreDamage() external view returns (int32); + // PreDamage threading: hooks read the running damage from their packed hook context + // (TargetLib.hookPreDamage) and call setPreDamage to mutate it. function setPreDamage(int32 value) external; // State mutating effects @@ -23,6 +23,7 @@ interface IEngine { external; function addEffect(uint256 targetIndex, uint256 monIndex, IEffect effect, bytes32 extraData) external; function removeEffect(uint256 targetIndex, uint256 monIndex, uint256 effectIndex) external; + function clearMonStatus(uint256 targetIndex, uint256 monIndex, uint256 expectedClass) external returns (bool); function editEffect(uint256 targetIndex, uint256 effectIndex, bytes32 newExtraData) external; function setGlobalKV(uint64 key, uint192 value) external; // Inlined stat boosts (formerly the StatBoosts effect contract). Keyed by msg.sender. @@ -140,15 +141,15 @@ interface IEngine { view returns (bytes32 battleKey, bytes32 partyHash); function getSeats(bytes32 battleKey) external view returns (address[4] memory seats); - function computePriorityPlayerIndex(bytes32 battleKey, uint256 rng) external view returns (uint256); // Per-slot "has acted (or is acting) this turn" — the mode-agnostic primitive status effects // use to decide between an immediate move-cancel and waiting for next RoundStart. function hasSlotActedThisTurn(uint256 absSlot) external view returns (bool); - // The roster range a slot may switch within (Multi partitions each side by seat quarter). - function getRosterBoundsForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) + // The roster range a slot may switch within (Multi partitions each side by seat quarter), + // bundled with the side's KO bitmap so a switch-target search is a single call. + function getSlotSwitchWindow(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex) external view - returns (uint256 lo, uint256 hi); + returns (uint256 lo, uint256 hi, uint256 koBitmap); function getStorageKey(bytes32 battleKey) external view returns (bytes32); function getBattle(bytes32 battleKey) external view returns (BattleConfigView memory, BattleData memory); function getMonValueForBattle( @@ -167,6 +168,11 @@ interface IEngine { uint256 monIndex, MonStateIndexName stateVarIndex ) external view returns (int32); + // Focused overheal-clamp snapshot: both HP quantities in one external frame. + function getMonHpState(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) + external + view + returns (uint32 maxHp, int32 hpDelta); // Current (base + delta, sentinel-aware) stat value in one call — the merged form of the // getMonValueForBattle + getMonStateForBattle pair. function getMonCurrentValue( @@ -179,13 +185,7 @@ interface IEngine { external view returns (uint256); - function getMoveDecisionForBattleState(bytes32 battleKey, uint256 playerIndex) - external - view - returns (MoveDecision memory); - function getTeamSize(bytes32 battleKey, uint256 playerIndex) external view returns (uint256); function getTurnIdForBattleState(bytes32 battleKey) external view returns (uint256); - function getActiveMonIndexForBattleState(bytes32 battleKey) external view returns (uint256[] memory); function getGlobalKV(bytes32 battleKey, uint64 key) external view returns (uint192); function validatePlayerMoveForBattle(bytes32 battleKey, uint256 moveIndex, uint256 playerIndex, uint16 extraData) external @@ -198,6 +198,7 @@ interface IEngine { external view returns (bool exists, uint256 effectIndex, bytes32 data); + function getMonStatusClass(bytes32 battleKey, uint256 targetIndex, uint256 monIndex) external view returns (uint256); function getWinner(bytes32 battleKey) external view returns (address); function getKOBitmap(bytes32 battleKey, uint256 playerIndex) external view returns (uint256); function getBattleContext(bytes32 battleKey) external view returns (BattleContext memory); diff --git a/src/Structs.sol b/src/Structs.sol index 415bf052..d214bae0 100644 --- a/src/Structs.sol +++ b/src/Structs.sol @@ -173,6 +173,9 @@ struct BattleConfig { uint40 startTimestamp; // 40 — battle start time; overflows in year ~36825 (shrunk from uint48 for slot-2 packing) bool hasInlineStaminaRegen; // 8 uint8 globalKVCount; // 8 — live entry count in the current battle's globalKV key buffer + // Per-mon stat-boost source counts, 4 bits per mon (max 15 sources/mon — distinct callers). + // MUST reset every startBattle (recycled keys); rides the slot-2 consolidated write. + uint32 p0BoostCounts; // 32 uint104 p0Salt; uint104 p1Salt; // OR of every player (per-mon) effect's stepsBitmap added this battle. Lets the hot step @@ -192,6 +195,12 @@ struct BattleConfig { // piggybacks on the engineHookStepsUnion SLOAD; rewritten every startBattle (recycled // storage must never leak a previous battle's mode). uint8 battleMode; // 8 + // Per-mon exclusive-status lanes: one 4-bit class nibble per mon, laneIndex = side*8 + + // monIndex (stride 8 = the Engine's hard team-size cap, NOT MONS_PER_TEAM). 0 = no status. + // Engine-owned: set in _addEffectInternal, cleared in _removeEffectAtSlot / startBattle's + // consolidated reset. Rides slot 3 so both writes coalesce with SSTOREs already paid. + uint64 monStatusLanes; // 64 + uint32 p1BoostCounts; // 32 — see p0BoostCounts; rides slot 3 MoveDecision p0Move; MoveDecision p1Move; // Stored at startBattle so Engine.getBattle can passthrough to level/exp/facet getters. @@ -204,12 +213,26 @@ struct BattleConfig { mapping(uint256 => EffectInstance) p0Effects; mapping(uint256 => EffectInstance) p1Effects; mapping(uint256 => EngineHookInstance) engineHooks; + // Stat-boost sources: ONE packed word per source (StatBoostLib layout — key/perm/5 lanes), + // slot = monIndex * 16 + i, valid for i < that mon's 4-bit count in p{0,1}BoostCounts. + // No reset needed on recycled keys: words are only read below the (reset) count. + mapping(uint256 => bytes32) p0BoostWords; + mapping(uint256 => bytes32) p1BoostWords; + // Per-mon aggregation cache, laneIndex = side*8 + monIndex: 5 stat lanes of + // [numerator:48 | count:3] (bits k*51..) + bit 255 = disabled (overflow fallback). + // Never read when the mon's source count is 0 (initialize-on-first-add), so recycled-key + // staleness is unobservable; count > 0 implies this battle wrote it. + mapping(uint256 => uint256) statBoostAcc; + // Exact OR of live EffectInstance.stepsBitmap values for each player mon. + // Lane index = side*8 + monIndex; one uint16 lane per mon fills one word exactly. + // Appended after mappings so existing BattleConfig mapping roots remain stable. + uint256 playerEffectStepsByMon; } struct EffectInstance { IEffect effect; // 160 bits uint16 stepsBitmap; // 16 bits - packs with effect in slot 0 (bit i = runs at EffectStep(i)) - // 80 bits unused in slot 0 + // High 80 bits carry Engine's offset-tagged compact data, or zero for slot-1 fallback. bytes32 data; // 256 bits in slot 1 } @@ -232,9 +255,13 @@ struct BattleConfigView { uint104 p1Salt; uint16 p0TeamIndex; uint16 p1TeamIndex; + uint64 monStatusLanes; // Per-mon exclusive-status class nibbles (see BattleConfig) + uint256 playerEffectStepsByMon; // Exact uint16 lifecycle-step union per mon MoveDecision p0Move; MoveDecision p1Move; EffectInstance[] globalEffects; + bytes32[][] p0StatBoosts; // Per-mon packed stat-boost source words (StatBoostLib layout) + bytes32[][] p1StatBoosts; EffectInstance[][] p0Effects; // Returns effects per mon in team EffectInstance[][] p1Effects; Mon[][] teams; diff --git a/src/effects/BasicEffect.sol b/src/effects/BasicEffect.sol index 0c8aa460..39332ed6 100644 --- a/src/effects/BasicEffect.sol +++ b/src/effects/BasicEffect.sol @@ -6,11 +6,9 @@ import "../IEngine.sol"; import "../Structs.sol"; abstract contract BasicEffect is IEffect { - // Each subclass must override getStepsBitmap() to return a static constant - // Bit layout: OnApply=0x01, RoundStart=0x02, RoundEnd=0x04, OnRemove=0x08, - // OnMonSwitchIn=0x10, OnMonSwitchOut=0x20, AfterDamage=0x40, AfterMove=0x80, - // OnUpdateMonState=0x100, PreDamage=0x200 - function getStepsBitmap() external pure virtual returns (uint16); + // Each subclass returns static metadata: lifecycle steps in the low uint16 and optional + // fresh-context step requests in the high uint16 (see IEffect). + function getStepsBitmap() external pure virtual returns (uint32); function name() external virtual returns (string memory) { return ""; diff --git a/src/effects/EffectCommandLib.sol b/src/effects/EffectCommandLib.sol new file mode 100644 index 00000000..af5a7347 --- /dev/null +++ b/src/effects/EffectCommandLib.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {IEffect} from "./IEffect.sol"; + +/// @notice Compact prototype command encoding for opted-in read-only effect resolvers. +library EffectCommandLib { + uint256 internal constant OP_CLEAR_STATUS = 1; + uint256 internal constant OP_ADD_EFFECT = 2; + + function clearStatus(uint256 targetIndex, uint256 monIndex, uint256 statusClass) + internal + pure + returns (uint256) + { + return OP_CLEAR_STATUS | (targetIndex << 8) | (monIndex << 16) | (statusClass << 24); + } + + function addEffect(uint256 targetIndex, uint256 monIndex, IEffect effect) internal pure returns (uint256) { + return OP_ADD_EFFECT | (targetIndex << 8) | (monIndex << 16) | (uint256(uint160(address(effect))) << 24); + } + +} diff --git a/src/effects/IEffect.sol b/src/effects/IEffect.sol index bea48ac5..c0ef313b 100644 --- a/src/effects/IEffect.sol +++ b/src/effects/IEffect.sol @@ -8,11 +8,14 @@ import "../Structs.sol"; interface IEffect { function name() external returns (string memory); - // Returns pre-computed bitmap of steps this effect runs at (set at deploy time) - // Bit layout: OnApply=0x01, RoundStart=0x02, RoundEnd=0x04, OnRemove=0x08, - // OnMonSwitchIn=0x10, OnMonSwitchOut=0x20, AfterDamage=0x40, AfterMove=0x80, - // OnUpdateMonState=0x100, PreDamage=0x200 - function getStepsBitmap() external view returns (uint16); + // Returns static metadata: lifecycle steps in bits 0-15 and requested fresh-context steps in + // bits 16-31. Storage collapses any nonzero context half to one capability bit; opted-in effects + // receive all context types supported for the hook currently running. Lifecycle layout: + // OnApply=0x01, RoundStart=0x02, RoundEnd=0x04, + // OnRemove=0x08, OnMonSwitchIn=0x10, OnMonSwitchOut=0x20, AfterDamage=0x40, + // AfterMove=0x80, OnUpdateMonState=0x100, PreDamage=0x200. Legacy deployed effects returning + // only the low uint16 remain EVM-compatible and simply request no fresh context. + function getStepsBitmap() external view returns (uint32); // Whether or not to add the effect if some condition is met function shouldApply(IEngine engine, bytes32 battleKey, bytes32 extraData, uint256 targetIndex, uint256 monIndex) @@ -20,9 +23,14 @@ interface IEffect { returns (bool); // Lifecycle hooks during normal battle flow. - // `activesPacked` carries every slot's active roster index (one 8-bit lane per absolute - // slot, EMPTY_ACTIVE_LANE = no mon there) — passed to avoid external calls back to Engine. - // Decode via TargetLib (sideActive / activeAt). + // `activesPacked` carries every slot's active roster index in its low 32 bits (one 8-bit lane + // per absolute slot, EMPTY_ACTIVE_LANE = no mon there). Opted-in AfterMove hooks also receive + // fresh 24-bit move lanes at bits 32..127 and the acted-slot mask at bits 128..131. Opted-in + // OnApply hooks receive target max HP at bits 32..63. RoundEnd hooks receive fresh 4-bit status + // lanes at bits 132..195. Opted-in AfterDamage hooks receive the target KO bit at bit 32. + // Opted-in OnUpdateMonState hooks receive target max HP at bits 32..63 and normalized signed HP + // delta at bits 64..95. Context layouts are hook-specific; existing effects remain compatible + // and opt-in readers decode the high context via TargetLib. function onRoundStart( IEngine engine, bytes32 battleKey, @@ -81,7 +89,8 @@ interface IEffect { // NOTE: CURRENTLY ONLY RUN LOCALLY ON MONS (global effects do not have this hook) // Runs before damage is applied; effects can mutate the in-flight damage by calling - // `engine.setPreDamage(int32)`. Read the current running damage via `engine.getPreDamage()`. + // `engine.setPreDamage(int32)`. Effects read the fresh current running damage via + // `TargetLib.hookPreDamage(activesPacked)`, which requires opting into PreDamage context. // Multiple subscribed effects compose sequentially in effect-array order, each observing // the post-mutation value from prior effects. function onPreDamage( diff --git a/src/effects/IEffectResolver.sol b/src/effects/IEffectResolver.sol new file mode 100644 index 00000000..088053a6 --- /dev/null +++ b/src/effects/IEffectResolver.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {EffectStep} from "../Enums.sol"; + +/// @notice Optional read-only effect hook that returns one Engine-validated mutation command. +interface IEffectResolver { + function resolveEffect( + EffectStep step, + uint256 rng, + bytes32 extraData, + uint256 targetIndex, + uint256 monIndex, + uint256 hookContext + ) external view returns (bytes32 updatedExtraData, bool removeAfterRun, uint256 command); +} + diff --git a/src/effects/StaminaRegen.sol b/src/effects/StaminaRegen.sol index 72e8ab82..dfec7b51 100644 --- a/src/effects/StaminaRegen.sol +++ b/src/effects/StaminaRegen.sol @@ -14,7 +14,7 @@ contract StaminaRegen is BasicEffect { } // Steps: RoundEnd, AfterMove - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8084; } diff --git a/src/effects/battlefield/Overclock.sol b/src/effects/battlefield/Overclock.sol index a6b630f5..4de2f21e 100644 --- a/src/effects/battlefield/Overclock.sol +++ b/src/effects/battlefield/Overclock.sol @@ -21,7 +21,7 @@ contract Overclock is BasicEffect { } // Steps: OnApply, RoundEnd, OnRemove, OnMonSwitchIn - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x801D; } diff --git a/src/effects/status/BlessedStatus.sol b/src/effects/status/BlessedStatus.sol index 4e09bd61..4e19647a 100644 --- a/src/effects/status/BlessedStatus.sol +++ b/src/effects/status/BlessedStatus.sol @@ -1,12 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; +import {ALWAYS_APPLIES_BIT, STATUS_CLASS_SHIFT} from "../../Constants.sol"; import {MonStateIndexName} from "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; import {StatusEffect} from "./StatusEffect.sol"; contract BlessedStatus is StatusEffect { + uint256 constant STATUS_CLASS = 6; + // Healed on removal, as a fraction of max HP. int32 public constant HEAL_DENOM = 16; @@ -14,42 +18,41 @@ contract BlessedStatus is StatusEffect { return "Blessed"; } - // Steps: OnApply (0x01), OnRemove (0x08), PreDamage (0x200) - function getStepsBitmap() external pure override returns (uint16) { - return 0x209; + // Steps: OnApply (0x01), OnRemove (0x08), PreDamage (0x200); fresh PreDamage context (0x0200_0000) + function getStepsBitmap() external pure override returns (uint32) { + return uint32(0x02000209) | uint32(uint16(STATUS_CLASS << STATUS_CLASS_SHIFT)) | uint32(ALWAYS_APPLIES_BIT); } // Absorb the next incoming damage source entirely, then remove (which grants the heal). - function onPreDamage(IEngine engine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) - external - override - returns (bytes32, bool removeAfterRun) - { - if (engine.getPreDamage() > 0) { + function onPreDamage( + IEngine engine, + bytes32, + uint256, + bytes32 extraData, + uint256, + uint256, + uint256 hookContext, + uint256 + ) external override returns (bytes32, bool removeAfterRun) { + if (TargetLib.hookPreDamage(hookContext) > 0) { engine.setPreDamage(0); return (extraData, true); } return (extraData, false); } - // On removal, grant the heal and clear the status flag. - function onRemove( - IEngine engine, - bytes32 battleKey, - bytes32 extraData, - uint256 targetIndex, - uint256 monIndex, - uint256 activesPacked - ) public override { + // On removal, grant the heal (the engine clears the status lane). + function onRemove(IEngine engine, bytes32 battleKey, bytes32, uint256 targetIndex, uint256 monIndex, uint256) + public + override + { _heal(engine, battleKey, targetIndex, monIndex); - super.onRemove(engine, battleKey, extraData, targetIndex, monIndex, activesPacked); } // Heal maxHp/HEAL_DENOM, clamped so we never overheal (copy of the ChainExpansion clamp). function _heal(IEngine engine, bytes32 battleKey, uint256 targetIndex, uint256 monIndex) internal { - int32 amtToHeal = - int32(engine.getMonValueForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp)) / HEAL_DENOM; - int32 hpDelta = engine.getMonStateForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp); + (uint32 maxHp, int32 hpDelta) = engine.getMonHpState(battleKey, targetIndex, monIndex); + int32 amtToHeal = int32(maxHp) / HEAL_DENOM; // hpDelta is negative when damaged; cap the heal to the damage taken so we can't exceed max HP. if (amtToHeal > (-1 * hpDelta)) { amtToHeal = -1 * hpDelta; diff --git a/src/effects/status/BurnStatus.sol b/src/effects/status/BurnStatus.sol index 34ab2ab4..3dea880c 100644 --- a/src/effects/status/BurnStatus.sol +++ b/src/effects/status/BurnStatus.sol @@ -1,14 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; +import {ALWAYS_APPLIES_BIT, HAS_REAPPLY_BIT, STATUS_CLASS_SHIFT} from "../../Constants.sol"; import "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; -import {EffectInstance, StatBoostToApply} from "../../Structs.sol"; +import {StatBoostToApply} from "../../Structs.sol"; +import {IStatusEffect} from "./IStatusEffect.sol"; import {StatusEffect} from "./StatusEffect.sol"; -import {StatusEffectLib} from "./StatusEffectLib.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; + +contract BurnStatus is StatusEffect, IStatusEffect { + uint256 constant STATUS_CLASS = 1; -contract BurnStatus is StatusEffect { uint256 public constant MAX_BURN_DEGREE = 3; uint8 public constant ATTACK_PERCENT = 50; @@ -22,99 +26,56 @@ contract BurnStatus is StatusEffect { } // Steps: OnApply, RoundEnd, OnRemove (no RoundStart behavior — the bit would only buy a - // no-op external call per burned turn) - function getStepsBitmap() external pure override returns (uint16) { - return 0x0D; + // no-op external call per burned turn). HAS_REAPPLY routes same-class re-applies to the + // degree escalation in onReapply. + function getStepsBitmap() external pure override returns (uint32) { + return 0x00010000 | 0x0D | uint16(STATUS_CLASS << STATUS_CLASS_SHIFT) | HAS_REAPPLY_BIT | ALWAYS_APPLIES_BIT; } // extraData layout: [burn degree: bits 0-7 | cached max HP: bits 32-63]. Base HP is fixed // for the battle, so caching it at apply saves the per-tick engine read. - function shouldApply(IEngine engine, bytes32 battleKey, bytes32, uint256 targetIndex, uint256 monIndex) - public - view - override - returns (bool) - { - uint64 keyForMon = StatusEffectLib.getKeyForMonIndex(targetIndex, monIndex); - - // Get value from engine KV - uint192 monStatusFlag = engine.getGlobalKV(battleKey, keyForMon); - - // Check if a status already exists for the mon (or if it's already burned) - bool noStatus = monStatusFlag == 0; - bool hasBurnAlready = monStatusFlag == uint192(uint160(address(this))); - return (noStatus || hasBurnAlready); - } - function onApply( IEngine engine, - bytes32 battleKey, + bytes32, uint256, bytes32, uint256 targetIndex, uint256 monIndex, - uint256 + uint256 context ) public override returns (bytes32 updatedExtraData, bool removeAfterRun) { - bool hasBurnAlready; - { - uint64 keyForMon = StatusEffectLib.getKeyForMonIndex(targetIndex, monIndex); - uint192 monStatusFlag = engine.getGlobalKV(battleKey, keyForMon); - hasBurnAlready = monStatusFlag == uint192(uint160(address(this))); - // Set the burn flag only when fresh — this single read replaces both super.onApply's - // guard re-read and its redundant same-value write on the escalation path. - if (!hasBurnAlready) { - engine.setGlobalKV(keyForMon, uint192(uint160(address(this)))); - } - } - - // Set stat debuff or increase burn degree - if (!hasBurnAlready) { - // Reduce attack by 1/ATTACK_DENOM of base attack stat - StatBoostToApply[] memory statBoosts = new StatBoostToApply[](1); - statBoosts[0] = StatBoostToApply({ - stat: MonStateIndexName.Attack, boostPercent: ATTACK_PERCENT, boostType: StatBoostType.Divide - }); - engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); - } else { - (EffectInstance[] memory effects, uint256[] memory indices) = - engine.getEffects(battleKey, targetIndex, monIndex); - uint256 indexOfBurnEffect; - uint256 burnDegree; - bytes32 newExtraData; - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - indexOfBurnEffect = indices[i]; - burnDegree = uint256(effects[i].data) & 0xFF; - newExtraData = effects[i].data; - } - } - if (burnDegree < MAX_BURN_DEGREE) { - newExtraData = bytes32((uint256(newExtraData) & ~uint256(0xFF)) | (burnDegree + 1)); - } - engine.editEffect(targetIndex, indexOfBurnEffect, newExtraData); - return (bytes32(0), true); - } - - uint256 maxHp = uint256(engine.getMonValueForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp)); + // Fresh apply only — the Engine routes same-class re-applies to onReapply instead. + // Reduce attack by 1/ATTACK_DENOM of base attack stat + StatBoostToApply[] memory statBoosts = new StatBoostToApply[](1); + statBoosts[0] = StatBoostToApply({ + stat: MonStateIndexName.Attack, boostPercent: ATTACK_PERCENT, boostType: StatBoostType.Divide + }); + engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); + + uint256 maxHp = TargetLib.hookMonMaxHp(context); return (bytes32(uint256(1) | (maxHp << 32)), false); } - function onRemove( - IEngine engine, - bytes32 battleKey, - bytes32, - uint256 targetIndex, - uint256 monIndex, - uint256 activesPacked - ) public override { - // Remove the base status flag - super.onRemove(engine, battleKey, bytes32(0), targetIndex, monIndex, activesPacked); + // Same-class re-apply (IStatusEffect): escalate the degree in place, preserving the cached + // max HP in the upper bits. + function onReapply(IEngine, bytes32, uint256, bytes32 existingData, uint256, uint256, uint256) + external + pure + returns (bytes32 newData, bool removeAfterRun) + { + uint256 burnDegree = uint256(existingData) & 0xFF; + if (burnDegree < MAX_BURN_DEGREE) { + existingData = bytes32((uint256(existingData) & ~uint256(0xFF)) | (burnDegree + 1)); + } + return (existingData, false); + } - // Reset the attack reduction + function onRemove(IEngine engine, bytes32, bytes32, uint256 targetIndex, uint256 monIndex, uint256) + public + override + { + // Lane clear is engine-owned (_removeEffectAtSlot); only the attack debuff is ours. engine.removeStatBoost(targetIndex, monIndex, StatBoostFlag.Perm); - // NOTE: no burn-degree KV reset — the degree lives in effect extraData (see onApply / - // onRoundEnd), and the old per-mon degree key was never written nor read anywhere. } // Deal damage over time diff --git a/src/effects/status/FrostbiteStatus.sol b/src/effects/status/FrostbiteStatus.sol index 870b1704..5b73376d 100644 --- a/src/effects/status/FrostbiteStatus.sol +++ b/src/effects/status/FrostbiteStatus.sol @@ -1,13 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; +import {ALWAYS_APPLIES_BIT, STATUS_CLASS_SHIFT} from "../../Constants.sol"; import "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {StatBoostToApply} from "../../Structs.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; import {StatusEffect} from "./StatusEffect.sol"; contract FrostbiteStatus is StatusEffect { + uint256 constant STATUS_CLASS = 2; + int32 constant DAMAGE_DENOM = 16; uint8 constant SP_ATTACK_PERCENT = 50; @@ -16,21 +20,19 @@ contract FrostbiteStatus is StatusEffect { } // Steps: OnApply, RoundEnd, OnRemove - function getStepsBitmap() external pure override returns (uint16) { - return 0x0D; + function getStepsBitmap() external pure override returns (uint32) { + return 0x00010000 | 0x0D | uint16(STATUS_CLASS << STATUS_CLASS_SHIFT) | ALWAYS_APPLIES_BIT; } function onApply( IEngine engine, - bytes32 battleKey, + bytes32, uint256 rng, bytes32 extraData, uint256 targetIndex, uint256 monIndex, - uint256 activesPacked + uint256 context ) public override returns (bytes32 updatedExtraData, bool removeAfterRun) { - super.onApply(engine, battleKey, rng, extraData, targetIndex, monIndex, activesPacked); - // Reduce special attack by half StatBoostToApply[] memory statBoosts = new StatBoostToApply[](1); statBoosts[0] = StatBoostToApply({ @@ -39,20 +41,14 @@ contract FrostbiteStatus is StatusEffect { engine.addStatBoost(targetIndex, monIndex, statBoosts, StatBoostFlag.Perm); // Cache max HP (bits 32-63; fixed for the battle) so each tick skips the engine read. - uint256 maxHp = uint256(engine.getMonValueForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp)); + uint256 maxHp = TargetLib.hookMonMaxHp(context); return (bytes32(maxHp << 32), false); } - function onRemove( - IEngine engine, - bytes32 battleKey, - bytes32 data, - uint256 targetIndex, - uint256 monIndex, - uint256 activesPacked - ) public override { - super.onRemove(engine, battleKey, data, targetIndex, monIndex, activesPacked); - + function onRemove(IEngine engine, bytes32, bytes32, uint256 targetIndex, uint256 monIndex, uint256) + public + override + { // Reset the special attack reduction engine.removeStatBoost(targetIndex, monIndex, StatBoostFlag.Perm); } diff --git a/src/effects/status/IStatusEffect.sol b/src/effects/status/IStatusEffect.sol new file mode 100644 index 00000000..15ba7866 --- /dev/null +++ b/src/effects/status/IStatusEffect.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {IEngine} from "../../IEngine.sol"; + +/// @dev Implemented only by statuses that set HAS_REAPPLY_BIT in their steps bitmap: the Engine +/// calls onReapply when the status is applied to a mon already carrying the same class +/// (e.g. Burn's degree escalation). `existingData` is the live entry's extraData; the +/// return either rewrites it in place or removes the entry. Statuses with the bit clear +/// never receive this call — a same-class re-apply is a zero-call no-op. +/// RULE (validator-enforced): a status's onApply must not apply another status to the same +/// mon — the lane is written once per stored entry, so a nested same-mon apply would alias. +interface IStatusEffect { + function onReapply( + IEngine engine, + bytes32 battleKey, + uint256 rng, + bytes32 existingData, + uint256 targetIndex, + uint256 monIndex, + uint256 activesPacked + ) external returns (bytes32 newData, bool removeAfterRun); +} diff --git a/src/effects/status/PanicStatus.sol b/src/effects/status/PanicStatus.sol index 6a3a3d8d..014eba47 100644 --- a/src/effects/status/PanicStatus.sol +++ b/src/effects/status/PanicStatus.sol @@ -1,12 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; +import {ALWAYS_APPLIES_BIT, STATUS_CLASS_SHIFT} from "../../Constants.sol"; import {MonStateIndexName} from "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {StatusEffect} from "./StatusEffect.sol"; contract PanicStatus is StatusEffect { + uint256 constant STATUS_CLASS = 4; + uint256 constant DURATION = 3; function name() public pure override returns (string memory) { @@ -14,8 +17,8 @@ contract PanicStatus is StatusEffect { } // Steps: OnApply, RoundStart, RoundEnd, OnRemove - function getStepsBitmap() external pure override returns (uint16) { - return 0x0F; + function getStepsBitmap() external pure override returns (uint32) { + return 0x0F | uint16(STATUS_CLASS << STATUS_CLASS_SHIFT) | ALWAYS_APPLIES_BIT; } // At the start of the turn, check to see if we should apply stamina debuff or end early @@ -37,17 +40,13 @@ contract PanicStatus is StatusEffect { return (extraData, false); } - // On apply, checks to apply the flag, and then sets the extraData to be the duration - function onApply( - IEngine engine, - bytes32 battleKey, - uint256 rng, - bytes32 data, - uint256 targetIndex, - uint256 monIndex, - uint256 activesPacked - ) public override returns (bytes32 updatedExtraData, bool removeAfterRun) { - super.onApply(engine, battleKey, rng, data, targetIndex, monIndex, activesPacked); + // On apply, sets the extraData to be the duration + function onApply(IEngine, bytes32, uint256, bytes32, uint256, uint256, uint256) + public + pure + override + returns (bytes32 updatedExtraData, bool removeAfterRun) + { return (bytes32(DURATION), false); } diff --git a/src/effects/status/SleepStatus.sol b/src/effects/status/SleepStatus.sol index aeb10d03..e6e2b1fc 100644 --- a/src/effects/status/SleepStatus.sol +++ b/src/effects/status/SleepStatus.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; -import {MOVE_INDEX_MASK, NO_OP_MOVE_INDEX, NO_SLOT, SWITCH_MOVE_INDEX} from "../../Constants.sol"; +import {MOVE_INDEX_MASK, NO_OP_MOVE_INDEX, NO_SLOT, STATUS_CLASS_SHIFT, SWITCH_MOVE_INDEX} from "../../Constants.sol"; import {MonStateIndexName} from "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {MoveDecision} from "../../Structs.sol"; @@ -10,6 +10,8 @@ import {TargetLib} from "../../lib/TargetLib.sol"; import {StatusEffect} from "./StatusEffect.sol"; contract SleepStatus is StatusEffect { + uint256 constant STATUS_CLASS = 3; + uint256 constant DURATION = 3; function name() public pure override returns (string memory) { @@ -17,26 +19,24 @@ contract SleepStatus is StatusEffect { } // Steps: OnApply, RoundStart, RoundEnd, OnRemove - function getStepsBitmap() external pure override returns (uint16) { - return 0x0F; + function getStepsBitmap() external pure override returns (uint32) { + return 0x0F | uint16(STATUS_CLASS << STATUS_CLASS_SHIFT); } function _globalSleepKey(uint256 targetIndex) internal pure returns (uint64) { return uint64(uint256(keccak256(abi.encodePacked(name(), targetIndex)))); } - // Whether or not to add the effect if the step condition is met. + // Extra apply condition beyond the Engine's lane gate (which already guarantees the mon is + // status-free before this is called — hence no ALWAYS_APPLIES_BIT on this status). // Enforces one sleeper per player: the global sleep key stores // [monIndex+1 (bits 160+) | status address (lower 160 bits)] for the player's current sleeper. - function shouldApply(IEngine engine, bytes32 battleKey, bytes32 data, uint256 targetIndex, uint256 monIndex) + function shouldApply(IEngine engine, bytes32 battleKey, bytes32, uint256 targetIndex, uint256) public view override returns (bool) { - if (!super.shouldApply(engine, battleKey, data, targetIndex, monIndex)) { - return false; - } uint192 sleeperFlag = engine.getGlobalKV(battleKey, _globalSleepKey(targetIndex)); if (sleeperFlag == 0) { return true; @@ -95,7 +95,6 @@ contract SleepStatus is StatusEffect { uint256 monIndex, uint256 activesPacked ) public override returns (bytes32 updatedExtraData, bool removeAfterRun) { - super.onApply(engine, battleKey, rng, data, targetIndex, monIndex, activesPacked); // Register this mon as the player's single sleeper (read by the shouldApply gate). engine.setGlobalKV( _globalSleepKey(targetIndex), uint192(uint160(address(this))) | (uint192(monIndex + 1) << 160) @@ -122,15 +121,10 @@ contract SleepStatus is StatusEffect { } } - function onRemove( - IEngine engine, - bytes32 battleKey, - bytes32 extraData, - uint256 targetIndex, - uint256 monIndex, - uint256 activesPacked - ) public override { - super.onRemove(engine, battleKey, extraData, targetIndex, monIndex, activesPacked); + function onRemove(IEngine engine, bytes32 battleKey, bytes32, uint256 targetIndex, uint256 monIndex, uint256) + public + override + { // Clear the sleeper flag only if it still points at this mon: after a KO'd sleeper // releases the gate, a successor sleeper owns the flag and must keep it. uint192 sleeperFlag = engine.getGlobalKV(battleKey, _globalSleepKey(targetIndex)); diff --git a/src/effects/status/StatusEffect.sol b/src/effects/status/StatusEffect.sol index 2eef5831..4f3a930b 100644 --- a/src/effects/status/StatusEffect.sol +++ b/src/effects/status/StatusEffect.sol @@ -1,56 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; -import {IEngine} from "../../IEngine.sol"; import {BasicEffect} from "../BasicEffect.sol"; -import {StatusEffectLib} from "./StatusEffectLib.sol"; -abstract contract StatusEffect is BasicEffect { - // Whether or not to add the effect if the step condition is met - function shouldApply(IEngine engine, bytes32 battleKey, bytes32, uint256 targetIndex, uint256 monIndex) - public - view - virtual - override - returns (bool) - { - uint64 keyForMon = StatusEffectLib.getKeyForMonIndex(targetIndex, monIndex); - - // Get value from ENGINE KV - uint192 monStatusFlag = engine.getGlobalKV(battleKey, keyForMon); - - // Check if a status already exists for the mon - if (monStatusFlag == 0) { - return true; - } else { - // Otherwise return false - return false; - } - } - - /// @dev ENGINE-ONLY INVARIANT: the engine calls onApply immediately after a passing - /// shouldApply with nothing in between (_addEffectInternal), so the per-mon status flag - /// is guaranteed clear (or already ours, for statuses whose shouldApply allows that) - /// whenever this runs — the old guard re-read of the same key was a pure ~700-gas - /// round-trip. No non-engine caller exists (and a direct caller could already write the - /// flag via setGlobalKV, so this is not a trust boundary). - function onApply(IEngine engine, bytes32, uint256, bytes32, uint256 targetIndex, uint256 monIndex, uint256) - public - virtual - override - returns (bytes32 extraData, bool removeAfterRun) - { - // Set the global status flag to be the address of the status (unconditional — see above) - engine.setGlobalKV(StatusEffectLib.getKeyForMonIndex(targetIndex, monIndex), uint192(uint160(address(this)))); - return (extraData, removeAfterRun); - } - - function onRemove(IEngine engine, bytes32, bytes32, uint256 targetIndex, uint256 monIndex, uint256) - public - virtual - override - { - // On remove, reset the status flag - engine.setGlobalKV(StatusEffectLib.getKeyForMonIndex(targetIndex, monIndex), 0); - } -} +/// @dev Marker base for exclusive statuses (one per mon). Exclusivity, re-apply routing, and +/// lane bookkeeping are Engine-native, keyed off the class id each status folds into its +/// getStepsBitmap() (bits 10-13): the Engine gates adds on the per-mon status lane before +/// any external call, sets the lane on apply, and clears it on removal. Statuses with +/// EXTRA apply conditions (SleepStatus's single-sleeper rule) override shouldApply and +/// must not set ALWAYS_APPLIES_BIT; all others set the bit and are gated by the lane +/// alone. Escalating statuses (BurnStatus) set HAS_REAPPLY_BIT and implement +/// IStatusEffect.onReapply. +abstract contract StatusEffect is BasicEffect {} diff --git a/src/effects/status/StatusEffectLib.sol b/src/effects/status/StatusEffectLib.sol deleted file mode 100644 index f8f654df..00000000 --- a/src/effects/status/StatusEffectLib.sol +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -library StatusEffectLib { - function getKeyForMonIndex(uint256 playerIndex, uint256 monIndex) internal pure returns (uint64) { - bytes32 STATUS_EFFECT = "STATUS_EFFECT"; - return uint64(uint256(keccak256(abi.encodePacked(STATUS_EFFECT, playerIndex, monIndex)))); - } -} diff --git a/src/effects/status/ZapStatus.sol b/src/effects/status/ZapStatus.sol index 9a06faaa..5a52537f 100644 --- a/src/effects/status/ZapStatus.sol +++ b/src/effects/status/ZapStatus.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; -import {MOVE_INDEX_MASK, NO_SLOT, SWITCH_MOVE_INDEX} from "../../Constants.sol"; +import { + ALWAYS_APPLIES_BIT, MOVE_INDEX_MASK, NO_SLOT, STATUS_CLASS_SHIFT, SWITCH_MOVE_INDEX +} from "../../Constants.sol"; import {MonStateIndexName} from "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {MoveDecision} from "../../Structs.sol"; @@ -10,6 +12,8 @@ import {TargetLib} from "../../lib/TargetLib.sol"; import {StatusEffect} from "./StatusEffect.sol"; contract ZapStatus is StatusEffect { + uint256 constant STATUS_CLASS = 5; + uint8 private constant ALREADY_SKIPPED = 1; function name() public pure override returns (string memory) { @@ -17,8 +21,8 @@ contract ZapStatus is StatusEffect { } // Steps: OnApply, RoundStart, RoundEnd, OnRemove - function getStepsBitmap() external pure override returns (uint16) { - return 0x0F; + function getStepsBitmap() external pure override returns (uint32) { + return 0x0F | uint16(STATUS_CLASS << STATUS_CLASS_SHIFT) | ALWAYS_APPLIES_BIT; } function onApply( @@ -30,8 +34,6 @@ contract ZapStatus is StatusEffect { uint256 monIndex, uint256 activesPacked ) public override returns (bytes32 updatedExtraData, bool removeAfterRun) { - super.onApply(engine, battleKey, rng, extraData, targetIndex, monIndex, activesPacked); - uint8 state; // If the target hasn't acted yet this turn, skip it immediately; otherwise wait for diff --git a/src/game-layer/CLAUDE.md b/src/game-layer/CLAUDE.md new file mode 100644 index 00000000..b3434ac6 --- /dev/null +++ b/src/game-layer/CLAUDE.md @@ -0,0 +1,72 @@ +# Gacha & Progression System + +`GachaTeamRegistry` is the concrete leaf — it's an `IEngineHook` (subscribes to `OnBattleEnd`) and composes seven abstract bases that each own one slice of state and behavior: + +| Abstract | Owns | +|---|---| +| `MonOwnership` | `monsOwned` per-player set; ownership view + bulk-check helpers (`_isOwnerBatch`, `_validateOwnership`). Satisfies the `_isFacetMonOwned` hook on `Facets`. | +| `MonRegistry` | Owner-managed mon catalog (`monStats`, `monMoves`, `monAbilities`, `monMetadata`, sequential `monIds` set). Inherits `ITeamRegistry` so its `createMon`/`modifyMon`/batched-getters bind directly. Backs the `_getMonStatsForFacets` hook used by facet delta computation. | +| `PlayerProfile` | Packed `playerData[address]` (one uint256 per player) + the owner-managed `isAssigner` allowlist. Exposes `pointsBalance`, `isWhitelistedOpponent`, the bulk admin flag setters, and `assignPoints` (IGachaPointsAssigner). | +| `PackedTeamStore` | Bit-packed team CRUD: `teamGroupsPacked` (4 teams × 64 bits per slot), `teamOrderPacked` (16-slot live bitmap + display order). Constructor takes `MONS_PER_TEAM` / `MOVES_PER_MON` immutables. Subclass hooks: `_packedTeamValidateOwnership`, `_packedTeamIsCpuOpponent`, `_packedTeamGetMonData`. | +| `Facets` | 12-facet ±5% stat tradeoff system (see below). Pure helpers, packed per-mon facet data, `assignFacets`. | +| `MonExp` | Packed per-mon exp (`packedExpForMon`), level curve, level-up facet draws (`_processLevelUps`), public `getExp` / `getLevel` / `getExpAndLevelsFor*`, assigner-gated `assignExp`. Inherits `Facets` + `PackedTeamStore` because level-ups draw facets and team views need the lane helpers. Subclass hooks: `_assertExpAssigner`, `_monRegistrySize`. | +| `Quests` | Daily quest pool + packed predicate evaluator. Subclass hook: `_extract` (opcode dispatch implemented by the leaf since it sees Engine + all sub-systems). | + +The leaf adds: the `onBattleEnd` orchestration (streak / quest / points / exp loop, plus a `_applyExpAndFacetDraws` walk that mirrors `assignExp` but with KO bitmap + streak + event packing), gacha rolling (`firstRoll`/`roll`/`_rollInto`), the per-(user, opponent) CPU phantom facet config, the `getTeams` variant that folds facet deltas in, the `_extract` quest opcode dispatch, and the wiring overrides for every subclass hook. With this split the leaf is ~750 LOC of integration and event-shape concerns; each base is independently auditable. + +**Rolling.** Mon ids are sequential starting at 0 (`createMon` enforces `monId == monIds.length()`). Ids `[0, NUM_STARTERS)` (= 3) are *starter* mons. + +- `firstRoll(uint256 starterId)` — one-shot per player, onboarding in a single tx. Caller picks `starterId ∈ {0,1,2}`; the contract guarantees that mon at slot 0 of the result and rolls `INITIAL_ROLLS - 1` (= 3) more uniformly from `[NUM_STARTERS, numMons)`. Free. Also creates the player's first team (slot 0) from the first `MONS_PER_TEAM` rolled ids, via the same internal path `createTeam` uses (`_createTeamForUser`) — skipping the redundant ownership check since those ids were just written. The constructor reverts `MonsPerTeamExceedsInitialRolls` if `MONS_PER_TEAM > INITIAL_ROLLS`, since a deployment configured that way could never satisfy this. +- `roll(uint256 numRolls)` — paid (`ROLL_COST` per roll, default 16 points). Uniform across the entire pool. Reverts `NoMoreStock` once the caller owns every mon. +- Linear-probing dedup keeps draws inside their window so `firstRoll`'s 3 random picks never land on a starter. + +**Points / exp / facets storage.** All packed for gas: + +``` +playerData[address] (1 slot per player): + bit 255 bonusAwarded (first-game-ever bonus claimed) + bit 254 isWhitelistedAsOpponent (admin-set; replaces a separate mapping) + bit 253 (reserved; formerly isHardCpu) + bits 250-252 streakDay (1..STREAK_FLAT_BONUS_MAX; 0 = no streak yet) + bits 224-249 (reserved) + bits 192-223 lastQuestCompletedDay (uint32 calendar day) + bits 160-191 lastSeenTimestamp (uint32 seconds; last battle of ANY kind — drives streak grace/reset) + bits 128-159 lastFirstGameTimestamp (uint32 seconds; last streak-bonus game — gates the 24h cooldown) + bits 0-127 pointsBalance (uint128) + +packedExpForMon[player][monId / 16]: 16 mons × 16 bits each, capped at 65535. +facetData[player][monId / 16]: 16 mons × 16 bits each + (bits 0-11 unlockedBitmap, bits 12-15 assignedFacetId). +``` + +Streak is timestamp-driven (not calendar-day): a battle qualifies for the streak bonus +when ≥24h have passed since `lastFirstGameTimestamp` (the last *bonus-earning* game). On a +qualifying battle the ratchet-vs-reset decision is measured from `lastSeenTimestamp` (the +last battle of *any* kind, advanced every battle): a gap >36h (`STREAK_GRACE_WINDOW`) of +genuine inactivity resets `streakDay` to 1, otherwise it ratchets up toward the cap of +`STREAK_FLAT_BONUS_MAX` (= 5). Splitting the two anchors is deliberate — measuring the +reset from the bonus anchor instead would strand players who play slightly more often than +once per 24h (their sub-24h plays advance no anchor, so the next day reads a phantom ~46h +gap and resets the streak forever). + +Both per-mon mappings share the same 16-mon bucketing so `_applyExpAndFacetDraws` walks the team in one pass and coalesces SSTOREs by bucket. + +**Battle rewards (`onBattleEnd`).** CPU side is short-circuited (no SSTOREs, no event). For each human side: + +- Base points: `POINTS_PER_WIN` (2) on win, `POINTS_PER_LOSS` (1) otherwise. +- Streak flat: `streakDay` (1..5) added inside the parenthetical of both the points and per-mon-exp formulas. Only granted when the battle qualifies as "first of day" (≥24h since the last grant). +- Points formula: `(basePts + streakFlat) × gachaMult + firstGameEverBonus`. `gachaMult` is `×QUEST_REWARD_MULT` (= 2) when the active daily quest completes (winner-only, one-shot per day), else 1. `firstGameEverBonus` is `+FIRST_GAME_EVER_BONUS` (= 16), one-shot ever, applied *after* the multiplier. +- Per-mon exp formula: `(baseExp + streakFlat) × expMult`, capped at 65535. `baseExp` is `EXP_PER_SURVIVING_MON` (2) for alive slots, `EXP_PER_KOD_MON` (1) for KO'd slots. +- `expMult` stack: a flat `GAME_EXP_MULT` (×2) on **every** battle regardless of game type (PvP, CPU, hard or not), times `QUEST_REWARD_MULT` (×2) on quest completion. Max stack = ×4. (There is no longer a PvP-vs-CPU or hard-CPU exp distinction — the old client-set "hard CPU" flag let any user self-grant the bonus, so it was removed.) +- Level-ups (12-tier curve, capped at level 12 to match `TOTAL_FACETS`) trigger one facet draw per level crossed. + +**Facets.** 12 systematically-derived stat tradeoffs across 4 stat groups (`HP`, `Atk`, `Def`, `Speed`). `_facetDef(facetId)` is pure — no constant table. Magnitudes are **boost-indexed**: the boost stat determines both the boost% and the cost% paid on the nerfed stat. HP/Atk/Def boosts are symmetric (+5% / -5%); speed boosts pay a heavier cost (+5% / -10%) so they can't cheaply break speed ties. The percentages live as `BOOST_PCT_*` / `COST_PCT_*` constants in `Facets.sol`. Unlocks are persistent per-mon; `assignFacets(monIds, facetIds)` is a free bulk re-assign that requires the caller to own every listed mon and the facet to be in the unlocked bitmap (`facetId == 0` clears). `GachaTeamRegistry.getTeams()` folds the active facet's delta into each mon's stats before returning, so by the time the Engine stores teams in `BattleConfig` they already reflect the boost/cost. `validateMon` no longer checks stat equality (the round-trip would always fail with facets applied) — moves and ability membership are still enforced. + +**CPU opponent facets.** When fighting a whitelisted opponent (CPU), the human caller picks the CPU's team *and* its facet config in one call: `setOpponentTeam(opponent, monIndices, facetIds)`. Per-user-per-CPU storage (`opponentTeamFacetsPacked[opponent][phantomKey]`) keyed by the same `uint16(uint160(msg.sender))` phantom slot as the team. No ownership/unlock checks — any facet 0..12 is allowed. `getTeamsWithDeltas` short-circuits to this slot-indexed config when a side is `isWhitelistedOpponent`, so per-user CPU facet configurations stay isolated even when many users fight the same CPU. + +**Quests.** Owner-managed `questPool`. One quest is active per UTC day, derived statelessly as `keccak256(day) % poolLength` (day = `block.timestamp / 1 days` + an owner-settable `_dayOffset`) — no rotation SSTORE and no race between concurrent battles. Each quest has up to `MAX_PREDICATES_PER_QUEST` (6) AND-composed predicates packed into one storage slot (41 bits each: `op` 5b, `cmp` 3b, `negate` 1b, `arg` 16b, `operand` 16b — total 246b + 3b count). Opcodes cover battle context (`TURNS`, `ALIVE_COUNT`, `ACTIVE_SLOT_INDEX`, `MON_KO_AT_SLOT`), team composition (`HAS_MON_ID`), per-mon progression (`MON_LEVEL`, `MON_FACET`), and live battle state (`MON_STATE` via `Engine.getMonStateForBattle`). + +**Events.** + +- `Roll(address indexed player, uint256[] monIds, uint256 pointsSpent)` — fires on both `firstRoll` (spend = 0) and paid `roll`. +- `GachaEvent(bytes32 indexed battleKey, uint256 p0Packed, uint256 p1Packed)` — one per battle, carrying both sides' packed payloads (CPU side is 0). Layout sized for `MONS_PER_TEAM` up to 8: points (bits 0-15), per-mon exp gain (bits 16-79, 8 lanes × 8b), per-mon facets unlocked this battle (bits 80-175, 8 lanes × 12-bit bitmap = 1 bit per facet id), `BONUS_*` flags (bits 176-183: `FIRST_ROLL` (bit 0) | `FIRST_GAME` (bit 1) | bit 2 reserved/formerly `HARD_CPU` | `QUEST` (bit 3)), combined exp multiplier (bits 184-191), outcome (bits 192-199: 0=loss, 1=win, 2=draw), `streakDay` (bits 200-202). Lanes saturate so a future tuning blow-up can't bleed into neighbouring fields. diff --git a/src/game-layer/GachaTeamRegistry.sol b/src/game-layer/GachaTeamRegistry.sol index 78b313b0..4780140d 100644 --- a/src/game-layer/GachaTeamRegistry.sol +++ b/src/game-layer/GachaTeamRegistry.sol @@ -98,11 +98,13 @@ contract GachaTeamRegistry is // ----- Errors ----- error NotWhitelistedOpponent(); error AlreadyFirstRolled(); + error NotFirstRolled(); error InvalidStarterId(); error NoMoreStock(); error NotEngine(); error NoPreviousRegistry(); error AlreadyMigrated(); + error MonsPerTeamExceedsInitialRolls(); // ----- Events ----- event Roll(address indexed player, uint256[] monIds, uint256 pointsSpent); @@ -151,11 +153,17 @@ contract GachaTeamRegistry is IGachaRNG _RNG, GachaTeamRegistry _PREVIOUS_REGISTRY ) PackedTeamStore(_MONS_PER_TEAM, _MOVES_PER_MON) { + if (_MONS_PER_TEAM > INITIAL_ROLLS) { + revert MonsPerTeamExceedsInitialRolls(); + } ENGINE = _ENGINE; RNG = address(_RNG) == address(0) ? IGachaRNG(address(this)) : _RNG; PREVIOUS_REGISTRY = _PREVIOUS_REGISTRY; _initializeOwner(msg.sender); _seedInitialQuests(); + + // Set deployer to be assigner + isAssigner[msg.sender] = true; } /// @dev Seeds the day-rotated quest pool. Pool size and content fix the schedule, since @@ -206,11 +214,7 @@ contract GachaTeamRegistry is // ===================================================================== /// @notice Admin: write `monIndices` into `user`'s `slot` and apply parallel - /// `facetIds` for those mons in one tx. The slot is marked live if it wasn't - /// already (so fresh-slot allocation and overwrite share one path). Facet writes - /// bypass the ownership + unlock checks in `assignFacets` and also mark each - /// non-zero facet bit as unlocked, so the user's own `assignFacets` won't revert - /// `FacetNotUnlocked` later. Does NOT add the mons to `monsOwned` — the user + /// `facetIds` for those mons in one tx. Does NOT add the mons to `monsOwned` — the user /// still can't swap mons they don't own via `updateTeam`. function setTeamForUser(address user, uint256 slot, uint256[] memory monIndices, uint8[] memory facetIds) external @@ -584,9 +588,24 @@ contract GachaTeamRegistry is // Remaining rolls are uniform across non-starter pool [NUM_STARTERS, numMons). _rollInto(rolledIds, 1, NUM_STARTERS); emit Roll(msg.sender, rolledIds, 0); + + uint256[] memory starterTeam = rolledIds; + if (MONS_PER_TEAM != INITIAL_ROLLS) { + starterTeam = new uint256[](MONS_PER_TEAM); + for (uint256 i; i < MONS_PER_TEAM; ++i) { + starterTeam[i] = rolledIds[i]; + } + } + _createTeamForUser(msg.sender, starterTeam); } function roll(uint256 numRolls) external returns (uint256[] memory rolledIds) { + // Owning nothing means the account skipped onboarding entirely: rolling here would + // leave it with mons but no starter and no team, which nothing downstream repairs. + // Both entry points (`firstRoll`, `migrate`) seed ownership, so one check covers them. + if (monsOwned[msg.sender].length() == 0) { + revert NotFirstRolled(); + } if (monsOwned[msg.sender].length() == monIds.length()) { revert NoMoreStock(); } diff --git a/src/game-layer/SignedAssigner.sol b/src/game-layer/SignedAssigner.sol new file mode 100644 index 00000000..46cfa45a --- /dev/null +++ b/src/game-layer/SignedAssigner.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {ECDSA} from "../lib/ECDSA.sol"; +import {EIP712} from "../lib/EIP712.sol"; +import {IGachaPointsAssigner} from "./IGachaPointsAssigner.sol"; + +contract SignedAssigner is EIP712 { + address public immutable SIGNER; + IGachaPointsAssigner public immutable GACHA; + + mapping(uint256 word => uint256 bits) public claimBitmap; + + error ClaimAlreadySpent(); + + event Claimed(address indexed recipient, uint256 amount, uint256 indexed claimId); + + constructor(address _SIGNER, address _GACHA) { + SIGNER = _SIGNER; + GACHA = IGachaPointsAssigner(_GACHA); + } + + function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) { + name = "SignedAssigner"; + version = "1"; + } + + /// @notice Redeem a signed grant. Callable by anyone; `recipient` is paid either way. + function claim(address recipient, uint256 amount, uint256 claimId, bytes calldata signature) external { + bytes32 digest = _hashTypedData( + keccak256( + abi.encode( + keccak256("Claim(address recipient,uint256 amount,uint256 claimId)"), recipient, amount, claimId + ) + ) + ); + // Reverts InvalidSignature() itself on a malformed signature. + if (ECDSA.recoverCalldata(digest, signature) != SIGNER) { + revert ECDSA.InvalidSignature(); + } + uint256 word = claimId >> 8; + uint256 bit = 1 << (claimId & 0xFF); + uint256 bits = claimBitmap[word]; + if (bits & bit != 0) { + revert ClaimAlreadySpent(); + } + claimBitmap[word] = bits | bit; + + GACHA.assignPoints(recipient, amount); + emit Claimed(recipient, amount, claimId); + } + + function isSpent(uint256 claimId) external view returns (bool) { + return claimBitmap[claimId >> 8] & (1 << (claimId & 0xFF)) != 0; + } +} diff --git a/src/kernel/PackedHeaderFrameLib.sol b/src/kernel/PackedHeaderFrameLib.sol new file mode 100644 index 00000000..35c0034c --- /dev/null +++ b/src/kernel/PackedHeaderFrameLib.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {BattleConfig, BattleData} from "../Structs.sol"; + +/// @notice Lazy memory frame for the persistent packed battle headers. +/// @dev Word indices 0..1 are BattleData; 2..7 are BattleConfig offsets 0..5; index 8 is +/// BattleConfig offset 18 (`playerEffectStepsByMon`). This prototype deliberately exposes +/// raw words: typed accessors can be generated once the routing experiment fixes the schema. +library PackedHeaderFrameLib { + uint256 internal constant WORD_COUNT = 9; + uint256 internal constant BATTLE_STATIC_WORD = 0; + uint256 internal constant BATTLE_STATE_WORD = 1; + + uint256 private constant ADDRESS_MASK = type(uint160).max; + uint256 private constant WINNER_SHIFT = 160; + uint256 private constant SWITCH_FLAG_SHIFT = 168; + uint256 private constant ACTIVE_MON_SHIFT = 176; + uint256 private constant TIMESTAMP_SHIFT = 192; + uint256 private constant TURN_ID_SHIFT = 232; + uint256 private constant NUM_BUFFERED_SHIFT = 248; + + error HeaderWordOutOfBounds(); + error DirtyHeaderFrame(); + + struct Frame { + uint256 battleBaseSlot; + uint256 configBaseSlot; + uint256[WORD_COUNT] words; + uint16 loadedMask; + uint16 dirtyMask; + } + + function init(BattleData storage battle, BattleConfig storage config) + internal + pure + returns (Frame memory frame) + { + assembly ("memory-safe") { + mstore(frame, battle.slot) + mstore(add(frame, 0x20), config.slot) + } + } + + function read(Frame memory frame, uint256 wordIndex) internal view returns (uint256 value) { + uint16 bit = _wordBit(wordIndex); + if (frame.loadedMask & bit == 0) { + value = _sload(_storageSlot(frame, wordIndex)); + frame.words[wordIndex] = value; + frame.loadedMask |= bit; + } else { + value = frame.words[wordIndex]; + } + } + + /// @notice Replace a complete packed word without paying a load first. + function write(Frame memory frame, uint256 wordIndex, uint256 value) internal pure { + uint16 bit = _wordBit(wordIndex); + frame.words[wordIndex] = value; + frame.loadedMask |= bit; + frame.dirtyMask |= bit; + } + + function p1(Frame memory frame) internal view returns (address) { + return address(uint160(read(frame, BATTLE_STATIC_WORD))); + } + + function p0(Frame memory frame) internal view returns (address) { + return address(uint160(read(frame, BATTLE_STATE_WORD))); + } + + function winnerIndex(Frame memory frame) internal view returns (uint8) { + return uint8(read(frame, BATTLE_STATE_WORD) >> WINNER_SHIFT); + } + + function playerSwitchForTurnFlag(Frame memory frame) internal view returns (uint8) { + return uint8(read(frame, BATTLE_STATE_WORD) >> SWITCH_FLAG_SHIFT); + } + + function activeMonIndex(Frame memory frame) internal view returns (uint16) { + return uint16(read(frame, BATTLE_STATE_WORD) >> ACTIVE_MON_SHIFT); + } + + function lastExecuteTimestamp(Frame memory frame) internal view returns (uint40) { + return uint40(read(frame, BATTLE_STATE_WORD) >> TIMESTAMP_SHIFT); + } + + function turnId(Frame memory frame) internal view returns (uint16) { + return uint16(read(frame, BATTLE_STATE_WORD) >> TURN_ID_SHIFT); + } + + function numBuffered(Frame memory frame) internal view returns (uint8) { + return uint8(read(frame, BATTLE_STATE_WORD) >> NUM_BUFFERED_SHIFT); + } + + function setWinnerIndex(Frame memory frame, uint8 value) internal view { + _replaceBattleStateBits(frame, uint256(value), type(uint8).max, WINNER_SHIFT); + } + + function setActiveMonIndex(Frame memory frame, uint16 value) internal view { + _replaceBattleStateBits(frame, uint256(value), type(uint16).max, ACTIVE_MON_SHIFT); + } + + /// @notice Apply the normal non-game-over end-of-turn BattleData transition in one word. + function advanceTurn(Frame memory frame, uint8 switchFlag, uint40 timestamp) internal view { + uint256 word = read(frame, BATTLE_STATE_WORD); + uint16 nextTurnId = uint16(word >> TURN_ID_SHIFT) + 1; + uint256 clearMask = ~( + (uint256(type(uint8).max) << SWITCH_FLAG_SHIFT) + | (uint256(type(uint40).max) << TIMESTAMP_SHIFT) + | (uint256(type(uint16).max) << TURN_ID_SHIFT) + ); + word = (word & clearMask) | (uint256(switchFlag) << SWITCH_FLAG_SHIFT) + | (uint256(timestamp) << TIMESTAMP_SHIFT) | (uint256(nextTurnId) << TURN_ID_SHIFT); + write(frame, BATTLE_STATE_WORD, word); + } + + function flush(Frame memory frame) internal { + uint16 dirty = frame.dirtyMask; + while (dirty != 0) { + uint256 wordIndex = _leastSignificantBitIndex(dirty); + _sstore(_storageSlot(frame, wordIndex), frame.words[wordIndex]); + dirty &= dirty - 1; + } + frame.dirtyMask = 0; + } + + /// @notice Invalidate after a legacy mechanic returns. Dirty words must be flushed first. + function invalidate(Frame memory frame) internal pure { + if (frame.dirtyMask != 0) revert DirtyHeaderFrame(); + frame.loadedMask = 0; + } + + function flushAndInvalidate(Frame memory frame) internal { + flush(frame); + frame.loadedMask = 0; + } + + function _replaceBattleStateBits(Frame memory frame, uint256 value, uint256 valueMask, uint256 shift) + private + view + { + uint256 word = read(frame, BATTLE_STATE_WORD); + uint256 shiftedMask = valueMask << shift; + write(frame, BATTLE_STATE_WORD, (word & ~shiftedMask) | ((value & valueMask) << shift)); + } + + function _storageSlot(Frame memory frame, uint256 wordIndex) private pure returns (uint256 slot) { + if (wordIndex < 2) return frame.battleBaseSlot + wordIndex; + if (wordIndex < 8) return frame.configBaseSlot + wordIndex - 2; + if (wordIndex == 8) return frame.configBaseSlot + 18; + revert HeaderWordOutOfBounds(); + } + + function _wordBit(uint256 wordIndex) private pure returns (uint16 bit) { + if (wordIndex >= WORD_COUNT) revert HeaderWordOutOfBounds(); + bit = uint16(1 << wordIndex); + } + + function _leastSignificantBitIndex(uint16 value) private pure returns (uint256 index) { + while (value & 1 == 0) { + value >>= 1; + index++; + } + } + + function _sload(uint256 slot) private view returns (uint256 value) { + assembly ("memory-safe") { + value := sload(slot) + } + } + + function _sstore(uint256 slot, uint256 value) private { + assembly ("memory-safe") { + sstore(slot, value) + } + } +} diff --git a/src/lib/MappingAllocator.sol b/src/lib/MappingAllocator.sol index b5df937d..64ee2a61 100644 --- a/src/lib/MappingAllocator.sol +++ b/src/lib/MappingAllocator.sol @@ -63,12 +63,4 @@ abstract contract MappingAllocator { } delete battleKeyToStorageKey[battleKey]; } - - function getFreeStorageKeys() public view returns (bytes32[] memory keys) { - uint256 count = _freeKeyCount(); - keys = new bytes32[](count); - for (uint256 i; i < count; i++) { - keys[i] = freeStorageKeys[i]; - } - } } diff --git a/src/lib/StaminaRegenLogic.sol b/src/lib/StaminaRegenLogic.sol index c72e1c46..e5e11d46 100644 --- a/src/lib/StaminaRegenLogic.sol +++ b/src/lib/StaminaRegenLogic.sol @@ -59,7 +59,7 @@ library StaminaRegenLogic { /// @notice External AfterMove entry point — gated on the same NoOp check as the inline path. function onAfterMoveExternal(IEngine engine, bytes32 battleKey, uint256 targetIndex, uint256 monIndex) internal { - MoveDecision memory moveDecision = engine.getMoveDecisionForBattleState(battleKey, targetIndex); + MoveDecision memory moveDecision = engine.getMoveDecisionForSlot(battleKey, targetIndex, 0); if (!_isRestingMove(moveDecision.packedMoveIndex)) { return; } diff --git a/src/lib/StatBoostLib.sol b/src/lib/StatBoostLib.sol index dc6a18f7..b3506b98 100644 --- a/src/lib/StatBoostLib.sol +++ b/src/lib/StatBoostLib.sol @@ -34,6 +34,119 @@ library StatBoostLib { return uint8(uint256(data) >> PERM_FLAG_OFFSET) != 0; } + // --------------------------------------------------------------------------------------------- + // Aggregation accumulator (one uint256 per mon, cached by the Engine). + // + // 5 stat lanes of [numerator:48 | count:3] at bit k*51, plus bit 255 = DISABLED. A lane + // mirrors _accumulateOne's running product exactly: numerator = base × ∏(100 ± pct) with + // count total instances, finalized as numerator / 100^count (single divide — so incremental + // multiply-in / divide-out is bit-identical to a full recompute). An update that would + // overflow a lane's 48-bit numerator or 3-bit count returns ok = false; the Engine then sets + // DISABLED and falls back to recompute-from-sources for the rest of the battle. + // --------------------------------------------------------------------------------------------- + + uint256 internal constant ACC_LANE_BITS = 51; + uint256 internal constant ACC_NUM_MASK = (1 << 48) - 1; + uint256 internal constant ACC_CNT_MASK = 0x7; + uint256 internal constant ACC_DISABLED_BIT = 1 << 255; + + /// @dev Lane k of a packed source word: (pct, count, isMul). count == 0 means "no boost". + function laneAt(bytes32 data, uint256 k) internal pure returns (uint256 pct, uint256 cnt, bool mul) { + uint256 inst = (uint256(data) >> (k * 16)) & 0xFFFF; + pct = inst >> 8; + cnt = (inst >> 1) & 0x7F; + mul = (inst & 0x1) == 1; + } + + /// @dev Scaling factor for one boost application, as a numerator over DENOM. A Divide can never + /// drive this to 0: the aggregation stores a stat as (numerator, count), so a zeroed + /// numerator is indistinguishable from an empty lane, and a divide-out of a 0 factor cannot + /// be inverted. A reduction of DENOM% or more therefore saturates at the smallest + /// representable factor rather than reverting (a revert here would wedge the battle — note + /// `DENOM - boostPercent` underflows for the uint8 range above 100). Every live move uses + /// 15-50%, so this is defensive only. + function boostFactor(uint256 boostPercent, bool isMul) internal pure returns (uint256) { + if (isMul) { + return DENOM + boostPercent; + } + return boostPercent >= DENOM ? 1 : DENOM - boostPercent; + } + + /// @dev Fold a source word into (mulIn = true) or out of (mulIn = false) the accumulator. + /// Divide-out is exact by construction (the factors were multiplied in); a lane drained + /// to count 0 resets its numerator to 0 (canonical empty — finalize reads base then). + function applyWordToAcc(uint256 acc, bytes32 word, uint32[5] memory baseStats, bool mulIn) + internal + pure + returns (uint256 newAcc, bool ok) + { + for (uint256 k; k < 5; ++k) { + (uint256 pct, uint256 cnt, bool mul) = laneAt(word, k); + if (cnt == 0) { + continue; + } + uint256 factor = boostFactor(pct, mul); + uint256 shift = k * ACC_LANE_BITS; + uint256 num = (acc >> shift) & ACC_NUM_MASK; + uint256 laneCnt = (acc >> (shift + 48)) & ACC_CNT_MASK; + if (mulIn) { + if (laneCnt + cnt > ACC_CNT_MASK) { + return (acc, false); + } + if (laneCnt == 0) { + num = baseStats[k]; + } + for (uint256 j; j < cnt; ++j) { + num *= factor; + if (num > ACC_NUM_MASK) { + return (acc, false); + } + } + laneCnt += cnt; + } else { + // `boostFactor` floors at 1, so the div-by-zero arm is unreachable; kept as a cheap + // assertion that the invariant holds for anything folded in here. + if (factor == 0 || laneCnt < cnt) { + return (acc, false); + } + for (uint256 j; j < cnt; ++j) { + num /= factor; + } + laneCnt -= cnt; + if (laneCnt == 0) { + num = 0; + } + } + acc = (acc & ~(((ACC_NUM_MASK) | (ACC_CNT_MASK << 48)) << shift)) | (num << shift) + | (laneCnt << (shift + 48)); + } + return (acc, true); + } + + /// @dev finalizeBoostedStats over the packed accumulator — same divide + clamp semantics. + function finalizeAccStats(uint256 acc, uint32[5] memory baseStats) + internal + pure + returns (uint32[5] memory newBoostedStats) + { + for (uint256 k; k < 5; ++k) { + uint256 shift = k * ACC_LANE_BITS; + uint256 laneCnt = (acc >> (shift + 48)) & ACC_CNT_MASK; + if (laneCnt == 0) { + newBoostedStats[k] = baseStats[k]; + continue; + } + uint256 raw = ((acc >> shift) & ACC_NUM_MASK) / denomPower(laneCnt); + if (raw > MAX_BOOSTED_STAT) { + newBoostedStats[k] = MAX_BOOSTED_STAT; + } else if (raw == 0) { + newBoostedStats[k] = 1; + } else { + newBoostedStats[k] = uint32(raw); + } + } + } + // Pack a fresh boost instance from the caller-supplied boosts. function packBoostData(uint168 key, bool perm, StatBoostToApply[] memory statBoostsToApply) internal @@ -158,10 +271,12 @@ library StatBoostLib { uint32[5] memory numBoostsPerStat, uint256[5] memory accumulatedNumeratorPerStat ) private pure { - uint256 existingStatValue = (accumulatedNumeratorPerStat[k] == 0) - ? baseStats[k] - : accumulatedNumeratorPerStat[k]; - uint256 scalingFactor = isMul ? DENOM + boostPercent : DENOM - boostPercent; + // Seed off the boost COUNT, not the numerator: a 100% Divide gives factor 0, so a numerator + // of 0 is a legitimately zeroed stat, not an empty lane. Reading it as empty would re-seed + // from base and resurrect the stat, diverging from the accumulator (which seeds off count). + uint256 existingStatValue = + (numBoostsPerStat[k] == 0) ? baseStats[k] : accumulatedNumeratorPerStat[k]; + uint256 scalingFactor = boostFactor(boostPercent, isMul); unchecked { accumulatedNumeratorPerStat[k] = existingStatValue * (scalingFactor ** boostCount); numBoostsPerStat[k] += uint32(boostCount); diff --git a/src/lib/SwitchTargetLib.sol b/src/lib/SwitchTargetLib.sol index 6c4846ab..a069147f 100644 --- a/src/lib/SwitchTargetLib.sol +++ b/src/lib/SwitchTargetLib.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; -import {MonStateIndexName} from "../Enums.sol"; import {IEngine} from "../IEngine.sol"; import {RNGLib} from "./RNGLib.sol"; @@ -22,14 +21,12 @@ library SwitchTargetLib { ) internal view returns (int32) { // Mix in target player index to break symmetry on mirror matchups uint256 offset = RNGLib.mixForAttacker(rng, playerIndex); - (uint256 lo, uint256 hi) = engine.getRosterBoundsForSlot(battleKey, playerIndex, slotIndex); + (uint256 lo, uint256 hi, uint256 koBitmap) = engine.getSlotSwitchWindow(battleKey, playerIndex, slotIndex); uint256 span = hi - lo; for (uint256 i; i < span; ++i) { uint256 candidate = lo + ((i + offset) % span); if (candidate != currentMonIndex && candidate != allyMonIndex) { - bool isKOed = - engine.getMonStateForBattle(battleKey, playerIndex, candidate, MonStateIndexName.IsKnockedOut) == 1; - if (!isKOed) { + if ((koBitmap >> candidate) & 1 == 0) { return int32(int256(candidate)); } } diff --git a/src/lib/TargetLib.sol b/src/lib/TargetLib.sol index 5c22bce6..aa460158 100644 --- a/src/lib/TargetLib.sol +++ b/src/lib/TargetLib.sol @@ -8,6 +8,14 @@ import "../Constants.sol"; /// 2 = side1/slot0, 3 = side1/slot1). `activesPacked` carries one 8-bit lane per /// absolute slot holding that slot's active roster index (EMPTY_ACTIVE_LANE = no mon). library TargetLib { + uint256 internal constant HOOK_MOVE_LANES_SHIFT = 32; + uint256 internal constant HOOK_ACTED_MASK_SHIFT = 128; + uint256 internal constant HOOK_STATUS_LANES_SHIFT = 132; + uint256 internal constant HOOK_MON_MAX_HP_SHIFT = 32; + uint256 internal constant HOOK_MON_HP_DELTA_SHIFT = 64; + uint256 internal constant HOOK_MON_KO_SHIFT = 32; + uint256 internal constant HOOK_PRE_DAMAGE_SHIFT = 32; + function sideOf(uint256 absSlot) internal pure returns (uint256) { return absSlot >> 1; } @@ -57,6 +65,43 @@ library TargetLib { return NO_SLOT; } + /// @notice Fresh 24-bit current-move lane embedded by Engine immediately before an + /// AfterMove callback: [extraData:16 | packedMoveIndex:8]. Other hook steps leave + /// these context lanes unset. The low 32 active-mon lanes remain ABI-compatible. + function hookMoveWordAt(uint256 hookContext, uint256 absSlot) internal pure returns (uint256) { + return (hookContext >> (HOOK_MOVE_LANES_SHIFT + absSlot * 24)) & 0xFFFFFF; + } + + function hookSlotActed(uint256 hookContext, uint256 absSlot) internal pure returns (bool) { + return ((hookContext >> (HOOK_ACTED_MASK_SHIFT + absSlot)) & 1) != 0; + } + + /// @notice Fresh 4-bit exclusive-status class for any roster mon, embedded immediately + /// before an opted-in lifecycle callback. Lanes use side*8+monIndex, matching Engine. + function hookStatusClass(uint256 hookContext, uint256 side, uint256 monIndex) internal pure returns (uint256) { + return (hookContext >> (HOOK_STATUS_LANES_SHIFT + (((side << 3) | monIndex) << 2))) & 0xF; + } + + /// @notice Fresh target-mon state embedded for an opted-in OnUpdateMonState callback. Context + /// layouts are hook-specific, so these lanes intentionally overlap AfterMove's moves. + function hookMonMaxHp(uint256 hookContext) internal pure returns (uint32) { + return uint32(hookContext >> HOOK_MON_MAX_HP_SHIFT); + } + + function hookMonHpDelta(uint256 hookContext) internal pure returns (int32) { + return int32(uint32(hookContext >> HOOK_MON_HP_DELTA_SHIFT)); + } + + /// @notice Fresh target KO state embedded for an opted-in AfterDamage callback. + function hookMonIsKnockedOut(uint256 hookContext) internal pure returns (bool) { + return ((hookContext >> HOOK_MON_KO_SHIFT) & 1) != 0; + } + + /// @notice Fresh signed in-flight damage embedded for an opted-in PreDamage callback. + function hookPreDamage(uint256 hookContext) internal pure returns (int32) { + return int32(uint32(hookContext >> HOOK_PRE_DAMAGE_SHIFT)); + } + /// @dev Kit-audit ruling for untargeted "the opposing active" effects (switch-in chips, /// KO-triggered debuffs...): the mirror of `ownSlot` on the opposing side, falling back /// to its partner when the mirror lane is empty; NO_SLOT when the opposing side is vacant. diff --git a/src/mons/aurox/GildedRecovery.sol b/src/mons/aurox/GildedRecovery.sol index 59af09dd..412e61c9 100644 --- a/src/mons/aurox/GildedRecovery.sol +++ b/src/mons/aurox/GildedRecovery.sol @@ -7,7 +7,6 @@ import "../../Enums.sol"; import {MoveMeta} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatusEffectLib} from "../../effects/status/StatusEffectLib.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; contract GildedRecovery is IMoveSet { @@ -31,32 +30,18 @@ contract GildedRecovery is IMoveSet { // extraData contains the mon index as raw uint16 uint256 targetMonIndex = uint256(extraData); - // Check if the target mon has a status effect - uint64 statusKey = StatusEffectLib.getKeyForMonIndex(attackerPlayerIndex, targetMonIndex); - uint192 statusFlag = engine.getGlobalKV(battleKey, statusKey); - - // If the mon has a status effect, remove it and heal - if (statusFlag != 0) { - // Find and remove the status effect - // Targeted lookup: engine scans for the status effect internally, no full-array build. - address statusEffectAddress = address(uint160(statusFlag)); - (bool exists, uint256 idx,) = - engine.getEffectData(battleKey, attackerPlayerIndex, targetMonIndex, statusEffectAddress); - if (exists) { - engine.removeEffect(attackerPlayerIndex, targetMonIndex, idx); - } + // Remove the target mon's status (any class); heal + grant stamina only on success + if (engine.clearMonStatus(attackerPlayerIndex, targetMonIndex, 0)) { // Give +1 stamina engine.updateMonState(attackerPlayerIndex, targetMonIndex, MonStateIndexName.Stamina, STAMINA_BONUS); // Heal 50% of max HP for self - int32 maxHp = int32( - engine.getMonValueForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp) - ); + (uint32 maxHpRaw, int32 currentHpDelta) = + engine.getMonHpState(battleKey, attackerPlayerIndex, attackerMonIndex); + int32 maxHp = int32(maxHpRaw); int32 healAmount = (maxHp * HEAL_PERCENT) / 100; // Don't overheal - int32 currentHpDelta = - engine.getMonStateForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp); if (currentHpDelta + healAmount > 0) { healAmount = -currentHpDelta; } diff --git a/src/mons/aurox/IronWall.sol b/src/mons/aurox/IronWall.sol index 3beda519..a40acdfa 100644 --- a/src/mons/aurox/IronWall.sol +++ b/src/mons/aurox/IronWall.sol @@ -8,6 +8,7 @@ import {IEngine} from "../../IEngine.sol"; import {MoveMeta} from "../../Structs.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; contract IronWall is IMoveSet, BasicEffect { @@ -38,12 +39,10 @@ contract IronWall is IMoveSet, BasicEffect { engine.addEffect(attackerPlayerIndex, attackerMonIndex, IEffect(address(this)), bytes32(0)); // Also, heal for INITIAL_HEAL_PERCENT - int32 maxHp = - int32(engine.getMonValueForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp)); + (uint32 maxHpRaw, int32 currentHpDelta) = engine.getMonHpState(battleKey, attackerPlayerIndex, attackerMonIndex); + int32 maxHp = int32(maxHpRaw); int32 healAmount = (maxHp * INITIAL_HEAL_PERCENT) / 100; // Prevent overhealing - int32 currentHpDelta = - engine.getMonStateForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp); if (currentHpDelta + healAmount > 0) { healAmount = -currentHpDelta; } @@ -82,18 +81,18 @@ contract IronWall is IMoveSet, BasicEffect { // IEffect implementation // Steps: OnMonSwitchOut, AfterDamage - function getStepsBitmap() external pure override returns (uint16) { - return 0x8060; + function getStepsBitmap() external pure override returns (uint32) { + return 0x00408060; // AfterDamage context | ALWAYS_APPLIES | lifecycle steps } function onAfterDamage( IEngine engine, - bytes32 battleKey, + bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, - uint256, + uint256 context, int32 damageDealt, uint256 ) external override returns (bytes32 updatedExtraData, bool removeAfterRun) { @@ -103,10 +102,7 @@ contract IronWall is IMoveSet, BasicEffect { // <= damageDealt/2 <= int32.max, so the cast back is safe. int32 healAmount = int32((int256(damageDealt) * HEAL_PERCENT) / 100); // Heal only if not KO'ed - if ( - healAmount > 0 - && engine.getMonStateForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.IsKnockedOut) == 0 - ) { + if (healAmount > 0 && !TargetLib.hookMonIsKnockedOut(context)) { engine.updateMonState(targetIndex, monIndex, MonStateIndexName.Hp, healAmount); } return (extraData, false); diff --git a/src/mons/aurox/UpOnly.sol b/src/mons/aurox/UpOnly.sol index 633026f2..2ce731f2 100644 --- a/src/mons/aurox/UpOnly.sol +++ b/src/mons/aurox/UpOnly.sol @@ -32,7 +32,7 @@ contract UpOnly is IAbility, BasicEffect { // IEffect implementation // Steps: AfterDamage - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8040; } diff --git a/src/mons/ekineki/SneakAttack.sol b/src/mons/ekineki/SneakAttack.sol index ad616761..83932644 100644 --- a/src/mons/ekineki/SneakAttack.sol +++ b/src/mons/ekineki/SneakAttack.sol @@ -40,11 +40,9 @@ contract SneakAttack is IMoveSet, BasicEffect { uint256 rng ) external { // Check if already used this switch-in (effect present = already used) - (EffectInstance[] memory effects,) = engine.getEffects(battleKey, attackerPlayerIndex, attackerMonIndex); - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - return; - } + (bool alreadyUsed,,) = engine.getEffectData(battleKey, attackerPlayerIndex, attackerMonIndex, address(this)); + if (alreadyUsed) { + return; } uint256 defenderPlayerIndex = (attackerPlayerIndex + 1) % 2; @@ -118,7 +116,7 @@ contract SneakAttack is IMoveSet, BasicEffect { // IEffect implementation — local effect that cleans up on switch-out // Steps: OnMonSwitchOut - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8020; } diff --git a/src/mons/embursa/HeatBeacon.sol b/src/mons/embursa/HeatBeacon.sol index 2302dd0a..69ee1b3c 100644 --- a/src/mons/embursa/HeatBeacon.sol +++ b/src/mons/embursa/HeatBeacon.sol @@ -2,22 +2,25 @@ pragma solidity ^0.8.0; +// @move-context: status-lanes + import "../../Constants.sol"; import "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {MoveMeta} from "../../Structs.sol"; import {IEffect} from "../../effects/IEffect.sol"; -import {StatusEffectLib} from "../../effects/status/StatusEffectLib.sol"; import {TargetLib} from "../../lib/TargetLib.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; import {HeatBeaconLib} from "./HeatBeaconLib.sol"; contract HeatBeacon is IMoveSet { IEffect immutable BURN_STATUS; + uint256 immutable BURN_CLASS; constructor(IEffect _BURN_STATUS) { BURN_STATUS = _BURN_STATUS; + BURN_CLASS = (uint256(_BURN_STATUS.getStepsBitmap()) >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK; } function name() public pure override returns (string memory) { @@ -26,7 +29,7 @@ contract HeatBeacon is IMoveSet { function move( IEngine engine, - bytes32 battleKey, + bytes32, uint256 attackerPlayerIndex, uint256 attackerMonIndex, uint256 targetBits, @@ -35,8 +38,7 @@ contract HeatBeacon is IMoveSet { uint256 ) external { // Only spread Burn to the opponent if Embursa is itself Burned. - uint64 selfStatusKey = StatusEffectLib.getKeyForMonIndex(attackerPlayerIndex, attackerMonIndex); - if (engine.getGlobalKV(battleKey, selfStatusKey) == uint192(uint160(address(BURN_STATUS)))) { + if (TargetLib.hookStatusClass(activesPacked, attackerPlayerIndex, attackerMonIndex) == BURN_CLASS) { uint256 targetSlot = TargetLib.lowestSlot(targetBits); if (targetSlot != NO_SLOT) { uint256 defenderPlayerIndex = TargetLib.sideOf(targetSlot); @@ -45,10 +47,7 @@ contract HeatBeacon is IMoveSet { } } - // Grant +1 priority to next turn's move (refresh if already set). - if (HeatBeaconLib._getPriorityBoost(engine, battleKey, attackerPlayerIndex) == 1) { - HeatBeaconLib._clearPriorityBoost(engine, attackerPlayerIndex); - } + // Grant +1 priority to next turn's move (idempotent refresh; consumed by the payoff move). HeatBeaconLib._setPriorityBoost(engine, attackerPlayerIndex); } diff --git a/src/mons/embursa/HoneyBribe.sol b/src/mons/embursa/HoneyBribe.sol index 774d9d05..fb857bea 100644 --- a/src/mons/embursa/HoneyBribe.sol +++ b/src/mons/embursa/HoneyBribe.sol @@ -57,11 +57,8 @@ contract HoneyBribe is IMoveSet { uint256 defenderMonIndex = TargetLib.activeAt(activesPacked, targetSlot); // Heal active mon by max HP / 2**bribeLevel uint256 bribeLevel = _getBribeLevel(engine, battleKey, attackerPlayerIndex, attackerMonIndex); - uint32 maxHp = - engine.getMonValueForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp); + (uint32 maxHp, int32 currentDamage) = engine.getMonHpState(battleKey, attackerPlayerIndex, attackerMonIndex); int32 healAmount = int32(uint32(maxHp / (DEFAULT_HEAL_DENOM * (2 ** bribeLevel)))); - int32 currentDamage = - engine.getMonStateForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp); if (currentDamage + healAmount > 0) { healAmount = -1 * currentDamage; } diff --git a/src/mons/embursa/Q5.sol b/src/mons/embursa/Q5.sol index 1188f75a..9e77bff2 100644 --- a/src/mons/embursa/Q5.sol +++ b/src/mons/embursa/Q5.sol @@ -104,7 +104,7 @@ contract Q5 is IMoveSet, BasicEffect { // Effect implementation // Steps: RoundStart - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8002; } diff --git a/src/mons/embursa/Tinderclaws.sol b/src/mons/embursa/Tinderclaws.sol index 83d27c85..136fe663 100644 --- a/src/mons/embursa/Tinderclaws.sol +++ b/src/mons/embursa/Tinderclaws.sol @@ -4,23 +4,33 @@ pragma solidity ^0.8.0; // @inline-ability: singleton-local -import {MOVE_INDEX_MASK, NO_OP_MOVE_INDEX, NO_SLOT, SWITCH_MOVE_INDEX} from "../../Constants.sol"; -import {MonStateIndexName, StatBoostFlag, StatBoostType} from "../../Enums.sol"; +import { + MOVE_INDEX_MASK, + NO_OP_MOVE_INDEX, + NO_SLOT, + STATUS_CLASS_MASK, + STATUS_CLASS_SHIFT, + SWITCH_MOVE_INDEX +} from "../../Constants.sol"; +import {EffectStep, MonStateIndexName, StatBoostFlag, StatBoostType} from "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; -import {EffectInstance, IEffect, MoveDecision, StatBoostToApply} from "../../Structs.sol"; +import {EffectInstance, IEffect, StatBoostToApply} from "../../Structs.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; -import {StatusEffectLib} from "../../effects/status/StatusEffectLib.sol"; +import {EffectCommandLib} from "../../effects/EffectCommandLib.sol"; +import {IEffectResolver} from "../../effects/IEffectResolver.sol"; import {TargetLib} from "../../lib/TargetLib.sol"; -contract Tinderclaws is IAbility, BasicEffect { +contract Tinderclaws is IAbility, BasicEffect, IEffectResolver { uint256 constant BURN_CHANCE = 3; // 1 in 3 chance uint8 constant SP_ATTACK_BOOST_PERCENT = 50; IEffect immutable BURN_STATUS; + uint256 immutable BURN_CLASS; constructor(IEffect _BURN_STATUS) { BURN_STATUS = _BURN_STATUS; + BURN_CLASS = (uint256(_BURN_STATUS.getStepsBitmap()) >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK; } function name() public pure override(IAbility, BasicEffect) returns (string memory) { @@ -39,14 +49,44 @@ contract Tinderclaws is IAbility, BasicEffect { } // Steps: RoundEnd, AfterMove - function getStepsBitmap() external pure override returns (uint16) { - return 0x8084; + function getStepsBitmap() external pure override returns (uint32) { + // Low 16 bits: lifecycle steps. High 16 bits: requested fresh-context steps. + // Bit 31 opts into the read-only command resolver; bits 18/23 request fresh hook context. + return 0x80848084; // resolver | RoundEnd+AfterMove context | ALWAYS_APPLIES | hooks + } + + function resolveEffect( + EffectStep step, + uint256 rng, + bytes32 extraData, + uint256 targetIndex, + uint256 monIndex, + uint256 hookContext + ) external view returns (bytes32 updatedExtraData, bool removeAfterRun, uint256 command) { + if (step == EffectStep.AfterMove) { + uint256 ownSlot = TargetLib.slotOfMon(hookContext, targetIndex, monIndex); + if (ownSlot == NO_SLOT) { + return (extraData, false, 0); + } + uint8 moveIndex = uint8(TargetLib.hookMoveWordAt(hookContext, ownSlot)) & MOVE_INDEX_MASK; + if (moveIndex == NO_OP_MOVE_INDEX) { + command = EffectCommandLib.clearStatus(targetIndex, monIndex, BURN_CLASS); + } else if (moveIndex != SWITCH_MOVE_INDEX) { + rng = uint256(keccak256(abi.encode(rng, targetIndex, monIndex, address(this)))); + if (rng % BURN_CHANCE == BURN_CHANCE - 1) { + command = EffectCommandLib.addEffect(targetIndex, monIndex, BURN_STATUS); + } + } + return (extraData, false, command); + } + + return (extraData, false, 0); } // extraData: 0 = no SpATK boost applied, 1 = SpATK boost applied function onAfterMove( IEngine engine, - bytes32 battleKey, + bytes32, uint256 rng, bytes32 extraData, uint256 targetIndex, @@ -57,13 +97,14 @@ contract Tinderclaws is IAbility, BasicEffect { if (ownSlot == NO_SLOT) { return (updatedExtraData, removeAfterRun); } - MoveDecision memory moveDecision = engine.getMoveDecisionForSlot(battleKey, targetIndex, ownSlot & 1); - // Unpack the move index from packedMoveIndex - uint8 moveIndex = moveDecision.packedMoveIndex & MOVE_INDEX_MASK; + // Engine embeds a fresh current-move lane immediately before every AfterMove callback. + // Reading it here avoids a warm external round-trip while remaining correct when an + // earlier effect rewrites the move during the same pass. + uint8 moveIndex = uint8(TargetLib.hookMoveWordAt(activesPacked, ownSlot)) & MOVE_INDEX_MASK; // If resting, remove burn if (moveIndex == NO_OP_MOVE_INDEX) { - _removeBurnIfPresent(engine, battleKey, targetIndex, monIndex); + engine.clearMonStatus(targetIndex, monIndex, BURN_CLASS); } // If used a move (not switch), 1/3 chance to self-burn else if (moveIndex != SWITCH_MOVE_INDEX) { @@ -82,14 +123,14 @@ contract Tinderclaws is IAbility, BasicEffect { function onRoundEnd( IEngine engine, - bytes32 battleKey, + bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, - uint256 + uint256 hookContext ) external override returns (bytes32 updatedExtraData, bool removeAfterRun) { - bool isBurned = _isBurned(engine, battleKey, targetIndex, monIndex); + bool isBurned = TargetLib.hookStatusClass(hookContext, targetIndex, monIndex) == BURN_CLASS; bool hasBoost = uint256(extraData) == 1; if (isBurned && !hasBoost) { @@ -110,25 +151,4 @@ contract Tinderclaws is IAbility, BasicEffect { return (extraData, false); } - - function _isBurned(IEngine engine, bytes32 battleKey, uint256 targetIndex, uint256 monIndex) - internal - view - returns (bool) - { - uint64 keyForMon = StatusEffectLib.getKeyForMonIndex(targetIndex, monIndex); - uint192 monStatusFlag = engine.getGlobalKV(battleKey, keyForMon); - return monStatusFlag == uint192(uint160(address(BURN_STATUS))); - } - - function _removeBurnIfPresent(IEngine engine, bytes32 battleKey, uint256 targetIndex, uint256 monIndex) internal { - (EffectInstance[] memory effects, uint256[] memory indices) = - engine.getEffects(battleKey, targetIndex, monIndex); - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(BURN_STATUS)) { - engine.removeEffect(targetIndex, monIndex, indices[i]); - return; - } - } - } } diff --git a/src/mons/ghouliath/GraveAffliction.sol b/src/mons/ghouliath/GraveAffliction.sol index c026e4f6..a36c6f13 100644 --- a/src/mons/ghouliath/GraveAffliction.sol +++ b/src/mons/ghouliath/GraveAffliction.sol @@ -2,12 +2,13 @@ pragma solidity ^0.8.0; +// @move-context: status-lanes + import "../../Constants.sol"; import "../../Enums.sol"; import {MoveMeta} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; -import {StatusEffectLib} from "../../effects/status/StatusEffectLib.sol"; import {TargetLib} from "../../lib/TargetLib.sol"; import {IMoveSet} from "../../moves/IMoveSet.sol"; @@ -36,10 +37,8 @@ contract GraveAffliction is IMoveSet { uint256 defenderPlayerIndex = TargetLib.sideOf(targetSlot); uint256 defenderMonIndex = TargetLib.activeAt(activesPacked, targetSlot); - // Only fires if the opposing mon currently has a status condition. The StatusEffect base sets - // this per-mon flag in onApply for every status (Sleep/Panic/Burn/Frostbite/Zap/Blessed). - uint64 statusKey = StatusEffectLib.getKeyForMonIndex(defenderPlayerIndex, defenderMonIndex); - if (engine.getGlobalKV(battleKey, statusKey) == 0) { + // Only fires if the opposing mon currently has a status condition (any class). + if (TargetLib.hookStatusClass(activesPacked, defenderPlayerIndex, defenderMonIndex) == 0) { return; } diff --git a/src/mons/ghouliath/RiseFromTheGrave.sol b/src/mons/ghouliath/RiseFromTheGrave.sol index 184c3832..688b8c14 100644 --- a/src/mons/ghouliath/RiseFromTheGrave.sol +++ b/src/mons/ghouliath/RiseFromTheGrave.sol @@ -7,6 +7,7 @@ import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; contract RiseFromTheGrave is IAbility, BasicEffect { uint64 public constant REVIVAL_DELAY = 3; @@ -36,18 +37,18 @@ contract RiseFromTheGrave is IAbility, BasicEffect { // IEffect implementation // Steps: RoundEnd, AfterDamage - function getStepsBitmap() external pure override returns (uint16) { - return 0x8044; + function getStepsBitmap() external pure override returns (uint32) { + return 0x00408044; // AfterDamage context | ALWAYS_APPLIES | lifecycle steps } function onAfterDamage( IEngine engine, - bytes32 battleKey, + bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, - uint256, + uint256 context, int32, uint256 ) external override returns (bytes32 updatedExtraData, bool removeAfterRun) { @@ -56,7 +57,7 @@ contract RiseFromTheGrave is IAbility, BasicEffect { and remove this effect (so we stop hooking into it on future applications) */ // If the mon is KO'd, add this effect to the global effects list and remove the mon effect - if (engine.getMonStateForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.IsKnockedOut) == 1) { + if (TargetLib.hookMonIsKnockedOut(context)) { uint64 v1 = REVIVAL_DELAY; uint64 v2 = uint64(targetIndex) & 0x3F; // player index (masked to 6 bits) uint64 v3 = uint64(monIndex) & 0x3F; // mon index (masked to 6 bits) diff --git a/src/mons/gorillax/Angery.sol b/src/mons/gorillax/Angery.sol index 68bce810..530ac196 100644 --- a/src/mons/gorillax/Angery.sol +++ b/src/mons/gorillax/Angery.sol @@ -34,7 +34,7 @@ contract Angery is IAbility, BasicEffect { // IEffect implementation // Steps: RoundEnd, AfterDamage - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8044; } diff --git a/src/mons/iblivion/Baselight.sol b/src/mons/iblivion/Baselight.sol index d94216aa..41e8b88b 100644 --- a/src/mons/iblivion/Baselight.sol +++ b/src/mons/iblivion/Baselight.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.0; -import {MonStateIndexName} from "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; /** * Baselight Ability for Iblivion @@ -85,23 +85,23 @@ contract Baselight is IAbility, BasicEffect { } // Steps: AfterDamage - function getStepsBitmap() external pure override returns (uint16) { - return 0x8040; + function getStepsBitmap() external pure override returns (uint32) { + return 0x00408040; // AfterDamage context | ALWAYS_APPLIES | AfterDamage } // Gain 1 Baselight per damage event (capped), but never from a lethal hit. function onAfterDamage( - IEngine engine, - bytes32 battleKey, + IEngine, + bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, - uint256, + uint256 context, int32, uint256 - ) external view override returns (bytes32 updatedExtraData, bool removeAfterRun) { - if (engine.getMonStateForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.IsKnockedOut) != 0) { + ) external pure override returns (bytes32 updatedExtraData, bool removeAfterRun) { + if (TargetLib.hookMonIsKnockedOut(context)) { return (extraData, false); } uint256 currentLevel = uint256(extraData); diff --git a/src/mons/iblivion/Loop.sol b/src/mons/iblivion/Loop.sol index 89ed7510..79ba592e 100644 --- a/src/mons/iblivion/Loop.sol +++ b/src/mons/iblivion/Loop.sol @@ -121,7 +121,7 @@ contract Loop is IMoveSet, BasicEffect { } // ALWAYS_APPLIES | OnMonSwitchOut (bit 5): the marker only listens for switch-out to re-arm - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8020; } diff --git a/src/mons/inutia/ChainExpansion.sol b/src/mons/inutia/ChainExpansion.sol index 1c76f930..d6404bee 100644 --- a/src/mons/inutia/ChainExpansion.sol +++ b/src/mons/inutia/ChainExpansion.sol @@ -43,12 +43,10 @@ contract ChainExpansion is IMoveSet, BasicEffect { uint16, uint256 ) external { - // Check if the ability is already applied globally - (EffectInstance[] memory effects,) = engine.getEffects(battleKey, 2, 2); - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - return; - } + // Skip if the ability is already applied globally (single instance by address) + (bool exists,,) = engine.getEffectData(battleKey, 2, 2, address(this)); + if (exists) { + return; } // Otherwise, add this effect globally engine.addEffect(2, attackerPlayerIndex, this, _encodeState(CHARGES, uint128(attackerPlayerIndex))); @@ -74,7 +72,7 @@ contract ChainExpansion is IMoveSet, BasicEffect { * Effect implementation */ // Steps: OnMonSwitchIn - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8010; } @@ -99,9 +97,8 @@ contract ChainExpansion is IMoveSet, BasicEffect { (uint256 chargesLeft, uint256 ownerIndex) = _decodeState(extraData); // If it's a friendly mon, then we heal (flat 1/8 of max HP) if (targetIndex == ownerIndex) { - int32 amtToHeal = - int32(engine.getMonValueForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp)) / HEAL_DENOM; - int32 damageReceived = engine.getMonStateForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp); + (uint32 maxHp, int32 damageReceived) = engine.getMonHpState(battleKey, targetIndex, monIndex); + int32 amtToHeal = int32(maxHp) / HEAL_DENOM; // Prevent overhealing if (amtToHeal > (-1 * damageReceived)) { amtToHeal = -1 * damageReceived; diff --git a/src/mons/inutia/HitAndDip.sol b/src/mons/inutia/HitAndDip.sol index 4242a31b..eec0132d 100644 --- a/src/mons/inutia/HitAndDip.sol +++ b/src/mons/inutia/HitAndDip.sol @@ -9,11 +9,14 @@ import {IEngine} from "../../IEngine.sol"; import {IEffect} from "../../effects/IEffect.sol"; import {TargetLib} from "../../lib/TargetLib.sol"; +import {IMoveResolver} from "../../moves/IMoveResolver.sol"; +import {MoveCommandLib} from "../../moves/MoveCommandLib.sol"; import {StandardAttack} from "../../moves/StandardAttack.sol"; import {ATTACK_PARAMS} from "../../moves/StandardAttackStructs.sol"; import {ITypeCalculator} from "../../types/ITypeCalculator.sol"; -contract HitAndDip is StandardAttack { +// @move-resolver +contract HitAndDip is StandardAttack, IMoveResolver { constructor(ITypeCalculator TYPE_CALCULATOR) StandardAttack( address(msg.sender), @@ -34,6 +37,39 @@ contract HitAndDip is StandardAttack { ) {} + function resolveMove( + uint32 continuation, + int32 priorResult, + uint256 attackerPlayerIndex, + uint256 attackerMonIndex, + uint256, + uint256 activesPacked, + uint16 extraData, + uint256 + ) external view returns (uint256 command, uint32 nextContinuation) { + if (continuation == 0) { + return ( + MoveCommandLib.attack( + uint32(basePower(bytes32(0))), + uint8(accuracy(bytes32(0))), + uint8(volatility(bytes32(0))), + moveType(IEngine(address(0)), bytes32(0)), + moveClass(IEngine(address(0)), bytes32(0)), + uint8(critRate(bytes32(0))), + uint8(effectAccuracy(bytes32(0))), + effect(bytes32(0)) + ), + 1 + ); + } + if (continuation == 1 && priorResult > 0) { + uint256 slot = TargetLib.slotOfMon(activesPacked, attackerPlayerIndex, attackerMonIndex); + if (slot != NO_SLOT) { + command = MoveCommandLib.switchMon(attackerPlayerIndex, slot & 1, uint256(extraData)); + } + } + } + function move( IEngine engine, bytes32 battleKey, diff --git a/src/mons/inutia/Initialize.sol b/src/mons/inutia/Initialize.sol index 93ea1df8..9ddc4cd8 100644 --- a/src/mons/inutia/Initialize.sol +++ b/src/mons/inutia/Initialize.sol @@ -79,7 +79,7 @@ contract Initialize is IMoveSet, BasicEffect { * Effect implementation */ // Steps: OnMonSwitchIn, OnMonSwitchOut - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8030; } diff --git a/src/mons/inutia/Interweaving.sol b/src/mons/inutia/Interweaving.sol index 0c31f3c0..c8673749 100644 --- a/src/mons/inutia/Interweaving.sol +++ b/src/mons/inutia/Interweaving.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.0; import {EMPTY_ACTIVE_LANE, NO_SLOT} from "../../Constants.sol"; import "../../Enums.sol"; import {IEngine} from "../../IEngine.sol"; -import {EffectInstance, StatBoostToApply} from "../../Structs.sol"; +import {StatBoostToApply} from "../../Structs.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; @@ -37,20 +37,16 @@ contract Interweaving is IAbility, BasicEffect { }); engine.addStatBoost(otherPlayerIndex, otherPlayerActiveMonIndex, statBoosts, StatBoostFlag.Temp); - // Check if the effect has already been set for this mon - (EffectInstance[] memory effects,) = engine.getEffects(battleKey, playerIndex, monIndex); - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == address(this)) { - return; - } + // Skip if the switch-out effect is already registered on this mon + (bool exists,,) = engine.getEffectData(battleKey, playerIndex, monIndex, address(this)); + if (exists) { + return; } - // Otherwise, add this effect to the mon when it switches in - // This way we can trigger on switch out engine.addEffect(playerIndex, monIndex, IEffect(address(this)), bytes32(0)); } // Steps: OnApply, OnMonSwitchOut - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8021; } diff --git a/src/mons/malalien/ActusReus.sol b/src/mons/malalien/ActusReus.sol index 4eebafcd..e1a2e67d 100644 --- a/src/mons/malalien/ActusReus.sol +++ b/src/mons/malalien/ActusReus.sol @@ -33,7 +33,7 @@ contract ActusReus is IAbility, BasicEffect { } // Steps: AfterDamage, AfterMove - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x80C0; } diff --git a/src/mons/nirvamma/Adaptor.sol b/src/mons/nirvamma/Adaptor.sol index 75d4080b..30536786 100644 --- a/src/mons/nirvamma/Adaptor.sol +++ b/src/mons/nirvamma/Adaptor.sol @@ -10,6 +10,7 @@ import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; /// @dev Source identity: low 160 bits = msg.sender for external dealDamage callers; full packed /// move slot for the inline-StandardAttack path. Two attackers wielding the same @@ -33,18 +34,23 @@ contract Adaptor is IAbility, BasicEffect { engine.addEffect(playerIndex, monIndex, IEffect(address(this)), bytes32(0)); } - // Steps: AfterDamage, PreDamage - function getStepsBitmap() external pure override returns (uint16) { - return 0x240; + // Steps: AfterDamage, PreDamage, ALWAYS_APPLIES (0x8000); fresh PreDamage context (0x0200_0000) + function getStepsBitmap() external pure override returns (uint32) { + return 0x02008240; } - function onPreDamage(IEngine engine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256 source) - external - override - returns (bytes32, bool) - { + function onPreDamage( + IEngine engine, + bytes32, + uint256, + bytes32 extraData, + uint256, + uint256, + uint256 hookContext, + uint256 source + ) external override returns (bytes32, bool) { if (extraData == bytes32(source)) { - int32 running = engine.getPreDamage(); + int32 running = TargetLib.hookPreDamage(hookContext); engine.setPreDamage(running / DAMAGE_DENOM); } return (extraData, false); diff --git a/src/mons/nirvamma/HardReset.sol b/src/mons/nirvamma/HardReset.sol index d048d439..967abdde 100644 --- a/src/mons/nirvamma/HardReset.sol +++ b/src/mons/nirvamma/HardReset.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {DEFAULT_PRIORITY, MOVE_INDEX_MASK, NO_OP_MOVE_INDEX, NO_SLOT} from "../../Constants.sol"; import {MonStateIndexName, MoveClass, Type} from "../../Enums.sol"; -import {EffectInstance, MoveDecision, MoveMeta} from "../../Structs.sol"; +import {EffectInstance, MoveMeta} from "../../Structs.sol"; import {IEngine} from "../../IEngine.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; @@ -82,9 +82,10 @@ contract HardReset is IMoveSet, BasicEffect { }); } - // Steps: AfterMove - function getStepsBitmap() external pure override returns (uint16) { - return 0x80; + // Steps: AfterMove, ALWAYS_APPLIES (0x8000). Request fresh AfterMove context in the + // high 16-bit metadata lane so rest detection does not re-enter Engine. + function getStepsBitmap() external pure override returns (uint32) { + return 0x00808080; } function onAfterMove( @@ -100,8 +101,8 @@ contract HardReset is IMoveSet, BasicEffect { if (restSlot == NO_SLOT) { return (extraData, false); } - MoveDecision memory dec = engine.getMoveDecisionForSlot(battleKey, targetIndex, restSlot & 1); - if ((dec.packedMoveIndex & MOVE_INDEX_MASK) != NO_OP_MOVE_INDEX) { + uint8 moveIndex = uint8(TargetLib.hookMoveWordAt(activesPacked, restSlot)) & MOVE_INDEX_MASK; + if (moveIndex != NO_OP_MOVE_INDEX) { return (extraData, false); } @@ -117,9 +118,9 @@ contract HardReset is IMoveSet, BasicEffect { } else if (cur < 0) { engine.updateMonState(targetIndex, monIndex, MonStateIndexName.Stamina, 1); } - int32 maxHp = int32(engine.getMonValueForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp)); + (uint32 maxHpRaw, int32 curHp) = engine.getMonHpState(battleKey, targetIndex, monIndex); + int32 maxHp = int32(maxHpRaw); int32 healAmt = maxHp / HP_DENOM; - int32 curHp = engine.getMonStateForBattle(battleKey, targetIndex, monIndex, MonStateIndexName.Hp); if (curHp + healAmt > 0) { healAmt = -curHp; } diff --git a/src/mons/pengym/DeepFreeze.sol b/src/mons/pengym/DeepFreeze.sol index 4de473a2..2d6086c0 100644 --- a/src/mons/pengym/DeepFreeze.sol +++ b/src/mons/pengym/DeepFreeze.sol @@ -17,10 +17,12 @@ contract DeepFreeze is IMoveSet { uint32 public constant BASE_POWER = 90; IEffect immutable FROSTBITE; + uint256 immutable FROSTBITE_CLASS; ITypeCalculator immutable TYPE_CALCULATOR; constructor(ITypeCalculator _TYPE_CALCULATOR, IEffect _FROSTBITE_STATUS) { FROSTBITE = _FROSTBITE_STATUS; + FROSTBITE_CLASS = (uint256(_FROSTBITE_STATUS.getStepsBitmap()) >> STATUS_CLASS_SHIFT) & STATUS_CLASS_MASK; TYPE_CALCULATOR = _TYPE_CALCULATOR; } @@ -28,16 +30,6 @@ contract DeepFreeze is IMoveSet { return "Deep Freeze"; } - function _frostbiteExists(IEngine engine, bytes32 battleKey, uint256 targetIndex, uint256 monIndex) - internal - view - returns (int32) - { - // Targeted lookup: engine scans for FROSTBITE internally, no full-array build. - (bool exists, uint256 idx,) = engine.getEffectData(battleKey, targetIndex, monIndex, address(FROSTBITE)); - return exists ? int32(int256(idx)) : int32(-1); - } - function move( IEngine engine, bytes32 battleKey, @@ -55,10 +47,8 @@ contract DeepFreeze is IMoveSet { uint256 otherPlayerIndex = TargetLib.sideOf(targetSlot); uint256 defenderMonIndex = TargetLib.activeAt(activesPacked, targetSlot); uint32 damageToDeal = BASE_POWER; - int32 frostbiteIndex = _frostbiteExists(engine, battleKey, otherPlayerIndex, defenderMonIndex); // Remove frostbite if it exists, and double the damage dealt - if (frostbiteIndex != -1) { - engine.removeEffect(otherPlayerIndex, defenderMonIndex, uint256(uint32(frostbiteIndex))); + if (engine.clearMonStatus(otherPlayerIndex, defenderMonIndex, FROSTBITE_CLASS)) { damageToDeal = damageToDeal * 2; } // Deal damage diff --git a/src/mons/pengym/PostWorkout.sol b/src/mons/pengym/PostWorkout.sol index 40548f95..7618de61 100644 --- a/src/mons/pengym/PostWorkout.sol +++ b/src/mons/pengym/PostWorkout.sol @@ -11,8 +11,6 @@ import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; -import {StatusEffectLib} from "../../effects/status/StatusEffectLib.sol"; - contract PostWorkout is IAbility, BasicEffect { function name() public pure override(IAbility, BasicEffect) returns (string memory) { return "Post-Workout"; @@ -30,39 +28,21 @@ contract PostWorkout is IAbility, BasicEffect { } // Steps: OnMonSwitchOut - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8020; } function onMonSwitchOut( IEngine engine, - bytes32 battleKey, + bytes32, uint256, bytes32, uint256 targetIndex, uint256 monIndex, uint256 ) external override returns (bytes32 updatedExtraData, bool removeAfterRun) { - uint64 keyForMon = StatusEffectLib.getKeyForMonIndex(targetIndex, monIndex); - uint192 statusAddress = engine.getGlobalKV(battleKey, keyForMon); - - // Check if a status exists - if (statusAddress != 0) { - IEffect statusEffect = IEffect(address(uint160(statusAddress))); - - // Get the index of the effect and remove it - uint256 effectIndex; - (EffectInstance[] memory effects, uint256[] memory indices) = - engine.getEffects(battleKey, targetIndex, monIndex); - for (uint256 i; i < effects.length; i++) { - if (address(effects[i].effect) == address(statusEffect)) { - effectIndex = indices[i]; - break; - } - } - engine.removeEffect(targetIndex, monIndex, effectIndex); - - // Boost stamina by 1 + // Clear any status (one per mon); on success boost stamina by 1 + if (engine.clearMonStatus(targetIndex, monIndex, 0)) { engine.updateMonState(targetIndex, monIndex, MonStateIndexName.Stamina, 1); } return (bytes32(0), false); diff --git a/src/mons/sofabbi/CarrotHarvest.sol b/src/mons/sofabbi/CarrotHarvest.sol index 182d14ec..a38fc602 100644 --- a/src/mons/sofabbi/CarrotHarvest.sol +++ b/src/mons/sofabbi/CarrotHarvest.sol @@ -35,7 +35,7 @@ contract CarrotHarvest is IAbility, BasicEffect { } // Steps: RoundEnd - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8004; } diff --git a/src/mons/sofabbi/SnackBreak.sol b/src/mons/sofabbi/SnackBreak.sol index 1734bab3..a18a36a4 100644 --- a/src/mons/sofabbi/SnackBreak.sol +++ b/src/mons/sofabbi/SnackBreak.sol @@ -47,13 +47,10 @@ contract SnackBreak is IMoveSet { uint256 ) external { uint256 snackLevel = _getSnackLevel(engine, battleKey, attackerPlayerIndex, attackerMonIndex); - uint32 maxHp = - engine.getMonValueForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp); + (uint32 maxHp, int32 currentDamage) = engine.getMonHpState(battleKey, attackerPlayerIndex, attackerMonIndex); // Heal active mon by max HP / 2**snackLevel int32 healAmount = int32(uint32(maxHp / (DEFAULT_HEAL_DENOM * (2 ** snackLevel)))); - int32 currentDamage = - engine.getMonStateForBattle(battleKey, attackerPlayerIndex, attackerMonIndex, MonStateIndexName.Hp); if (currentDamage + healAmount > 0) { healAmount = -1 * currentDamage; } diff --git a/src/mons/xmon/Dreamcatcher.sol b/src/mons/xmon/Dreamcatcher.sol index 0bad683f..d0087fdf 100644 --- a/src/mons/xmon/Dreamcatcher.sol +++ b/src/mons/xmon/Dreamcatcher.sol @@ -11,6 +11,7 @@ import {IEngine} from "../../IEngine.sol"; import {IAbility} from "../../abilities/IAbility.sol"; import {BasicEffect} from "../../effects/BasicEffect.sol"; import {IEffect} from "../../effects/IEffect.sol"; +import {TargetLib} from "../../lib/TargetLib.sol"; contract Dreamcatcher is IAbility, BasicEffect { int32 public constant HEAL_DENOM = 16; @@ -31,28 +32,28 @@ contract Dreamcatcher is IAbility, BasicEffect { } // Steps: OnUpdateMonState - function getStepsBitmap() external pure override returns (uint16) { - return 0x8100; + function getStepsBitmap() external pure override returns (uint32) { + return 0x01008100; // OnUpdateMonState context | ALWAYS_APPLIES | OnUpdateMonState } function onUpdateMonState( IEngine engine, - bytes32 battleKey, + bytes32, uint256, bytes32 extraData, uint256 playerIndex, uint256 monIndex, - uint256, + uint256 context, MonStateIndexName stateVarIndex, int32 valueToAdd ) external override returns (bytes32, bool) { // Only trigger if Stamina is being increased (positive valueToAdd) if (stateVarIndex == MonStateIndexName.Stamina && valueToAdd > 0) { - uint32 maxHp = engine.getMonValueForBattle(battleKey, playerIndex, monIndex, MonStateIndexName.Hp); + uint32 maxHp = TargetLib.hookMonMaxHp(context); int32 healAmount = int32(uint32(maxHp)) / HEAL_DENOM; // Prevent overhealing - int32 existingHpDelta = engine.getMonStateForBattle(battleKey, playerIndex, monIndex, MonStateIndexName.Hp); + int32 existingHpDelta = TargetLib.hookMonHpDelta(context); if (existingHpDelta + healAmount > 0) { healAmount = 0 - existingHpDelta; } diff --git a/src/mons/xmon/InvokeTaboo.sol b/src/mons/xmon/InvokeTaboo.sol index 73fdad04..9f68dd5b 100644 --- a/src/mons/xmon/InvokeTaboo.sol +++ b/src/mons/xmon/InvokeTaboo.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; -import {ALWAYS_APPLIES_BIT, DEFAULT_PRIORITY, MOVE_INDEX_MASK, NO_SLOT, SWITCH_MOVE_INDEX} from "../../Constants.sol"; +import {DEFAULT_PRIORITY, MOVE_INDEX_MASK, NO_SLOT, SWITCH_MOVE_INDEX} from "../../Constants.sol"; import {MoveClass, Type} from "../../Enums.sol"; import {MoveDecision, MoveMeta} from "../../Structs.sol"; @@ -74,14 +74,15 @@ contract InvokeTaboo is IMoveSet, BasicEffect { return MoveClass.Other; } - // Steps: OnMonSwitchOut (0x20), AfterMove (0x80), ALWAYS_APPLIES (0x8000) - function getStepsBitmap() external pure override returns (uint16) { - return ALWAYS_APPLIES_BIT | 0x00A0; + // Steps: OnMonSwitchOut (0x20), AfterMove (0x80), ALWAYS_APPLIES (0x8000). + // Request fresh AfterMove context in the high 16-bit metadata lane. + function getStepsBitmap() external pure override returns (uint32) { + return 0x008080A0; } function onAfterMove( IEngine engine, - bytes32 battleKey, + bytes32, uint256, bytes32 extraData, uint256 targetIndex, @@ -92,8 +93,7 @@ contract InvokeTaboo is IMoveSet, BasicEffect { if (ownSlot == NO_SLOT) { return (extraData, false); } - MoveDecision memory moveDecision = engine.getMoveDecisionForSlot(battleKey, targetIndex, ownSlot & 1); - uint8 moveIndex = moveDecision.packedMoveIndex & MOVE_INDEX_MASK; + uint8 moveIndex = uint8(TargetLib.hookMoveWordAt(activesPacked, ownSlot)) & MOVE_INDEX_MASK; uint8 tabooMoveIndex = uint8(uint256(extraData)); if (moveIndex == tabooMoveIndex) { diff --git a/src/mons/xmon/OldVengeance.sol b/src/mons/xmon/OldVengeance.sol index 29159cd4..64794dba 100644 --- a/src/mons/xmon/OldVengeance.sol +++ b/src/mons/xmon/OldVengeance.sol @@ -96,7 +96,7 @@ contract OldVengeance is IMoveSet, BasicEffect { } // Steps: RoundEnd (0x04), OnMonSwitchOut (0x20), ALWAYS_APPLIES (0x8000) - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8024; } diff --git a/src/mons/xmon/Somniphobia.sol b/src/mons/xmon/Somniphobia.sol index e5f47e43..c6f981c4 100644 --- a/src/mons/xmon/Somniphobia.sol +++ b/src/mons/xmon/Somniphobia.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; -import {ALWAYS_APPLIES_BIT, DEFAULT_PRIORITY, EMPTY_ACTIVE_LANE} from "../../Constants.sol"; +import {DEFAULT_PRIORITY, EMPTY_ACTIVE_LANE} from "../../Constants.sol"; import {MonStateIndexName, MoveClass, Type} from "../../Enums.sol"; import {MoveMeta} from "../../Structs.sol"; @@ -85,8 +85,8 @@ contract Somniphobia is IMoveSet, BasicEffect { } // Steps: RoundEnd (0x04), OnMonSwitchIn (0x10), OnMonSwitchOut (0x20), OnUpdateMonState (0x100), ALWAYS_APPLIES (0x8000) - function getStepsBitmap() external pure override returns (uint16) { - return ALWAYS_APPLIES_BIT | 0x0134; + function getStepsBitmap() external pure override returns (uint32) { + return 0x01008134; // OnUpdateMonState context | ALWAYS_APPLIES | lifecycle steps } function onUpdateMonState( @@ -96,7 +96,7 @@ contract Somniphobia is IMoveSet, BasicEffect { bytes32 extraData, uint256 playerIndex, uint256 monIndex, - uint256, + uint256 context, MonStateIndexName stateVarIndex, int32 valueToAdd ) external override returns (bytes32, bool) { @@ -105,7 +105,7 @@ contract Somniphobia is IMoveSet, BasicEffect { uint256 lane = exists ? _lane(data, playerIndex ^ 1) : 0; if (lane != 0) { int32 stack = int32(uint32(lane >> 8)); - uint32 maxHp = engine.getMonValueForBattle(battleKey, playerIndex, monIndex, MonStateIndexName.Hp); + uint32 maxHp = TargetLib.hookMonMaxHp(context); int32 damage = int32(uint32(maxHp)) / DAMAGE_DENOM * stack; if (damage > 0) { engine.dealDamage(playerIndex, monIndex, damage); diff --git a/src/moves/IMoveResolver.sol b/src/moves/IMoveResolver.sol new file mode 100644 index 00000000..a9e19607 --- /dev/null +++ b/src/moves/IMoveResolver.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +/// @notice Prototype continuation resolver for external moves. +interface IMoveResolver { + function resolveMove( + uint32 continuation, + int32 priorResult, + uint256 attackerPlayerIndex, + uint256 attackerMonIndex, + uint256 targetBits, + uint256 activesPacked, + uint16 extraData, + uint256 rng + ) external view returns (uint256 command, uint32 nextContinuation); +} + diff --git a/src/moves/MoveCommandLib.sol b/src/moves/MoveCommandLib.sol new file mode 100644 index 00000000..b0274887 --- /dev/null +++ b/src/moves/MoveCommandLib.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {MoveClass, Type} from "../Enums.sol"; +import {IEffect} from "../effects/IEffect.sol"; + +library MoveCommandLib { + uint256 internal constant OP_ATTACK = 1; + uint256 internal constant OP_SWITCH = 2; + + function attack( + uint32 basePower, + uint8 accuracy, + uint8 volatility, + Type moveType, + MoveClass moveClass, + uint8 critRate, + uint8 effectAccuracy, + IEffect effect + ) internal pure returns (uint256) { + return OP_ATTACK | (uint256(basePower) << 8) | (uint256(accuracy) << 40) | (uint256(volatility) << 48) + | (uint256(uint8(moveType)) << 56) | (uint256(uint8(moveClass)) << 64) | (uint256(critRate) << 72) + | (uint256(effectAccuracy) << 80) | (uint256(uint160(address(effect))) << 88); + } + + function switchMon(uint256 playerIndex, uint256 slotIndex, uint256 monIndex) internal pure returns (uint256) { + return OP_SWITCH | (playerIndex << 8) | (slotIndex << 16) | (monIndex << 24); + } +} + diff --git a/test/DoublesGasBenchmark.t.sol b/test/DoublesGasBenchmark.t.sol index 0424036b..310dfb0d 100644 --- a/test/DoublesGasBenchmark.t.sol +++ b/test/DoublesGasBenchmark.t.sol @@ -2,12 +2,14 @@ pragma solidity ^0.8.0; import "../lib/forge-std/src/Test.sol"; +import {Vm} from "../lib/forge-std/src/Vm.sol"; import "../src/Constants.sol"; import "../src/Enums.sol"; import "../src/Structs.sol"; import {Engine} from "../src/Engine.sol"; +import {IEngine} from "../src/IEngine.sol"; import {IRuleset} from "../src/IRuleset.sol"; import {IEffect} from "../src/effects/IEffect.sol"; import {BurnStatus} from "../src/effects/status/BurnStatus.sol"; @@ -51,6 +53,51 @@ contract DoublesGasBenchmark is BatchHelper, GasMeasure { Tally private _acc; uint256 private _accGas; + function _snapshotEffectCensus(string memory name, Vm.AccountAccess[] memory accesses, bytes32 storageKey) private { + EffectStorageTally memory effectStorage = _effectStorageTally(accesses, address(engine), storageKey); + uint256 roundStartCalls = _countCalls(accesses, address(engine), address(0), IEffect.onRoundStart.selector); + uint256 roundEndCalls = _countCalls(accesses, address(engine), address(0), IEffect.onRoundEnd.selector); + uint256 afterMoveCalls = _countCalls(accesses, address(engine), address(0), IEffect.onAfterMove.selector); + uint256 preDamageCalls = _countCalls(accesses, address(engine), address(0), IEffect.onPreDamage.selector); + uint256 afterDamageCalls = _countCalls(accesses, address(engine), address(0), IEffect.onAfterDamage.selector); + uint256 updateStateCalls = _countCalls(accesses, address(engine), address(0), IEffect.onUpdateMonState.selector); + uint256 moveDecisionCallbacks = + _countCalls(accesses, address(0), address(engine), IEngine.getMoveDecisionForSlot.selector); + uint256 lifecycleCalls = + roundStartCalls + roundEndCalls + afterMoveCalls + preDamageCalls + afterDamageCalls + updateStateCalls; + uint256 nonHookHeaderReadUpperBound = + effectStorage.headerReads > lifecycleCalls ? effectStorage.headerReads - lifecycleCalls : 0; + + vm.snapshotValue(string.concat(name, "_effectHeaderReads"), effectStorage.headerReads); + vm.snapshotValue(string.concat(name, "_effectDataReads"), effectStorage.dataReads); + vm.snapshotValue(string.concat(name, "_effectHeaderWrites"), effectStorage.headerWrites); + vm.snapshotValue(string.concat(name, "_effectDataWrites"), effectStorage.dataWrites); + vm.snapshotValue(string.concat(name, "_roundStartCalls"), roundStartCalls); + vm.snapshotValue(string.concat(name, "_roundEndCalls"), roundEndCalls); + vm.snapshotValue(string.concat(name, "_afterMoveCalls"), afterMoveCalls); + vm.snapshotValue(string.concat(name, "_preDamageCalls"), preDamageCalls); + vm.snapshotValue(string.concat(name, "_afterDamageCalls"), afterDamageCalls); + vm.snapshotValue(string.concat(name, "_updateStateCalls"), updateStateCalls); + vm.snapshotValue(string.concat(name, "_moveDecisionCallbacks"), moveDecisionCallbacks); + vm.snapshotValue(string.concat(name, "_lifecycleCalls"), lifecycleCalls); + vm.snapshotValue(string.concat(name, "_nonHookHeaderReadUpperBound"), nonHookHeaderReadUpperBound); + + console.log( + string.concat(name, " effect header/data reads:"), effectStorage.headerReads, effectStorage.dataReads + ); + console.log( + string.concat(name, " RoundStart/RoundEnd/AfterMove:"), roundStartCalls, roundEndCalls, afterMoveCalls + ); + console.log( + string.concat(name, " PreDamage/AfterDamage/UpdateState:"), + preDamageCalls, + afterDamageCalls, + updateStateCalls + ); + console.log(string.concat(name, " moveDecision callbacks:"), moveDecisionCallbacks); + console.log(string.concat(name, " non-hook header read upper bound:"), nonHookHeaderReadUpperBound); + } + function setUp() public { p0 = vm.addr(P0_PK); p1 = vm.addr(P1_PK); @@ -173,6 +220,7 @@ contract DoublesGasBenchmark is BatchHelper, GasMeasure { function test_doublesBatchedReplayGas() public { engine = new Engine(GAME_MONS_PER_TEAM, GAME_MOVES_PER_MON); bytes32 battleKey = _startDoubles(address(this)); + bytes32 storageKey = engine.getStorageKey(battleKey); uint256[] memory entries = new uint256[](24); for (uint64 t = 0; t < 12; t++) { @@ -185,9 +233,11 @@ contract DoublesGasBenchmark is BatchHelper, GasMeasure { uint256 g0 = gasleft(); engine.executeBatchedSlotTurns(battleKey, entries); _accGas += g0 - gasleft(); - _acc = _addTally(_acc, _tally(vm.stopAndReturnStateDiff())); + Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); + _acc = _addTally(_acc, _tally(accesses)); engine.resetCallContext(); _endMeasure("Doubles_Batch12"); + _snapshotEffectCensus("Doubles_Batch12", accesses, storageKey); assertEq(engine.getTurnIdForBattleState(battleKey), 12, "all twelve turns executed"); } @@ -271,6 +321,7 @@ contract DoublesGasBenchmark is BatchHelper, GasMeasure { battle.ruleset = IRuleset(INLINE_STAMINA_REGEN_RULESET); (bytes32 battleKey,) = engine.computeBattleKey(p0, p1); engine.startBattleWithMode(battle, BATTLE_MODE_DOUBLES); + bytes32 storageKey = engine.getStorageKey(battleKey); vm.warp(vm.getBlockTimestamp() + 1); // T0 send-ins; T1 burn B0 + siphon B1 while B rests; T2-T9 siphon every turn while @@ -297,13 +348,80 @@ contract DoublesGasBenchmark is BatchHelper, GasMeasure { uint256 g0 = gasleft(); engine.executeBatchedSlotTurns(battleKey, entries); _accGas += g0 - gasleft(); - _acc = _addTally(_acc, _tally(vm.stopAndReturnStateDiff())); + Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); + _acc = _addTally(_acc, _tally(accesses)); engine.resetCallContext(); _endMeasure("Doubles_KitBatch10"); + _snapshotEffectCensus("Doubles_KitBatch10", accesses, storageKey); assertEq(engine.getTurnIdForBattleState(battleKey), 10, "all ten turns executed"); } + function _tailEntries(uint256 n, uint256 shape) private pure returns (uint256[] memory entries) { + entries = new uint256[](n * 2); + for (uint256 t; t < n; t++) { + uint104 s0 = uint104(10_000 + 2 * t); + uint104 s1 = uint104(10_001 + 2 * t); + if (shape == 0) { + entries[t * 2] = sideWord(NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, s0); + entries[t * 2 + 1] = sideWord(NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, s1); + } else if (shape == 1) { + entries[t * 2] = sideWord(0, targetBits(2), NO_OP_MOVE_INDEX, 0, s0); + entries[t * 2 + 1] = sideWord(NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, s1); + } else { + entries[t * 2] = sideWord(0, targetBits(2), 0, targetBits(3), s0); + entries[t * 2 + 1] = sideWord(0, targetBits(0), 0, targetBits(1), s1); + } + } + } + + function _measureDoublesTail(uint256 shape, uint256 n) private returns (uint256 rawGas) { + bytes32 battleKey = _startDoubles(address(this)); + (uint256 side0, uint256 side1) = _scriptSides(0); + uint256[] memory sendIns = new uint256[](2); + sendIns[0] = side0; + sendIns[1] = side1; + engine.executeBatchedSlotTurns(battleKey, sendIns); + engine.resetCallContext(); + + uint256[] memory entries = _tailEntries(n, shape); + vm.cool(address(engine)); + uint256 g0 = gasleft(); + engine.executeBatchedSlotTurns(battleKey, entries); + rawGas = g0 - gasleft(); + engine.resetCallContext(); + vm.prank(p0); + engine.forfeit(battleKey); + } + + /// @notice Differential 2-slot pipeline matrix on the same recycled storage key and teams. + /// The tails vary only action shape: four rests, one attack, or four attacks. + function test_doublesTurnFloorMatrix() public { + engine = new Engine(GAME_MONS_PER_TEAM, GAME_MOVES_PER_MON); + + // Seed and free the one-key pool so every measured scenario is reused-key steady state. + bytes32 warmup = _startDoubles(address(this)); + vm.prank(p0); + engine.forfeit(warmup); + + uint256 n = 8; + uint256 allRest = _measureDoublesTail(0, n); + uint256 oneAttack = _measureDoublesTail(1, n); + uint256 allAttack = _measureDoublesTail(2, n); + + vm.snapshotValue("DoublesFloor_AllRest8_rawGas", allRest); + vm.snapshotValue("DoublesFloor_OneAttack8_rawGas", oneAttack); + vm.snapshotValue("DoublesFloor_AllAttack8_rawGas", allAttack); + vm.snapshotValue("DoublesFloor_AllRest_perTurn", allRest / n); + vm.snapshotValue("DoublesFloor_OneAttack_perTurn", oneAttack / n); + vm.snapshotValue("DoublesFloor_AllAttack_perTurn", allAttack / n); + + console.log("Doubles floor matrix, 8-turn tails:"); + console.log(" all rest total/per turn:", allRest, allRest / n); + console.log(" one attack total/per turn:", oneAttack, oneAttack / n); + console.log(" all attack total/per turn:", allAttack, allAttack / n); + } + // ----- GasMeasure plumbing (mirrors ProdPvPGasBenchmark) ----- function _beginMeasure() internal { diff --git a/test/EngineOptimizationTest.sol b/test/EngineOptimizationTest.sol index ee1b4c44..c0790b25 100644 --- a/test/EngineOptimizationTest.sol +++ b/test/EngineOptimizationTest.sol @@ -38,8 +38,9 @@ contract KOOpponentOnSwitchAbility is IAbility { function activateOnSwitch(IEngine engine, bytes32 battleKey, uint256 playerIndex, uint256) external { uint256 opponentIndex = 1 - playerIndex; - uint256[] memory activeMons = engine.getActiveMonIndexForBattleState(battleKey); - engine.dealDamage(opponentIndex, activeMons[opponentIndex], 1000); + BattleContext memory ctx = engine.getBattleContext(battleKey); + uint256 oppActive = opponentIndex == 0 ? ctx.p0ActiveMonIndex : ctx.p1ActiveMonIndex; + engine.dealDamage(opponentIndex, oppActive, 1000); } } @@ -392,8 +393,8 @@ contract EngineOptimizationTest is Test, BattleHelper { _executeSinglePlayerMoveAndReset(testEngine, signedManager, battleKey, BOB, uint16(1)); - uint256[] memory activeMons = testEngine.getActiveMonIndexForBattleState(battleKey); - assertEq(activeMons[1], 1, "P1 should switch to mon 1"); + BattleContext memory activeMons = testEngine.getBattleContext(battleKey); + assertEq(activeMons.p1ActiveMonIndex, 1, "P1 should switch to mon 1"); assertEq( uint256(testEngine.getBattleContext(battleKey).playerSwitchForTurnFlag), 2, @@ -452,8 +453,8 @@ contract EngineOptimizationTest is Test, BattleHelper { signedManager.revealMove(battleKey, SWITCH_MOVE_INDEX, uint104(0), uint16(1), true); testEngine.resetCallContext(); - uint256[] memory activeMons = testEngine.getActiveMonIndexForBattleState(battleKey); - assertEq(activeMons[1], 1, "P1 should switch through normal reveal fallback"); + BattleContext memory activeMons = testEngine.getBattleContext(battleKey); + assertEq(activeMons.p1ActiveMonIndex, 1, "P1 should switch through normal reveal fallback"); assertEq( uint256(testEngine.getBattleContext(battleKey).playerSwitchForTurnFlag), 2, diff --git a/test/EngineTest.sol b/test/EngineTest.sol index 21a73a56..73d0b5df 100644 --- a/test/EngineTest.sol +++ b/test/EngineTest.sol @@ -29,6 +29,7 @@ import {GlobalEffectAttack} from "./mocks/GlobalEffectAttack.sol"; import {InstantDeathEffect} from "./mocks/InstantDeathEffect.sol"; import {InstantDeathOnSwitchInEffect} from "./mocks/InstantDeathOnSwitchInEffect.sol"; import {MockRandomnessOracle} from "./mocks/MockRandomnessOracle.sol"; +import {RawDamageMove} from "./mocks/RawDamageMove.sol"; import {SelfSwitchAndDamageMove} from "./mocks/SelfSwitchAndDamageMove.sol"; import {IEngineHook} from "../src/IEngineHook.sol"; @@ -36,6 +37,7 @@ import {IEngineHook} from "../src/IEngineHook.sol"; import {OneTurnStatBoost} from "./mocks/OneTurnStatBoost.sol"; import {SingleInstanceEffect} from "./mocks/SingleInstanceEffect.sol"; import {SkipTurnMove} from "./mocks/SkipTurnMove.sol"; +import {StaminaGainKOEffect} from "./mocks/StaminaGainKOEffect.sol"; import {TempStatBoostEffect} from "./mocks/TempStatBoostEffect.sol"; import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; @@ -43,7 +45,14 @@ import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; import {BattleHelper} from "./abstract/BattleHelper.sol"; import {DummyStatus} from "./mocks/DummyStatus.sol"; import {EditEffectAttack} from "./mocks/EditEffectAttack.sol"; +import {SetEffectDataAttack} from "./mocks/SetEffectDataAttack.sol"; import {TestTypeCalculator} from "./mocks/TestTypeCalculator.sol"; +import {WideDataEffect} from "./mocks/WideDataEffect.sol"; +import { + FreshMoveContextAbility, + FreshStatusContextAbility, + TEST_CONTEXT_STATUS_CLASS +} from "./mocks/FreshMoveContextAbility.sol"; contract EngineTest is Test, BattleHelper { DefaultCommitManager commitManager; @@ -127,6 +136,55 @@ contract EngineTest is Test, BattleHelper { assertEq(state.turnId, 2); } + function test_afterMoveContextIsFreshAfterEarlierEffectRewrite() public { + FreshMoveContextAbility observer = new FreshMoveContextAbility(); + Mon memory aliceMon = dummyMon; + aliceMon.ability = uint256(uint160(address(observer))); + + Mon[] memory aliceTeam = new Mon[](1); + Mon[] memory bobTeam = new Mon[](1); + aliceTeam[0] = aliceMon; + bobTeam[0] = dummyMon; + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startBattle(engine, defaultOracle, defaultRegistry, matchmaker, address(commitManager)); + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Alice authors move 0. The first AfterMove effect rewrites it to rest; the context-aware + // observer runs second and must see the rewritten lane, not a pass-level snapshot. + _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); + assertEq(observer.observedMoveIndex(), NO_OP_MOVE_INDEX, "context must reflect prior hook rewrite"); + } + + function test_roundEndContextIsFreshAfterEarlierEffectAddsStatus() public { + FreshStatusContextAbility observer = new FreshStatusContextAbility(); + Mon memory aliceMon = dummyMon; + aliceMon.ability = uint256(uint160(address(observer))); + + Mon[] memory aliceTeam = new Mon[](1); + Mon[] memory bobTeam = new Mon[](1); + aliceTeam[0] = aliceMon; + bobTeam[0] = dummyMon; + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startBattle(engine, defaultOracle, defaultRegistry, matchmaker, address(commitManager)); + _commitRevealExecuteForAliceAndBob( + engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + + // Ability registration installs a writer before the observer. During RoundEnd the writer + // adds a status, and the following opted-in hook must receive the new lane value. + assertEq( + observer.observedStatusClass(), + TEST_CONTEXT_STATUS_CLASS, + "context must reflect prior hook status mutation" + ); + } + function test_fasterSpeedKOsGameOver() public { // Initialize mons IMoveSet normalAttack = new CustomAttack( @@ -572,7 +630,7 @@ contract EngineTest is Test, BattleHelper { // Assert that mon index for Alice is 1 // Assert that the mon state for Alice has -5 applied to the switched in mon - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[0], 1); + assertEq(engine.getBattleContext(battleKey).p0ActiveMonIndex, 1); assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Hp), -5); } @@ -621,7 +679,7 @@ contract EngineTest is Test, BattleHelper { // Assert that mon index for Alice is 1 // Assert that the mon state for Alice has -5 applied to the previous mon - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[0], 1); + assertEq(engine.getBattleContext(battleKey).p0ActiveMonIndex, 1); assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), -5); } @@ -1379,7 +1437,7 @@ contract EngineTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, _packForceSwitch(1, 1), 0); // Verify that Bob's mon is now index 1 - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[1], 1); + assertEq(engine.getBattleContext(battleKey).p1ActiveMonIndex, 1); // Verify that Alice's mon took damage assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), -5); @@ -1453,7 +1511,7 @@ contract EngineTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, _packForceSwitch(0, 1), 0); // Assert that Alice's mon is now index 1 - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[0], 1); + assertEq(engine.getBattleContext(battleKey).p0ActiveMonIndex, 1); // Assert that Alice's new mon took damage assertEq(engine.getMonStateForBattle(battleKey, 0, 1, MonStateIndexName.Hp), -5); @@ -1543,7 +1601,7 @@ contract EngineTest is Test, BattleHelper { engine.execute(battleKey); // Check that the active mon index for Alice is still 0 (no switch happened) - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[0], 0); + assertEq(engine.getBattleContext(battleKey).p0ActiveMonIndex, 0); } function test_forceSwitchMoveIgnoresInvalidSwitchTargetNonPriorityPlayerAfterAttacking() public { @@ -1629,7 +1687,7 @@ contract EngineTest is Test, BattleHelper { engine.execute(battleKey); // Check that the active mon index for Alice is still 0 (no switch happened) - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[0], 0); + assertEq(engine.getBattleContext(battleKey).p0ActiveMonIndex, 0); } // environmental effect kills mon after switch in from player move and forces switch @@ -2701,7 +2759,38 @@ contract EngineTest is Test, BattleHelper { // Switch for turn flag should be 0, Bob's active mon index should now be 0 (, BattleData memory state) = engine.getBattle(battleKey); assertEq(state.playerSwitchForTurnFlag, 0); - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[1], 0); + assertEq(engine.getBattleContext(battleKey).p1ActiveMonIndex, 0); + } + + // `AttackCalculator._calculateDamageCore` clamps a huge rawDamage to `type(int32).max` and hands + // it back as a legitimate damage value, so `_dealDamageInternal` must absorb it. Subtracting it + // from an already-negative hpDelta underflows int32 — which in 0.8 reverts, wedging the battle. + function test_dealDamageAbsorbsClampedMaxWithoutUnderflow() public { + uint256[] memory moves = new uint256[](2); + moves[0] = uint256(uint160(address(new RawDamageMove(2)))); + moves[1] = uint256(uint160(address(new RawDamageMove(type(int32).max)))); + Mon memory mon = _createMon(); + mon.moves = moves; + mon.stats.hp = 100; + Mon[] memory team = new Mon[](2); + team[0] = mon; + team[1] = mon; + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + bytes32 battleKey = _startBattle(engine, defaultOracle, defaultRegistry, matchmaker, address(commitManager)); + _commitRevealExecuteForAliceAndBob(battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); + + // Chip both actives so neither hpDelta is the cleared sentinel any more. + _commitRevealExecuteForAliceAndBob(battleKey, 0, 0, uint16(0), uint16(0)); + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), -2); + + // The clamped max lands on a mon already sitting at a negative hpDelta. + _commitRevealExecuteForAliceAndBob(battleKey, 1, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); + + // It should read as a KO, not revert and not wrap to a positive hpDelta. + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), 1); + // Floored at exactly -baseHp: fully dead, and still a value int32 can hold. + assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), -int32(uint32(mon.stats.hp))); } function test_editEffect() public { @@ -2733,6 +2822,121 @@ contract EngineTest is Test, BattleHelper { assertEq(effects[0].data, bytes32(uint256(69))); } + function test_effectDataCompactAndWideFallbackTransitions() public { + WideDataEffect wideEffect = new WideDataEffect(); + // Exact representation boundary: max-1 is offset-tagged; max falls back to slot 1. + uint256 compactMarkerMax = (uint256(1) << 78) - 1; + bytes32 compactData = bytes32(compactMarkerMax - 1); + bytes32 secondWideData = bytes32(compactMarkerMax); + SetEffectDataAttack setCompact = new SetEffectDataAttack(compactData); + SetEffectDataAttack setWide = new SetEffectDataAttack(secondWideData); + + Mon memory mon = _createMon(); + mon.ability = uint160(address(new EffectAbility(wideEffect))); + mon.moves = new uint256[](2); + mon.moves[0] = uint256(uint160(address(setCompact))); + mon.moves[1] = uint256(uint160(address(setWide))); + Mon[] memory team = new Mon[](1); + team[0] = mon; + defaultRegistry.setTeam(ALICE, team); + defaultRegistry.setTeam(BOB, team); + bytes32 battleKey = _startBattle(engine, defaultOracle, defaultRegistry, matchmaker, address(commitManager)); + + _commitRevealExecuteForAliceAndBob(battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, 0, 0); + (EffectInstance[] memory effects, uint256[] memory indices) = engine.getEffects(battleKey, 1, 0); + assertEq(effects[0].data, wideEffect.INITIAL_DATA()); + (bool exists,, bytes32 targetedData) = engine.getEffectData(battleKey, 1, 0, address(wideEffect)); + assertTrue(exists); + assertEq(targetedData, wideEffect.INITIAL_DATA()); + + uint16 editExtraData = uint16(uint256(1) | (indices[0] << 2)); + _commitRevealExecuteForAliceAndBob(battleKey, 0, NO_OP_MOVE_INDEX, editExtraData, 0); + (effects,) = engine.getEffects(battleKey, 1, 0); + assertEq(effects[0].data, compactData); + (BattleConfigView memory battleView,) = engine.getBattle(battleKey); + assertEq(battleView.p1Effects[0][0].data, compactData); + + _commitRevealExecuteForAliceAndBob(battleKey, 1, NO_OP_MOVE_INDEX, editExtraData, 0); + (effects,) = engine.getEffects(battleKey, 1, 0); + assertEq(effects[0].data, secondWideData); + (exists,, targetedData) = engine.getEffectData(battleKey, 1, 0, address(wideEffect)); + assertTrue(exists); + assertEq(targetedData, secondWideData); + } + + // Regression: a KO delivered by an effect's OnUpdateMonState -> dealDamage hook, fired off the + // inline round-end stamina regen (INLINE_STAMINA_REGEN_RULESET is production's default path), must + // still set playerSwitchForTurnFlag for the next turn. _handleMove / _handleEffects recompute the + // flag via _checkForGameOverOrKO whenever koOccurredFlag is set; _inlineStaminaRegen must do the + // same, because a regen tick's OnUpdateMonState listener can reach dealDamage. When the killing + // tick is the round-end regen -- the last thing in the turn -- nothing downstream observes the KO, + // so pre-fix the flag stayed at 2 and the engine wrongly ran the next turn as a normal two-sided turn. + function test_inlineRegenKOSetsForcedSwitchFlag() public { + // Bob's mon carries a KO-on-stamina-gain effect via its ability; the effect's OnUpdateMonState + // hook deals lethal damage on any positive stamina gain -- a mon-agnostic stand-in for an + // effect (e.g. a nightmare tick) that reaches dealDamage from a regen. + StaminaGainKOEffect koOnStaminaGain = new StaminaGainKOEffect(); + IAbility koAbility = new EffectAbility(koOnStaminaGain); + + // Non-resting stamina burn: costs stamina (so a round-end regen has something to top up) but + // deals no damage, so the killing blow is unambiguously the regen-triggered effect. + IMoveSet staminaBurn = new CustomAttack( + typeCalc, CustomAttack.Args({TYPE: Type.Liquid, BASE_POWER: 0, ACCURACY: 100, STAMINA_COST: 2, PRIORITY: 0}) + ); + uint256[] memory burnMoves = new uint256[](1); + burnMoves[0] = uint256(uint160(address(staminaBurn))); + + Mon memory aliceMon = _createMon(); + aliceMon.moves = burnMoves; + aliceMon.stats.hp = 100; + aliceMon.stats.speed = 2; // faster, so move order is deterministic + + Mon memory bobMon = _createMon(); + bobMon.moves = burnMoves; + bobMon.ability = uint160(address(koAbility)); + bobMon.stats.hp = 100; + bobMon.stats.speed = 1; + + Mon[] memory aliceTeam = new Mon[](1); + aliceTeam[0] = aliceMon; + // Bob gets a second mon so the KO forces a switch rather than ending the game. + Mon[] memory bobTeam = new Mon[](2); + bobTeam[0] = bobMon; + bobTeam[1] = bobMon; + + defaultRegistry.setTeam(ALICE, aliceTeam); + defaultRegistry.setTeam(BOB, bobTeam); + + bytes32 battleKey = _startBattle( + engine, + defaultOracle, + defaultRegistry, + matchmaker, + new IEngineHook[](0), + IRuleset(INLINE_STAMINA_REGEN_RULESET), + address(commitManager) + ); + + // Both select mon 0; Bob's ability registers the KO-on-stamina-gain effect on switch-in. + _commitRevealExecuteForAliceAndBob(battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0)); + + // Alice rests; Bob burns stamina (does NOT rest, so its only regen is the round-end top-up -- + // the LAST inline-regen call in the turn). That regen fires the KO effect, the killing blow: a + // KO that originates inside _inlineStaminaRegen with nothing running afterwards to notice it. + _commitRevealExecuteForAliceAndBob(battleKey, NO_OP_MOVE_INDEX, 0, 0, 0); + + assertEq( + engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.IsKnockedOut), + 1, + "Bob's active mon should be KOed by the regen-triggered effect" + ); + + // Bob's active mon just died inside _inlineStaminaRegen, so the next turn must be a one-sided + // forced switch for Bob (playerSwitchForTurnFlag == 1). Pre-fix the flag was left at 2. + (, BattleData memory state) = engine.getBattle(battleKey); + assertEq(state.playerSwitchForTurnFlag, 1, "Bob should be forced to switch after the regen KO"); + } + function test_maxBattleDuration() public { Mon memory mon = _createMon(); mon.moves = new uint256[](0); @@ -2740,12 +2944,12 @@ contract EngineTest is Test, BattleHelper { team[0] = mon; defaultRegistry.setTeam(ALICE, team); defaultRegistry.setTeam(BOB, team); - // Assert there are no free battle config slots - assertEq(engine.getFreeStorageKeys().length, 0); bytes32 battleKey = _startBattle(engine, defaultOracle, defaultRegistry, matchmaker, address(commitManager)); - vm.warp(block.timestamp + MAX_BATTLE_DURATION + 1); + bytes32 storageKey = engine.getStorageKey(battleKey); + vm.warp(vm.getBlockTimestamp() + MAX_BATTLE_DURATION + 1); engine.end(battleKey); - // Assert that there is now 1 free battle config slot - assertEq(engine.getFreeStorageKeys().length, 1); + // The timed-out battle's config slot returns to the pool, so the next battle recycles it. + bytes32 battleKey2 = _startBattle(engine, defaultOracle, defaultRegistry, matchmaker, address(commitManager)); + assertEq(engine.getStorageKey(battleKey2), storageKey, "expected recycled storage key"); } } diff --git a/test/GachaMigrationTest.sol b/test/GachaMigrationTest.sol index 8fc3cbf2..8bc03827 100644 --- a/test/GachaMigrationTest.sol +++ b/test/GachaMigrationTest.sol @@ -191,6 +191,22 @@ contract GachaMigrationTest is Test { assertTrue(newReg.migrated(BOB), "bob now migrated"); } + // roll()'s precondition keys off owned mons, which an import satisfies — a returning + // player must not be pushed back through firstRoll on the new registry. + function test_migratedPlayerCanRoll() public { + vm.startPrank(ALICE); + newReg.migrate(); + uint256[] memory rolled = newReg.roll(1); + vm.stopPrank(); + assertEq(rolled.length, 1, "migrated player can roll"); + } + + function test_roll_revertsBeforeImport() public { + vm.expectRevert(GachaTeamRegistry.NotFirstRolled.selector); + vm.prank(ALICE); + newReg.roll(1); + } + // ----- helpers ----- function _seedCatalog(GachaTeamRegistry reg) internal { diff --git a/test/GachaTeamRegistryTest.sol b/test/GachaTeamRegistryTest.sol index 11b597f1..4fc3aebc 100644 --- a/test/GachaTeamRegistryTest.sol +++ b/test/GachaTeamRegistryTest.sol @@ -100,6 +100,10 @@ contract GachaTeamRegistryTest is Test { // Use single-shot prank so setUp leaves no lingering prank state — tests opt in. vm.prank(ALICE); gachaTeamRegistry.firstRoll(0); + // firstRoll now seeds a starter team at slot 0; delete it so slot allocation in the + // tests below (which assume no team exists yet) is unaffected. + vm.prank(ALICE); + gachaTeamRegistry.deleteTeam(0); // Mon id 1 is a starter Alice didn't pick → unowned. Mon id 2 is also unowned. unownedMonId = 1; diff --git a/test/GachaTest.sol b/test/GachaTest.sol index f699bb18..2415f003 100644 --- a/test/GachaTest.sol +++ b/test/GachaTest.sol @@ -108,6 +108,48 @@ contract GachaTest is Test, BattleHelper { gachaRegistry.firstRoll(invalidStarter); } + // Granted points must not let an account roll straight past onboarding: it would + // end up owning mons with no starter and no team, which nothing downstream repairs. + function test_roll_beforeFirstRoll_reverts() public { + GachaTeamRegistry gachaRegistry = new GachaTeamRegistry(0, 0, engine, mockRNG, GachaTeamRegistry(address(0))); + uint256 poolSize = gachaRegistry.NUM_STARTERS() + gachaRegistry.INITIAL_ROLLS() - 1; + for (uint256 i = 0; i < poolSize; i++) { + gachaRegistry.createMon( + i, + MonStats({ + hp: 10, + stamina: 2, + speed: 2, + attack: 1, + defense: 1, + specialAttack: 1, + specialDefense: 1, + type1: Type.Fire, + type2: Type.None + }), + new uint256[](0), + new uint256[](0), + new bytes32[](0), + new bytes32[](0) + ); + } + // Fund the roll so the guard is what reverts, not the points underflow. + address[] memory selfList = new address[](1); + selfList[0] = address(this); + gachaRegistry.setAssigners(selfList, new address[](0)); + gachaRegistry.assignPoints(ALICE, 10 * gachaRegistry.ROLL_COST()); + + vm.expectRevert(GachaTeamRegistry.NotFirstRolled.selector); + vm.prank(ALICE); + gachaRegistry.roll(1); + + // Same account clears the guard once onboarding has actually run. + vm.startPrank(ALICE); + gachaRegistry.firstRoll(0); + assertEq(gachaRegistry.roll(1).length, 1, "roll works after firstRoll"); + vm.stopPrank(); + } + function test_firstRoll_emitsRollWithZeroSpend() public { GachaTeamRegistry gachaRegistry = new GachaTeamRegistry(0, 0, engine, mockRNG, GachaTeamRegistry(address(0))); uint256 poolSize = gachaRegistry.NUM_STARTERS() + gachaRegistry.INITIAL_ROLLS() - 1; @@ -152,6 +194,50 @@ contract GachaTest is Test, BattleHelper { assertTrue(found, "Roll event emitted"); } + function test_firstRoll_createsStarterTeam() public { + GachaTeamRegistry gachaRegistry = new GachaTeamRegistry( + GAME_MONS_PER_TEAM, GAME_MOVES_PER_MON, engine, mockRNG, GachaTeamRegistry(address(0)) + ); + uint256 poolSize = gachaRegistry.NUM_STARTERS() + gachaRegistry.INITIAL_ROLLS() - 1; + for (uint256 i = 0; i < poolSize; i++) { + gachaRegistry.createMon( + i, + MonStats({ + hp: 10, + stamina: 2, + speed: 2, + attack: 1, + defense: 1, + specialAttack: 1, + specialDefense: 1, + type1: Type.Fire, + type2: Type.None + }), + new uint256[](0), + new uint256[](0), + new bytes32[](0), + new bytes32[](0) + ); + } + + vm.prank(ALICE); + uint256[] memory monIds = gachaRegistry.firstRoll(0); + + assertEq(gachaRegistry.getTeamCount(ALICE), 1); + uint256[] memory teamMonIds = gachaRegistry.getMonRegistryIndicesForTeam(ALICE, 0); + assertEq(teamMonIds.length, monIds.length); + for (uint256 i; i < monIds.length; i++) { + assertEq(teamMonIds[i], monIds[i]); + } + } + + function test_firstRoll_revertsWhenMonsPerTeamExceedsInitialRolls() public { + GachaTeamRegistry probe = new GachaTeamRegistry(0, 0, engine, mockRNG, GachaTeamRegistry(address(0))); + uint256 tooMany = probe.INITIAL_ROLLS() + 1; + vm.expectRevert(GachaTeamRegistry.MonsPerTeamExceedsInitialRolls.selector); + new GachaTeamRegistry(tooMany, GAME_MOVES_PER_MON, engine, mockRNG, GachaTeamRegistry(address(0))); + } + function test_assignPoints() public { GachaTeamRegistry gachaRegistry = new GachaTeamRegistry(0, 0, engine, mockRNG, GachaTeamRegistry(address(0))); @@ -348,25 +434,28 @@ contract GachaTest is Test, BattleHelper { // bonus must only fire once, even though a roll happens in between. GachaTeamRegistry gachaRegistry = new GachaTeamRegistry(0, 0, engine, mockRNG, GachaTeamRegistry(address(0))); - // One mon in the registry is enough for a single regular roll. - gachaRegistry.createMon( - 0, - MonStats({ - hp: 10, - stamina: 2, - speed: 2, - attack: 1, - defense: 1, - specialAttack: 1, - specialDefense: 1, - type1: Type.Fire, - type2: Type.None - }), - new uint256[](0), - new uint256[](0), - new bytes32[](0), - new bytes32[](0) - ); + // roll() requires prior ownership, so the pool has to cover a firstRoll too. + uint256 poolSize = gachaRegistry.NUM_STARTERS() + gachaRegistry.INITIAL_ROLLS() - 1; + for (uint256 i = 0; i < poolSize; i++) { + gachaRegistry.createMon( + i, + MonStats({ + hp: 10, + stamina: 2, + speed: 2, + attack: 1, + defense: 1, + specialAttack: 1, + specialDefense: 1, + type1: Type.Fire, + type2: Type.None + }), + new uint256[](0), + new uint256[](0), + new bytes32[](0), + new bytes32[](0) + ); + } Mon[] memory team = new Mon[](1); team[0] = Mon({ @@ -406,8 +495,9 @@ contract GachaTest is Test, BattleHelper { alicePointsAfterFirstBattle, gachaRegistry.FIRST_GAME_EVER_BONUS() + gachaRegistry.POINTS_PER_WIN() + 1 ); - // ---- Roll ---- + // ---- Roll ---- (firstRoll is free, so it leaves the point accounting alone) vm.startPrank(ALICE); + gachaRegistry.firstRoll(0); gachaRegistry.roll(1); vm.stopPrank(); diff --git a/test/MainnetReplayGasTest.t.sol b/test/MainnetReplayGasTest.t.sol new file mode 100644 index 00000000..1cc1881b --- /dev/null +++ b/test/MainnetReplayGasTest.t.sol @@ -0,0 +1,959 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../src/Constants.sol"; +import "../src/Enums.sol"; +import "../src/Structs.sol"; +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; + +import {SetupMons} from "../script/SetupMons.s.sol"; +import {Engine} from "../src/Engine.sol"; +import {IEngine} from "../src/IEngine.sol"; +import {IAbility} from "../src/abilities/IAbility.sol"; +import {IEffect} from "../src/effects/IEffect.sol"; +import {IEffectResolver} from "../src/effects/IEffectResolver.sol"; +import {GachaTeamRegistry} from "../src/game-layer/GachaTeamRegistry.sol"; +import {IGachaRNG} from "../src/rng/IGachaRNG.sol"; + +import {Overclock} from "../src/effects/battlefield/Overclock.sol"; +import {BlessedStatus} from "../src/effects/status/BlessedStatus.sol"; +import {BurnStatus} from "../src/effects/status/BurnStatus.sol"; +import {FrostbiteStatus} from "../src/effects/status/FrostbiteStatus.sol"; +import {PanicStatus} from "../src/effects/status/PanicStatus.sol"; +import {SleepStatus} from "../src/effects/status/SleepStatus.sol"; +import {ZapStatus} from "../src/effects/status/ZapStatus.sol"; +import {TypeCalculator} from "../src/types/TypeCalculator.sol"; + +import {IEngineHook} from "../src/IEngineHook.sol"; +import {IRuleset} from "../src/IRuleset.sol"; +import {SignedCommitManager} from "../src/commit-manager/SignedCommitManager.sol"; +import {BattleOfferLib} from "../src/matchmaker/BattleOfferLib.sol"; +import {SignedMatchmaker} from "../src/matchmaker/SignedMatchmaker.sol"; +import {IRandomnessOracle} from "../src/rng/IRandomnessOracle.sol"; +import {IMoveResolver} from "../src/moves/IMoveResolver.sol"; +import {IMoveSet} from "../src/moves/IMoveSet.sol"; + +import {GasMeasure} from "./abstract/GasMeasure.sol"; +import {sideWord} from "./abstract/SlotWire.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; + +/// @notice Bit-faithful replay of the first real mainnet game we captured — MegaETH tx +/// 0x5c5daa0edaaa79ac05a6915c36c393c03b172f0336a25917d96b811a5f1283e9 (CPU::executeGame, +/// 27 batched turns, receipt 4,875,113 gas). Both sides ran an Embursa burn kit, so this +/// exercises the burn apply/escalate + switch-guard paths that Tier A/B of the gas audit +/// target. Teams (ids + facet-adjusted stats) and the 27 packed turn entries were read +/// off-chain from the real config (getBattle) + tx calldata; the turn RNG is keccak(p0Salt, +/// p1Salt) with NO battleKey dependency, so replaying the same salts reproduces the exact +/// battle here. Measured through engine.executeBatchedTurns (the same path the tx took) on +/// a VIRGIN storageKey and again on a REUSED (steady-state) one. +contract MainnetReplayGasTest is Test, SetupMons, GasMeasure { + uint256 constant P0_PK = 0xA11CE; + uint256 constant P1_PK = 0xB0B; + uint256 constant TX_BASE = 21000; + address p0; + address p1; + + Engine engine; + SignedCommitManager mgr; + SignedMatchmaker maker; + GachaTeamRegistry gachaReg; + TestTeamRegistry registry; + + struct ReplayCensus { + EffectStorageTally effectStorage; + StorageWorkingSet engineWorkingSet; + CallBoundaryStorageTally callBoundaryStorage; + EngineStorageCategoryTally storageCategories; + MonStateStorageTally monStateStorage; + MonStateFrameModel monStateFrame; + PackedHeaderFrameModel packedHeaderFrame; + uint256 roundStartCalls; + uint256 roundEndCalls; + uint256 afterMoveCalls; + uint256 preDamageCalls; + uint256 afterDamageCalls; + uint256 updateStateCalls; + uint256 moveDecisionCallbacks; + uint256 statusClassCallbacks; + uint256 lifecycleCalls; + uint256 nonHookHeaderReadUpperBound; + uint256 moveResolverCalls; + uint256 moveResolverContinuations; + uint256 effectResolverCalls; + uint256 legacyMoveBoundaries; + uint256 legacyAbilityBoundaries; + uint256 legacyEffectBoundaries; + uint256 legacyBoundaryUpperBound; + } + + // Real rosters from the mainnet config (getBattle). p1 is the CPU side. + uint256[4] P0_IDS = [uint256(1), 5, 7, 6]; // Inutia, Sofabbi, Embursa, Pengym + uint256[4] P1_IDS = [uint256(5), 7, 4, 7]; // Sofabbi, Embursa, Gorillax, Embursa + + function setUp() public { + p0 = vm.addr(P0_PK); + p1 = vm.addr(P1_PK); + _deployStack(); + registry.setTeam(p0, _buildTeam(P0_IDS, _p0Stats())); + registry.setTeam(p1, _buildTeam(P1_IDS, _p1Stats())); + } + + function _deployStack() internal { + engine = new Engine(4, 4); + TypeCalculator tc = new TypeCalculator(); + Overclock oc = new Overclock(); + SleepStatus sleepStatus = new SleepStatus(); + PanicStatus panicStatus = new PanicStatus(); + FrostbiteStatus frost = new FrostbiteStatus(); + BurnStatus burn = new BurnStatus(); + ZapStatus zap = new ZapStatus(); + BlessedStatus blessed = new BlessedStatus(); + gachaReg = + new GachaTeamRegistry(4, 4, IEngine(address(engine)), IGachaRNG(address(0)), GachaTeamRegistry(address(0))); + vm.setEnv("TYPE_CALCULATOR", vm.toString(address(tc))); + vm.setEnv("OVERCLOCK", vm.toString(address(oc))); + vm.setEnv("SLEEP_STATUS", vm.toString(address(sleepStatus))); + vm.setEnv("PANIC_STATUS", vm.toString(address(panicStatus))); + vm.setEnv("FROSTBITE_STATUS", vm.toString(address(frost))); + vm.setEnv("BURN_STATUS", vm.toString(address(burn))); + vm.setEnv("ZAP_STATUS", vm.toString(address(zap))); + vm.setEnv("BLESSED_STATUS", vm.toString(address(blessed))); + vm.setEnv("GACHA_TEAM_REGISTRY", vm.toString(address(gachaReg))); + deployGhouliath(gachaReg); + deployInutia(gachaReg); + deployMalalien(gachaReg); + deployIblivion(gachaReg); + deployGorillax(gachaReg); + deploySofabbi(gachaReg); + deployPengym(gachaReg); + deployEmbursa(gachaReg); + deployVolthare(gachaReg); + deployAurox(gachaReg); + deployXmon(gachaReg); + mgr = new SignedCommitManager(IEngine(address(engine))); + maker = new SignedMatchmaker(engine); + registry = new TestTeamRegistry(); + } + + function _buildTeam(uint256[4] memory ids, MonStats[4] memory logStats) internal view returns (Mon[] memory team) { + team = new Mon[](4); + for (uint256 i; i < 4; i++) { + (, uint256[] memory moves, uint256[] memory abilities) = gachaReg.getMonData(ids[i]); + team[i] = Mon({stats: logStats[i], ability: abilities.length > 0 ? abilities[0] : 0, moves: moves}); + } + } + + function _mk(uint32 hp, uint32 stam, uint32 spe, uint32 atk, uint32 def, uint32 spa, uint32 spd, Type t1, Type t2) + internal + pure + returns (MonStats memory) + { + return MonStats({ + hp: hp, + stamina: stam, + speed: spe, + attack: atk, + defense: def, + specialAttack: spa, + specialDefense: spd, + type1: t1, + type2: t2 + }); + } + + // Facet-adjusted stats straight from the mainnet config (getBattle). + function _p0Stats() internal pure returns (MonStats[4] memory s) { + s[0] = _mk(371, 5, 218, 179, 189, 183, 192, Type(9), Type(14)); + s[1] = _mk(333, 5, 167, 180, 211, 120, 282, Type(7), Type(14)); + s[2] = _mk(420, 5, 106, 141, 231, 190, 169, Type(4), Type(14)); + s[3] = _mk(371, 5, 156, 191, 191, 210, 172, Type(6), Type(14)); + } + + function _p1Stats() internal pure returns (MonStats[4] memory s) { + s[0] = _mk(333, 5, 167, 189, 201, 126, 269, Type(7), Type(14)); + s[1] = _mk(420, 5, 106, 148, 220, 199, 161, Type(4), Type(14)); + s[2] = _mk(407, 5, 123, 317, 175, 117, 176, Type(2), Type(14)); + s[3] = _mk(420, 5, 106, 148, 220, 199, 161, Type(4), Type(14)); + } + + // The 27 packed turn entries from the tx calldata (verbatim wire words: + // p0Move 8 | p0Extra 16 | p0Salt 104 | p1Move 8 | p1Extra 16 | p1Salt 104). + function _entries() internal pure returns (uint256[] memory e) { + e = new uint256[](27); + e[0] = 0x17def5ffbebed718e42ad8d5437b700007d; + e[1] = 0x17cc1b6f203a7394f49cc2cbe26000002; + e[2] = 0x1491cabc6f3f9f098a351da75a6000303; + e[3] = 0x2d351b8789b948ba4d526e455d0000001; + e[4] = 0x28ea2c7b0d748d1437c53a9d7cb00007e; + e[5] = 0x17e454fd4f7ab2d549f0ee2ab9000027d; + e[6] = 0x27dd5e77a28073a9e5ee44929aab3000001; + e[7] = 0x173a3d8262dc65be7dbf730c590000001; + e[8] = 0x1643e54852af9503f2e8077ebf700007e; + e[9] = 0x7ef094b6975e53293948e1203cc700037d; + e[10] = 0x31feeeddb68b4b17088085a1080000002; + e[11] = 0x339b37ca50816ee03f076911342000001; + e[12] = 0x3e201cd1cb941d52bc67fec615d00007e; + e[13] = 0x17d2888d001183e07bcc3f0a8e8e800017d; + e[14] = 0x37d0000000000000000000000000000007e; + e[15] = 0x1977f57d82dc43af2a2448c2f91000002; + e[16] = 0x1a18f414979e79dece646b3ed36000101; + e[17] = 0x2c444b8009fc7e49a768d6a26eb000003; + e[18] = 0x2ff3f0d7e0548d4c90746470428000003; + e[19] = 0x1a90ef652a7be14013561d2f88f00007e; + e[20] = 0x7e1c013c0ff1e95c94cef042b98a00007d; + e[21] = 0x22d9ec4faf7e71b4cbe49adbe55000002; + e[22] = 0x2a2c344c9bc853f445b3c8df0cc00007e; + e[23] = 0x7d0000000000000000000000000000007e; + e[24] = 0xe0eb713b94ee96ba68b8c238c3000001; + e[25] = 0x7e474f7f0d702737a8ad3337d43000037d; + e[26] = 0x53d475e14cb30342bbd9209f1f000002; + } + + function _startBattle() internal returns (bytes32 key) { + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(maker); + address[] memory makersToRemove = new address[](0); + vm.prank(p0); + engine.updateMatchmakers(makersToAdd, makersToRemove); + vm.prank(p1); + engine.updateMatchmakers(makersToAdd, makersToRemove); + bytes32 pairHash; + (key, pairHash) = engine.computeBattleKey(p0, p1); + uint256 nonce = engine.pairHashNonces(pairHash); + // moveManager == the tx sender of executeBatchedTurns (mirrors the CPU relayer role). + BattleOffer memory offer = BattleOffer({ + battle: Battle({ + p0: p0, + p0TeamIndex: 0, + p1: p1, + p1TeamIndex: 0, + p2: address(0), + p2TeamIndex: 0, + p3: address(0), + p3TeamIndex: 0, + teamRegistry: registry, + rngOracle: IRandomnessOracle(address(0)), + ruleset: IRuleset(INLINE_STAMINA_REGEN_RULESET), + moveManager: address(mgr), + matchmaker: maker, + engineHooks: new IEngineHook[](0) + }), + pairHashNonce: nonce, + battleMode: BATTLE_MODE_SINGLES + }); + bytes32 digest = maker.hashTypedData(BattleOfferLib.hashBattleOfferForSigning(offer, 0)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(P0_PK, digest); + vm.prank(p1); + bytes[4] memory seatSigs; + seatSigs[0] = abi.encodePacked(r, s, v); + maker.startGame(offer, 0, seatSigs); + } + + /// @dev Prod-faithful per-tx gas = on-chain exec + EIP-2028 calldata. The ABI encode is done by + /// the caller OUTSIDE the bracket (the relayer encodes off-chain); cold-starts storage first. + function _replayOneTx(bytes32 battleKey, bool measure) internal returns (uint256 execGas) { + uint256[] memory entries = _entries(); + bytes memory cd = abi.encodeCall(engine.executeBatchedTurns, (battleKey, entries)); + if (measure) { + vm.cool(address(engine)); + } + vm.prank(address(mgr)); + uint256 g0 = gasleft(); + (bool ok,) = address(engine).call(cd); + uint256 raw = g0 - gasleft(); + require(ok, "replay reverted"); + if (measure) { + execGas = raw + _calldataCost(cd) + TX_BASE; + } + engine.resetCallContext(); + } + + /// @dev Recorded analog of `_replayOneTx`. State-diff collection perturbs Foundry's raw gas + /// scalar, so this intentionally returns census data only and cannot become a KPI. + function _replayOneTxRecorded(bytes32 battleKey) internal returns (Vm.AccountAccess[] memory accesses) { + uint256[] memory entries = _entries(); + bytes memory cd = abi.encodeCall(engine.executeBatchedTurns, (battleKey, entries)); + vm.cool(address(engine)); + vm.startStateDiffRecording(); + vm.prank(address(mgr)); + (bool ok,) = address(engine).call(cd); + require(ok, "recorded replay reverted"); + accesses = vm.stopAndReturnStateDiff(); + engine.resetCallContext(); + } + + function _calldataCost(bytes memory cd) internal pure returns (uint256 cost) { + for (uint256 i; i < cd.length; i++) { + cost += cd[i] == bytes1(0) ? 4 : 16; + } + } + + function _recordReplayCensus( + string memory name, + bytes32 battleKey, + Vm.AccountAccess[] memory accesses, + bytes32 storageKey, + uint256 reusedGas + ) internal returns (ReplayCensus memory c) { + c.effectStorage = _effectStorageTally(accesses, address(engine), storageKey); + c.engineWorkingSet = _storageWorkingSet(accesses, address(engine)); + c.callBoundaryStorage = _callBoundaryStorageTally(accesses, address(engine), address(mgr)); + c.storageCategories = _engineStorageCategoryTally(accesses, address(engine), battleKey, storageKey); + c.monStateStorage = _monStateStorageTally(accesses, address(engine), storageKey); + c.monStateFrame = + _monStateFrameModel(accesses, address(engine), storageKey, _legacyBoundarySelectors()); + c.packedHeaderFrame = _packedHeaderFrameModel( + accesses, + address(engine), + address(mgr), + battleKey, + storageKey, + _legacyBoundarySelectors() + ); + uint256 classifiedHeaderOps = c.storageCategories.reads[STORAGE_BATTLE_DATA] + + c.storageCategories.writes[STORAGE_BATTLE_DATA] + + c.storageCategories.reads[STORAGE_CONFIG_HEADER] + + c.storageCategories.writes[STORAGE_CONFIG_HEADER]; + require(c.packedHeaderFrame.currentStorageOps == classifiedHeaderOps, "header frame census mismatch"); + require( + c.storageCategories.reads[STORAGE_OTHER] + c.storageCategories.writes[STORAGE_OTHER] == 0, + "unclassified Engine storage" + ); + c.roundStartCalls = _countCalls(accesses, address(engine), address(0), IEffect.onRoundStart.selector); + c.roundEndCalls = _countCalls(accesses, address(engine), address(0), IEffect.onRoundEnd.selector); + c.afterMoveCalls = _countCalls(accesses, address(engine), address(0), IEffect.onAfterMove.selector) + + _countCalls(accesses, address(engine), address(0), IEffectResolver.resolveEffect.selector); + c.preDamageCalls = _countCalls(accesses, address(engine), address(0), IEffect.onPreDamage.selector); + c.afterDamageCalls = _countCalls(accesses, address(engine), address(0), IEffect.onAfterDamage.selector); + c.updateStateCalls = _countCalls(accesses, address(engine), address(0), IEffect.onUpdateMonState.selector); + c.moveDecisionCallbacks = + _countCalls(accesses, address(0), address(engine), IEngine.getMoveDecisionForSlot.selector); + c.statusClassCallbacks = _countCalls(accesses, address(0), address(engine), IEngine.getMonStatusClass.selector); + c.lifecycleCalls = c.roundStartCalls + c.roundEndCalls + c.afterMoveCalls + c.preDamageCalls + + c.afterDamageCalls + c.updateStateCalls; + c.nonHookHeaderReadUpperBound = + c.effectStorage.headerReads > c.lifecycleCalls ? c.effectStorage.headerReads - c.lifecycleCalls : 0; + c.moveResolverCalls = _countCalls(accesses, address(engine), address(0), IMoveResolver.resolveMove.selector); + c.moveResolverContinuations = _countCallsWithNonzeroFirstArg( + accesses, address(engine), address(0), IMoveResolver.resolveMove.selector + ); + c.effectResolverCalls = + _countCalls(accesses, address(engine), address(0), IEffectResolver.resolveEffect.selector); + c.legacyMoveBoundaries = _countCalls(accesses, address(engine), address(0), IMoveSet.move.selector); + c.legacyAbilityBoundaries = + _countCalls(accesses, address(engine), address(0), IAbility.activateOnSwitch.selector); + c.legacyEffectBoundaries = _legacyEffectBoundaryCount(accesses); + c.legacyBoundaryUpperBound = + c.legacyMoveBoundaries + c.legacyAbilityBoundaries + c.legacyEffectBoundaries; + + vm.snapshotValue(string.concat(name, "_execGas"), reusedGas); + vm.snapshotValue(string.concat(name, "_effectHeaderReads"), c.effectStorage.headerReads); + vm.snapshotValue(string.concat(name, "_effectDataReads"), c.effectStorage.dataReads); + vm.snapshotValue(string.concat(name, "_effectHeaderWrites"), c.effectStorage.headerWrites); + vm.snapshotValue(string.concat(name, "_effectDataWrites"), c.effectStorage.dataWrites); + vm.snapshotValue(string.concat(name, "_engineStorageReads"), c.engineWorkingSet.reads); + vm.snapshotValue(string.concat(name, "_engineStorageWrites"), c.engineWorkingSet.writes); + vm.snapshotValue( + string.concat(name, "_engineStorageUniqueTouchedSlots"), c.engineWorkingSet.uniqueTouchedSlots + ); + vm.snapshotValue( + string.concat(name, "_engineStorageUniqueWrittenSlots"), c.engineWorkingSet.uniqueWrittenSlots + ); + vm.snapshotValue( + string.concat(name, "_engineStorageLoadCommitFloorOps"), c.engineWorkingSet.loadCommitFloorOps + ); + vm.snapshotValue(string.concat(name, "_engineStorageRemovableOps"), c.engineWorkingSet.removableOps); + vm.snapshotValue( + string.concat(name, "_engineRootStorageReads"), c.callBoundaryStorage.engineRootReads + ); + vm.snapshotValue( + string.concat(name, "_engineRootStorageWrites"), c.callBoundaryStorage.engineRootWrites + ); + vm.snapshotValue( + string.concat(name, "_engineCallbackStorageReads"), c.callBoundaryStorage.engineCallbackReads + ); + vm.snapshotValue( + string.concat(name, "_engineCallbackStorageWrites"), c.callBoundaryStorage.engineCallbackWrites + ); + vm.snapshotValue(string.concat(name, "_externalStorageReads"), c.callBoundaryStorage.externalReads); + vm.snapshotValue(string.concat(name, "_externalStorageWrites"), c.callBoundaryStorage.externalWrites); + for (uint256 category; category < STORAGE_CATEGORY_COUNT; category++) { + string memory prefix = string.concat(name, "_storage_", _storageCategoryName(category)); + vm.snapshotValue(string.concat(prefix, "Reads"), c.storageCategories.reads[category]); + vm.snapshotValue(string.concat(prefix, "Writes"), c.storageCategories.writes[category]); + vm.snapshotValue(string.concat(prefix, "UniqueTouched"), c.storageCategories.uniqueTouched[category]); + vm.snapshotValue(string.concat(prefix, "UniqueWritten"), c.storageCategories.uniqueWritten[category]); + } + vm.snapshotValue( + string.concat(name, "_storage_firstOtherSlot"), uint256(c.storageCategories.firstOtherSlot) + ); + vm.snapshotValue(string.concat(name, "_roundStartCalls"), c.roundStartCalls); + vm.snapshotValue(string.concat(name, "_roundEndCalls"), c.roundEndCalls); + vm.snapshotValue(string.concat(name, "_afterMoveCalls"), c.afterMoveCalls); + vm.snapshotValue(string.concat(name, "_preDamageCalls"), c.preDamageCalls); + vm.snapshotValue(string.concat(name, "_afterDamageCalls"), c.afterDamageCalls); + vm.snapshotValue(string.concat(name, "_updateStateCalls"), c.updateStateCalls); + vm.snapshotValue(string.concat(name, "_moveDecisionCallbacks"), c.moveDecisionCallbacks); + vm.snapshotValue(string.concat(name, "_statusClassCallbacks"), c.statusClassCallbacks); + vm.snapshotValue(string.concat(name, "_lifecycleCalls"), c.lifecycleCalls); + vm.snapshotValue(string.concat(name, "_nonHookHeaderReadUpperBound"), c.nonHookHeaderReadUpperBound); + vm.snapshotValue(string.concat(name, "_monStateReads"), c.monStateStorage.reads); + vm.snapshotValue(string.concat(name, "_monStateWrites"), c.monStateStorage.writes); + vm.snapshotValue(string.concat(name, "_monStateUniqueTouchedLanes"), c.monStateStorage.uniqueTouchedLanes); + vm.snapshotValue(string.concat(name, "_monStateUniqueWrittenLanes"), c.monStateStorage.uniqueWrittenLanes); + vm.snapshotValue(string.concat(name, "_monStateFrameStorageOps"), c.monStateStorage.frameStorageOps); + vm.snapshotValue(string.concat(name, "_monStateRemovableStorageOps"), c.monStateStorage.removableStorageOps); + vm.snapshotValue(string.concat(name, "_monStateFrameLoadsWithLegacy"), c.monStateFrame.loads); + vm.snapshotValue(string.concat(name, "_monStateFrameCommitsWithLegacy"), c.monStateFrame.commits); + vm.snapshotValue(string.concat(name, "_monStateFrameReloadedLanes"), c.monStateFrame.reloadedLanes); + vm.snapshotValue(string.concat(name, "_monStateDirtyFlushBoundaries"), c.monStateFrame.dirtyFlushBoundaries); + vm.snapshotValue( + string.concat(name, "_monStateCleanInvalidationBoundaries"), c.monStateFrame.cleanInvalidationBoundaries + ); + vm.snapshotValue( + string.concat(name, "_monStateFrameStorageOpsWithLegacy"), c.monStateFrame.modeledStorageOps + ); + vm.snapshotValue( + string.concat(name, "_monStateRemovableStorageOpsWithLegacy"), c.monStateFrame.removableStorageOps + ); + vm.snapshotValue(string.concat(name, "_headerFrameLoadsWithLegacy"), c.packedHeaderFrame.loads); + vm.snapshotValue(string.concat(name, "_headerFrameCommitsWithLegacy"), c.packedHeaderFrame.commits); + vm.snapshotValue( + string.concat(name, "_headerFrameCallbackPassthroughOps"), + c.packedHeaderFrame.callbackPassthroughOps + ); + vm.snapshotValue(string.concat(name, "_headerFrameReloadedWords"), c.packedHeaderFrame.reloadedWords); + vm.snapshotValue( + string.concat(name, "_headerFrameDirtyFlushBoundaries"), + c.packedHeaderFrame.dirtyFlushBoundaries + ); + vm.snapshotValue( + string.concat(name, "_headerFrameCleanInvalidationBoundaries"), + c.packedHeaderFrame.cleanInvalidationBoundaries + ); + vm.snapshotValue( + string.concat(name, "_headerFrameStorageOpsWithLegacy"), c.packedHeaderFrame.modeledStorageOps + ); + vm.snapshotValue( + string.concat(name, "_headerFrameRemovableStorageOpsWithLegacy"), + c.packedHeaderFrame.removableStorageOps + ); + vm.snapshotValue(string.concat(name, "_moveResolverCalls"), c.moveResolverCalls); + vm.snapshotValue(string.concat(name, "_moveResolverContinuations"), c.moveResolverContinuations); + vm.snapshotValue(string.concat(name, "_effectResolverCalls"), c.effectResolverCalls); + vm.snapshotValue(string.concat(name, "_legacyMoveBoundaries"), c.legacyMoveBoundaries); + vm.snapshotValue(string.concat(name, "_legacyAbilityBoundaries"), c.legacyAbilityBoundaries); + vm.snapshotValue(string.concat(name, "_legacyEffectBoundaries"), c.legacyEffectBoundaries); + vm.snapshotValue(string.concat(name, "_legacyBoundaryUpperBound"), c.legacyBoundaryUpperBound); + + assertGt(c.effectStorage.headerReads, 0, "effect header census must be live"); + assertGt(c.roundEndCalls, 0, "round-end census must be live"); + assertGt(c.afterMoveCalls, 0, "after-move census must be live"); + assertGt(c.monStateStorage.reads, 0, "mon-state census must be live"); + } + + /// @dev Every mutating legacy effect hook is a conservative frame flush/invalidation boundary. + /// This is an upper bound: consecutive calls with no intervening dirty lane need not flush. + function _legacyEffectBoundaryCount(Vm.AccountAccess[] memory accesses) private view returns (uint256 count) { + count = _countCalls(accesses, address(engine), address(0), IEffect.shouldApply.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onRoundStart.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onRoundEnd.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onMonSwitchIn.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onMonSwitchOut.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onAfterDamage.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onAfterMove.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onUpdateMonState.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onPreDamage.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onApply.selector); + count += _countCalls(accesses, address(engine), address(0), IEffect.onRemove.selector); + } + + function _legacyBoundarySelectors() private pure returns (bytes4[] memory selectors) { + selectors = new bytes4[](13); + selectors[0] = IMoveSet.move.selector; + selectors[1] = IAbility.activateOnSwitch.selector; + selectors[2] = IEffect.shouldApply.selector; + selectors[3] = IEffect.onRoundStart.selector; + selectors[4] = IEffect.onRoundEnd.selector; + selectors[5] = IEffect.onMonSwitchIn.selector; + selectors[6] = IEffect.onMonSwitchOut.selector; + selectors[7] = IEffect.onAfterDamage.selector; + selectors[8] = IEffect.onAfterMove.selector; + selectors[9] = IEffect.onUpdateMonState.selector; + selectors[10] = IEffect.onPreDamage.selector; + selectors[11] = IEffect.onApply.selector; + selectors[12] = IEffect.onRemove.selector; + } + + function _logFrameCensus(ReplayCensus memory census) private pure { + console.log( + " Engine storage reads/writes :", + census.engineWorkingSet.reads, + census.engineWorkingSet.writes + ); + console.log( + " Engine unique touched/written slots :", + census.engineWorkingSet.uniqueTouchedSlots, + census.engineWorkingSet.uniqueWrittenSlots + ); + console.log( + " Engine current/floor/removable storage ops :", + census.engineWorkingSet.reads + census.engineWorkingSet.writes, + census.engineWorkingSet.loadCommitFloorOps, + census.engineWorkingSet.removableOps + ); + console.log( + " Engine root reads/writes :", + census.callBoundaryStorage.engineRootReads, + census.callBoundaryStorage.engineRootWrites + ); + console.log( + " Engine callback reads/writes :", + census.callBoundaryStorage.engineCallbackReads, + census.callBoundaryStorage.engineCallbackWrites + ); + console.log( + " mechanic-owned storage reads/writes :", + census.callBoundaryStorage.externalReads, + census.callBoundaryStorage.externalWrites + ); + console.log(" storage categories: reads / writes / unique / unique-written"); + _logStorageCategory(" other/unclassified", census.storageCategories, STORAGE_OTHER); + _logStorageCategory(" shell/allocator", census.storageCategories, STORAGE_SHELL); + _logStorageCategory(" BattleData", census.storageCategories, STORAGE_BATTLE_DATA); + _logStorageCategory(" config header", census.storageCategories, STORAGE_CONFIG_HEADER); + _logStorageCategory(" static catalog/team", census.storageCategories, STORAGE_STATIC_CATALOG); + _logStorageCategory(" MonState", census.storageCategories, STORAGE_MON_STATE); + _logStorageCategory(" effects/hooks", census.storageCategories, STORAGE_EFFECTS); + _logStorageCategory(" stat boosts", census.storageCategories, STORAGE_BOOSTS); + _logStorageCategory(" global KV", census.storageCategories, STORAGE_GLOBAL_KV); + if (census.storageCategories.firstOtherSlot != bytes32(0)) { + console.log(" first unclassified slot"); + console.logBytes32(census.storageCategories.firstOtherSlot); + } + console.log( + " packed-header current/modeled/removable ops :", + census.packedHeaderFrame.currentStorageOps, + census.packedHeaderFrame.modeledStorageOps, + census.packedHeaderFrame.removableStorageOps + ); + console.log( + " packed-header loads/commits/callback passthrough :", + census.packedHeaderFrame.loads, + census.packedHeaderFrame.commits, + census.packedHeaderFrame.callbackPassthroughOps + ); + console.log( + " packed-header reloads / dirty flushes / clean bars:", + census.packedHeaderFrame.reloadedWords, + census.packedHeaderFrame.dirtyFlushBoundaries, + census.packedHeaderFrame.cleanInvalidationBoundaries + ); + console.log( + " MonState reads/writes :", + census.monStateStorage.reads, + census.monStateStorage.writes + ); + console.log( + " MonState unique touched/written lanes :", + census.monStateStorage.uniqueTouchedLanes, + census.monStateStorage.uniqueWrittenLanes + ); + console.log( + " MonState current/frame/removable storage ops :", + census.monStateStorage.currentStorageOps, + census.monStateStorage.frameStorageOps, + census.monStateStorage.removableStorageOps + ); + console.log( + " frame loads/commits with legacy boundaries :", + census.monStateFrame.loads, + census.monStateFrame.commits + ); + console.log( + " frame reloaded lanes / dirty flushes / clean bars :", + census.monStateFrame.reloadedLanes, + census.monStateFrame.dirtyFlushBoundaries, + census.monStateFrame.cleanInvalidationBoundaries + ); + console.log( + " with-legacy frame/removable storage ops :", + census.monStateFrame.modeledStorageOps, + census.monStateFrame.removableStorageOps + ); + console.log( + " resolver move/continuation/effect calls :", + census.moveResolverCalls, + census.moveResolverContinuations, + census.effectResolverCalls + ); + console.log( + " legacy move/ability/effect boundary upper bound :", + census.legacyMoveBoundaries, + census.legacyAbilityBoundaries, + census.legacyEffectBoundaries + ); + console.log(" total legacy boundary upper bound :", census.legacyBoundaryUpperBound); + } + + function _logStorageCategory(string memory label, EngineStorageCategoryTally memory t, uint256 category) + private + pure + { + console.log(label, t.reads[category], t.writes[category]); + console.log(" unique touched/written", t.uniqueTouched[category], t.uniqueWritten[category]); + } + + function _storageCategoryName(uint256 category) private pure returns (string memory) { + if (category == STORAGE_SHELL) return "shell"; + if (category == STORAGE_BATTLE_DATA) return "battleData"; + if (category == STORAGE_CONFIG_HEADER) return "configHeader"; + if (category == STORAGE_STATIC_CATALOG) return "staticCatalog"; + if (category == STORAGE_MON_STATE) return "monState"; + if (category == STORAGE_EFFECTS) return "effects"; + if (category == STORAGE_BOOSTS) return "boosts"; + if (category == STORAGE_GLOBAL_KV) return "globalKV"; + return "other"; + } + + function _setFirstDoublesReplayTeams() private { + uint256[] memory p0Ids = new uint256[](4); + p0Ids[0] = 4; // Gorillax + p0Ids[1] = 2; // Malalien + p0Ids[2] = 5; // Sofabbi + p0Ids[3] = 11; // Ekineki + uint256[] memory p1Ids = new uint256[](4); + p1Ids[0] = 0; // Ghouliath + p1Ids[1] = 3; // Iblivion + p1Ids[2] = 10; // Xmon + p1Ids[3] = 5; // Sofabbi + uint8[] memory p0Facets = new uint8[](4); + uint8[] memory p1Facets = new uint8[](4); + for (uint256 i; i < 4; i++) { + p0Facets[i] = 6; + } + gachaReg.setTeamForUser(p0, 0, p0Ids, p0Facets); + gachaReg.setTeamForUser(p1, 0, p1Ids, p1Facets); + } + + function _startDoublesBattle() private returns (bytes32 key) { + address[] memory makersToAdd = new address[](1); + makersToAdd[0] = address(maker); + vm.prank(p0); + engine.updateMatchmakers(makersToAdd, new address[](0)); + vm.prank(p1); + engine.updateMatchmakers(makersToAdd, new address[](0)); + bytes32 pairHash; + (key, pairHash) = engine.computeBattleKey(p0, p1); + BattleOffer memory offer = BattleOffer({ + battle: Battle({ + p0: p0, + p0TeamIndex: 0, + p1: p1, + p1TeamIndex: 0, + p2: address(0), + p2TeamIndex: 0, + p3: address(0), + p3TeamIndex: 0, + teamRegistry: gachaReg, + rngOracle: IRandomnessOracle(address(0)), + ruleset: IRuleset(INLINE_STAMINA_REGEN_RULESET), + moveManager: address(mgr), + matchmaker: maker, + engineHooks: new IEngineHook[](0) + }), + pairHashNonce: engine.pairHashNonces(pairHash), + battleMode: BATTLE_MODE_DOUBLES + }); + bytes32 digest = maker.hashTypedData(BattleOfferLib.hashBattleOfferForSigning(offer, 0)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(P0_PK, digest); + bytes[4] memory seatSigs; + seatSigs[0] = abi.encodePacked(r, s, v); + vm.prank(p1); + maker.startGame(offer, 0, seatSigs); + } + + function _firstDoublesReplayEntries() private pure returns (uint256[] memory e) { + e = new uint256[](20); + e[0] = sideWord(SWITCH_MOVE_INDEX, 1, SWITCH_MOVE_INDEX, 3, 629152483201830402240141); + e[1] = sideWord(SWITCH_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 1, 0); + e[2] = sideWord(1, 32768, 0, 32768, 288092756437916069539032); + e[3] = sideWord(1, 4096, 1, 0, 0); + e[4] = sideWord(NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, 0); + e[5] = sideWord(NO_OP_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 2, 0); + e[6] = sideWord(1, 32768, 0, 32768, 874957430843907341896228); + e[7] = sideWord(1, 4096, 1, 4096, 0); + e[8] = sideWord(NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, 0); + e[9] = sideWord(NO_OP_MOVE_INDEX, 0, SWITCH_MOVE_INDEX, 3, 0); + e[10] = sideWord(NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, 1115499015655134625153807); + e[11] = sideWord(3, 4096, 2, 8192, 0); + e[12] = sideWord(SWITCH_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, 74943448586572800650145); + e[13] = sideWord(NO_OP_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0, 0); + e[14] = sideWord(2, 32768, 0, 16384, 315825775983314630690957); + e[15] = sideWord(3, 8192, NO_OP_MOVE_INDEX, 0, 0); + e[16] = sideWord(2, 32768, NO_OP_MOVE_INDEX, 0, 363940942254872708793045); + e[17] = sideWord(3, 4096, 3, 0, 0); + e[18] = sideWord(2, 32768, 0, 32768, 191897147809779057662496); + e[19] = sideWord(3, 4096, 2, 4096, 0); + } + + function _replayDoublesOneTx(bytes32 battleKey, bool measure) private returns (uint256 execGas) { + uint256[] memory entries = _firstDoublesReplayEntries(); + bytes memory cd = abi.encodeCall(engine.executeBatchedSlotTurns, (battleKey, entries)); + if (measure) { + vm.cool(address(engine)); + } + vm.prank(address(mgr)); + uint256 g0 = gasleft(); + (bool ok,) = address(engine).call(cd); + uint256 raw = g0 - gasleft(); + require(ok, "doubles replay reverted"); + if (measure) { + execGas = raw + _calldataCost(cd) + TX_BASE; + } + engine.resetCallContext(); + } + + function _replayDoublesRecorded(bytes32 battleKey) private returns (Vm.AccountAccess[] memory accesses) { + uint256[] memory entries = _firstDoublesReplayEntries(); + bytes memory cd = abi.encodeCall(engine.executeBatchedSlotTurns, (battleKey, entries)); + vm.cool(address(engine)); + vm.startStateDiffRecording(); + vm.prank(address(mgr)); + (bool ok,) = address(engine).call(cd); + require(ok, "recorded doubles replay reverted"); + accesses = vm.stopAndReturnStateDiff(); + engine.resetCallContext(); + } + + function _finishIncompleteReplay(bytes32 battleKey) private { + if (engine.getWinner(battleKey) == address(0)) { + vm.prank(p0); + engine.forfeit(battleKey); + } + } + + function _assertExactEffectStepLanes(bytes32 battleKey) private view { + (BattleConfigView memory cfg,) = engine.getBattle(battleKey); + for (uint256 side; side < 2; side++) { + EffectInstance[][] memory effects = side == 0 ? cfg.p0Effects : cfg.p1Effects; + for (uint256 mon; mon < effects.length; mon++) { + uint16 expected; + for (uint256 i; i < effects[mon].length; i++) { + expected |= effects[mon][i].stepsBitmap; + } + uint256 shift = ((side << 3) | mon) << 4; + assertEq(uint16(cfg.playerEffectStepsByMon >> shift), expected, "effect-step lane mismatch"); + } + } + } + + // --------------------------------------------------------------------------------------------- + // R2.0 — turn-pipeline floor: same rosters/config, but the turns carry ZERO content + // (turn 0 = send-ins, then NO_OPs: no moves, no effects, no damage). Prices the fixed + // per-turn engine cost so the replay's per-turn self-gas can be split into floor vs content. + // Raw exec gas, warm storage, no vm.cool — comparable to trace frame-self numbers. + // --------------------------------------------------------------------------------------------- + + function _packEntry(uint8 p0Move, uint16 p0Extra, uint104 p0Salt, uint8 p1Move, uint16 p1Extra, uint104 p1Salt) + internal + pure + returns (uint256) + { + return uint256(p0Move) | (uint256(p0Extra) << 8) | (uint256(p0Salt) << 24) | (uint256(p1Move) << 128) + | (uint256(p1Extra) << 136) | (uint256(p1Salt) << 152); + } + + function _noopEntries(uint256 n, uint256 saltBase) internal pure returns (uint256[] memory e) { + e = new uint256[](n); + for (uint256 i; i < n; i++) { + e[i] = _packEntry( + NO_OP_MOVE_INDEX, 0, uint104(saltBase + 2 * i), NO_OP_MOVE_INDEX, 0, uint104(saltBase + 2 * i + 1) + ); + } + } + + function _execBatch(bytes32 battleKey, uint256[] memory entries) internal returns (uint256 raw) { + bytes memory cd = abi.encodeCall(engine.executeBatchedTurns, (battleKey, entries)); + vm.prank(address(mgr)); + uint256 g0 = gasleft(); + (bool ok,) = address(engine).call(cd); + raw = g0 - gasleft(); + require(ok, "batch reverted"); + engine.resetCallContext(); + } + + function test_noopFloor_batchedExecute() public { + vm.warp(vm.getBlockTimestamp() + 1); + + // Battle 1: the real replay, unmeasured — completes and frees the storageKey. + bytes32 key1 = _startBattle(); + vm.warp(vm.getBlockTimestamp() + 1); + _replayOneTx(key1, false); + require(engine.getWinner(key1) != address(0), "warmup replay must finish"); + + // Battle 2 (reused key): send-ins, then 1-noop and 25-noop batches to split + // per-batch fixed cost from per-turn cost. + bytes32 key2 = _startBattle(); + require(engine.getStorageKey(key1) == engine.getStorageKey(key2), "storageKey must be reused"); + vm.warp(vm.getBlockTimestamp() + 1); + uint256[] memory sw = new uint256[](1); + sw[0] = _packEntry(SWITCH_MOVE_INDEX, 0, 11, SWITCH_MOVE_INDEX, 0, 12); + uint256 switchTurn = _execBatch(key2, sw); + uint256 t1 = _execBatch(key2, _noopEntries(1, 100)); + uint256 t25 = _execBatch(key2, _noopEntries(25, 300)); + uint256 perTurn = t25 / 25; + + // Battle 3 (reused again after forfeit): the whole no-op game in ONE batch, mirroring + // the replay's shape (1 tx, 27 entries) for a like-for-like total. Capture the storage + // key BEFORE forfeit — freeing deletes the battleKey mapping (identity fallback after). + bytes32 liveStorageKey = engine.getStorageKey(key2); + vm.prank(p0); + engine.forfeit(key2); + bytes32 key3 = _startBattle(); + require(engine.getStorageKey(key3) == liveStorageKey, "storageKey must be reused (3)"); + vm.warp(vm.getBlockTimestamp() + 1); + uint256[] memory whole = new uint256[](27); + whole[0] = _packEntry(SWITCH_MOVE_INDEX, 0, 13, SWITCH_MOVE_INDEX, 0, 14); + uint256[] memory noops = _noopEntries(26, 500); + for (uint256 i; i < 26; i++) { + whole[i + 1] = noops[i]; + } + uint256 t27 = _execBatch(key3, whole); + + vm.snapshotValue("SinglesFloor_SendIn_rawGas", switchTurn); + vm.snapshotValue("SinglesFloor_OneNoop_rawGas", t1); + vm.snapshotValue("SinglesFloor_25Noops_rawGas", t25); + vm.snapshotValue("SinglesFloor_Noop_perTurn", perTurn); + vm.snapshotValue("SinglesFloor_BatchFixed", t1 - perTurn); + vm.snapshotValue("SinglesFloor_Whole27_rawGas", t27); + + console.log(""); + console.log("=== R2.0 no-op floor (reused key, raw exec gas, warm) ==="); + console.log(" send-in turn (batch of 1) :", switchTurn); + console.log(" 1 no-op turn (batch of 1) :", t1); + console.log(" 25 no-op turns (one batch) :", t25); + console.log(" floor per no-op turn (t25/25) :", perTurn); + console.log(" per-batch fixed (t1 - floor) :", t1 - perTurn); + console.log(" whole no-op game, 1 batch of 27 :", t27); + } + + function test_mainnetReplay_batchedExecute() public { + vm.warp(vm.getBlockTimestamp() + 1); + + // ---- VIRGIN storageKey (fresh, every execute SSTORE is 0->nonzero) ---- + bytes32 key1 = _startBattle(); + vm.warp(vm.getBlockTimestamp() + 1); + uint256 virginGas = _replayOneTx(key1, true); + address winner1 = engine.getWinner(key1); + require(winner1 != address(0), "replay must reach game over (faithful)"); + + // Reaching game over freed key1's storageKey back into the pool. + // ---- REUSED storageKey (steady state, execute SSTOREs are nonzero->nonzero) ---- + bytes32 key2 = _startBattle(); + require(engine.getStorageKey(key1) == engine.getStorageKey(key2), "storageKey must be reused"); + vm.warp(vm.getBlockTimestamp() + 1); + uint256 reusedGas = _replayOneTx(key2, true); + require(engine.getWinner(key2) != address(0), "reused replay must also reach game over"); + + // State-diff recording perturbs Foundry's raw gas scalar, so collect the census on a + // third, storage-congruent replay and never use its gas result as a KPI. + bytes32 recycledStorageKey = engine.getStorageKey(key1); + bytes32 key3 = _startBattle(); + require(engine.getStorageKey(key3) == recycledStorageKey, "storageKey must be reused for census"); + vm.warp(vm.getBlockTimestamp() + 1); + Vm.AccountAccess[] memory accesses = _replayOneTxRecorded(key3); + require(engine.getWinner(key3) != address(0), "census replay must also reach game over"); + ReplayCensus memory census = + _recordReplayCensus("MainnetReplay_Reused", key3, accesses, recycledStorageKey, reusedGas); + + console.log(""); + console.log("=== MAINNET replay (27 batched turns), prod config (inline regen) ==="); + console.log(" VIRGIN storageKey (cold, first battle) execGas :", virginGas); + console.log(" REUSED storageKey (steady state) execGas :", reusedGas); + console.log(" virgin - reused (recycling saving) :", virginGas - reusedGas); + console.log( + " player effect header/data reads :", + census.effectStorage.headerReads, + census.effectStorage.dataReads + ); + console.log( + " hooks RoundStart/RoundEnd/AfterMove :", + census.roundStartCalls, + census.roundEndCalls, + census.afterMoveCalls + ); + console.log( + " hooks PreDamage/AfterDamage/UpdateState :", + census.preDamageCalls, + census.afterDamageCalls, + census.updateStateCalls + ); + console.log( + " callbacks moveDecision/statusClass :", + census.moveDecisionCallbacks, + census.statusClassCallbacks + ); + console.log(" non-hook effect header read upper bound :", census.nonHookHeaderReadUpperBound); + _logFrameCensus(census); + } + + /// @dev Real submitted move/target/salt sequence from `replays.txt`. The historical deployed + /// catalog state is not yet available locally, so this is an action-mix gas replay rather + /// than a winner-faithful replay. It intentionally receives the same reused-key+census + /// treatment as singles; forfeit only recycles the key after the measured bracket. + function test_mainnetDoublesReplay1_batchedExecute() public { + _setFirstDoublesReplayTeams(); + vm.warp(vm.getBlockTimestamp() + 1); + + bytes32 key1 = _startDoublesBattle(); + bytes32 recycledStorageKey = engine.getStorageKey(key1); + vm.warp(vm.getBlockTimestamp() + 1); + _replayDoublesOneTx(key1, false); + assertEq(engine.getTurnIdForBattleState(key1), 10, "all real doubles turns execute"); + _finishIncompleteReplay(key1); + + bytes32 key2 = _startDoublesBattle(); + require(engine.getStorageKey(key2) == recycledStorageKey, "doubles storageKey must be reused"); + vm.warp(vm.getBlockTimestamp() + 1); + uint256 reusedGas = _replayDoublesOneTx(key2, true); + assertEq(engine.getTurnIdForBattleState(key2), 10, "all reused doubles turns execute"); + _finishIncompleteReplay(key2); + + bytes32 key3 = _startDoublesBattle(); + require(engine.getStorageKey(key3) == recycledStorageKey, "doubles storageKey must be reused for census"); + (BattleConfigView memory freshCfg,) = engine.getBattle(key3); + assertEq(freshCfg.playerEffectStepsByMon, 0, "recycled step lanes must reset"); + vm.warp(vm.getBlockTimestamp() + 1); + Vm.AccountAccess[] memory accesses = _replayDoublesRecorded(key3); + assertEq(engine.getTurnIdForBattleState(key3), 10, "all census doubles turns execute"); + _assertExactEffectStepLanes(key3); + ReplayCensus memory census = + _recordReplayCensus("MainnetDoublesReplay1_Reused", key3, accesses, recycledStorageKey, reusedGas); + _finishIncompleteReplay(key3); + + console.log(""); + console.log("=== MAINNET doubles replay 1 (10 submitted turns, reused key) ==="); + console.log(" execGas :", reusedGas); + console.log( + " player effect header/data reads :", + census.effectStorage.headerReads, + census.effectStorage.dataReads + ); + console.log( + " hooks RoundStart/RoundEnd/AfterMove :", + census.roundStartCalls, + census.roundEndCalls, + census.afterMoveCalls + ); + console.log( + " callbacks moveDecision/statusClass :", + census.moveDecisionCallbacks, + census.statusClassCallbacks + ); + console.log(" non-hook effect header read upper bound :", census.nonHookHeaderReadUpperBound); + _logFrameCensus(census); + } +} diff --git a/test/SignedAssignerTest.sol b/test/SignedAssignerTest.sol new file mode 100644 index 00000000..deeaa3e3 --- /dev/null +++ b/test/SignedAssignerTest.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; + +import "../src/Constants.sol"; + +import {Engine} from "../src/Engine.sol"; +import {GachaTeamRegistry} from "../src/game-layer/GachaTeamRegistry.sol"; +import {PlayerProfile} from "../src/game-layer/PlayerProfile.sol"; +import {SignedAssigner} from "../src/game-layer/SignedAssigner.sol"; +import {ECDSA} from "../src/lib/ECDSA.sol"; + +import {MockGachaRNG} from "./mocks/MockGachaRNG.sol"; + +contract SignedAssignerTest is Test { + address constant ALICE = address(0xA1); + address constant BOB = address(0xB0); + + uint256 constant SIGNER_PK = 0x5160E; + uint256 constant IMPOSTOR_PK = 0xBAD; + uint256 constant AMOUNT = 16; + + /// @dev Order of the secp256k1 curve; used to malleate a signature. + uint256 constant SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141; + + bytes32 constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 constant CLAIM_TYPEHASH = keccak256("Claim(address recipient,uint256 amount,uint256 claimId)"); + + GachaTeamRegistry registry; + SignedAssigner assigner; + Engine engine; + MockGachaRNG mockRNG; + address signer; + + function setUp() public { + signer = vm.addr(SIGNER_PK); + engine = new Engine(GAME_MONS_PER_TEAM, GAME_MOVES_PER_MON); + mockRNG = new MockGachaRNG(); + registry = new GachaTeamRegistry(4, 1, engine, mockRNG, GachaTeamRegistry(address(0))); + assigner = new SignedAssigner(signer, address(registry)); + _setAssigner(address(assigner), true); + } + + function _setAssigner(address who, bool allowed) internal { + address[] memory on = new address[](allowed ? 1 : 0); + address[] memory off = new address[](allowed ? 0 : 1); + (allowed ? on : off)[0] = who; + registry.setAssigners(on, off); + } + + // Rebuilt here rather than read off the contract, so a domain or typehash change fails. + function _digest(address recipient, uint256 amount, uint256 claimId) internal view returns (bytes32) { + bytes32 domainSeparator = keccak256( + abi.encode( + DOMAIN_TYPEHASH, keccak256("SignedAssigner"), keccak256("1"), block.chainid, address(assigner) + ) + ); + bytes32 structHash = keccak256(abi.encode(CLAIM_TYPEHASH, recipient, amount, claimId)); + return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + function _signParts(uint256 pk, address recipient, uint256 amount, uint256 claimId) + internal + view + returns (uint8 v, bytes32 r, bytes32 s) + { + return vm.sign(pk, _digest(recipient, amount, claimId)); + } + + function _sign(uint256 pk, address recipient, uint256 amount, uint256 claimId) + internal + view + returns (bytes memory) + { + (uint8 v, bytes32 r, bytes32 s) = _signParts(pk, recipient, amount, claimId); + return abi.encodePacked(r, s, v); + } + + function _sign(address recipient, uint256 amount, uint256 claimId) internal view returns (bytes memory) { + return _sign(SIGNER_PK, recipient, amount, claimId); + } + + /// Anyone may submit; the signed recipient is paid either way (BOB here drives the relay path). + function test_claimPaysSignedRecipient() public { + vm.expectEmit(true, true, true, true); + emit SignedAssigner.Claimed(ALICE, AMOUNT, 1); + vm.prank(BOB); + assigner.claim(ALICE, AMOUNT, 1, _sign(ALICE, AMOUNT, 1)); + + assertEq(registry.pointsBalance(ALICE), AMOUNT); + assertEq(registry.pointsBalance(BOB), 0); + assertTrue(assigner.isSpent(1)); + } + + /// Regression: the nullifier must key on claimId, not the signature bytes. ECDSA signatures + /// are malleable, so a signature-keyed nullifier lets a flipped s replay the same grant. + function test_malleatedSignatureCannotReplay() public { + (uint8 v, bytes32 r, bytes32 s) = _signParts(SIGNER_PK, ALICE, AMOUNT, 1); + assigner.claim(ALICE, AMOUNT, 1, abi.encodePacked(r, s, v)); + + bytes memory malleated = abi.encodePacked(r, bytes32(SECP256K1_N - uint256(s)), v == 27 ? uint8(28) : uint8(27)); + assertTrue( + keccak256(malleated) != keccak256(abi.encodePacked(r, s, v)), "malleated signature bytes must differ" + ); + + vm.expectRevert(SignedAssigner.ClaimAlreadySpent.selector); + assigner.claim(ALICE, AMOUNT, 1, malleated); + assertEq(registry.pointsBalance(ALICE), AMOUNT); + } + + /// The same (recipient, amount) must stay grantable under a fresh claimId. + function test_sameAmountRepeatableWithNewClaimId() public { + assigner.claim(ALICE, AMOUNT, 1, _sign(ALICE, AMOUNT, 1)); + assigner.claim(ALICE, AMOUNT, 2, _sign(ALICE, AMOUNT, 2)); + + assertEq(registry.pointsBalance(ALICE), AMOUNT * 2); + } + + /// Every field is inside the digest, and only SIGNER's key opens it. + function test_signedFieldsAreBound() public { + bytes memory sig = _sign(ALICE, AMOUNT, 1); + + vm.expectRevert(ECDSA.InvalidSignature.selector); + assigner.claim(BOB, AMOUNT, 1, sig); + + vm.expectRevert(ECDSA.InvalidSignature.selector); + assigner.claim(ALICE, AMOUNT * 100, 1, sig); + + vm.expectRevert(ECDSA.InvalidSignature.selector); + assigner.claim(ALICE, AMOUNT, 2, sig); + + vm.expectRevert(ECDSA.InvalidSignature.selector); + assigner.claim(ALICE, AMOUNT, 1, _sign(IMPOSTOR_PK, ALICE, AMOUNT, 1)); + } + + /// Signer-compromise recovery: revoking the allowlist entry kills unspent claims. + function test_removedAssignerReverts() public { + bytes memory sig = _sign(ALICE, AMOUNT, 1); + _setAssigner(address(assigner), false); + + vm.expectRevert(PlayerProfile.NotAssigner.selector); + assigner.claim(ALICE, AMOUNT, 1, sig); + } + + /// Ids sharing a word (0/255, 256/257) and crossing one (255/256) stay independent. + function test_bitmapWordBoundary() public { + uint256[4] memory ids = [uint256(0), 255, 256, 257]; + for (uint256 i; i < ids.length; ++i) { + assigner.claim(ALICE, AMOUNT, ids[i], _sign(ALICE, AMOUNT, ids[i])); + assertTrue(assigner.isSpent(ids[i])); + } + assertEq(registry.pointsBalance(ALICE), AMOUNT * ids.length); + + for (uint256 i; i < ids.length; ++i) { + vm.expectRevert(SignedAssigner.ClaimAlreadySpent.selector); + assigner.claim(ALICE, AMOUNT, ids[i], _sign(ALICE, AMOUNT, ids[i])); + } + } + + function testFuzz_claimIdSpendableOnce(uint256 claimId, uint128 amount) public { + vm.assume(amount > 0); + bytes memory sig = _sign(ALICE, amount, claimId); + + assigner.claim(ALICE, amount, claimId, sig); + assertEq(registry.pointsBalance(ALICE), amount); + + vm.expectRevert(SignedAssigner.ClaimAlreadySpent.selector); + assigner.claim(ALICE, amount, claimId, sig); + } +} diff --git a/test/SignedCommitManager.t.sol b/test/SignedCommitManager.t.sol index 50b8009f..cd1d51c7 100644 --- a/test/SignedCommitManager.t.sol +++ b/test/SignedCommitManager.t.sol @@ -754,9 +754,9 @@ contract SignedCommitManagerEngineSafetyTest is SignedCommitManagerTestBase { _executeDualSigned(battleKey, 0, SWITCH_MOVE_INDEX, uint16(1), NO_OP_MOVE_INDEX, 0); assertEq(engine.getTurnIdForBattleState(battleKey), 1, "turn should advance"); - uint256[] memory active = engine.getActiveMonIndexForBattleState(battleKey); - assertEq(active[0], 1, "p0 should have switched in mon 1 via valid SWITCH"); - assertEq(active[1], 0, "p1 should have been force-switched to mon 0"); + BattleContext memory active = engine.getBattleContext(battleKey); + assertEq(active.p0ActiveMonIndex, 1, "p0 should have switched in mon 1 via valid SWITCH"); + assertEq(active.p1ActiveMonIndex, 0, "p1 should have been force-switched to mon 0"); } /// @notice Turn 0 with a regular (attack) moveIndex must also coerce, not try to run the attack. @@ -767,9 +767,9 @@ contract SignedCommitManagerEngineSafetyTest is SignedCommitManagerTestBase { _executeDualSigned(battleKey, 0, 0, 0, 0, 0); assertEq(engine.getTurnIdForBattleState(battleKey), 1, "turn should advance"); - uint256[] memory active = engine.getActiveMonIndexForBattleState(battleKey); - assertEq(active[0], 0, "p0 force-switched to mon 0"); - assertEq(active[1], 0, "p1 force-switched to mon 0"); + BattleContext memory active = engine.getBattleContext(battleKey); + assertEq(active.p0ActiveMonIndex, 0, "p0 force-switched to mon 0"); + assertEq(active.p1ActiveMonIndex, 0, "p1 force-switched to mon 0"); } /// @notice Regular move with an out-of-bounds moveIndex must silently no-op rather than revert @@ -806,8 +806,8 @@ contract SignedCommitManagerEngineSafetyTest is SignedCommitManagerTestBase { _executeDualSigned(battleKey, 1, SWITCH_MOVE_INDEX, uint16(99), NO_OP_MOVE_INDEX, 0); assertEq(engine.getTurnIdForBattleState(battleKey), 2, "turn should advance"); - uint256[] memory active = engine.getActiveMonIndexForBattleState(battleKey); - assertEq(active[1], 0, "p1 still on mon 0 - OOB switch silently no-oped"); + BattleContext memory active = engine.getBattleContext(battleKey); + assertEq(active.p1ActiveMonIndex, 0, "p1 still on mon 0 - OOB switch silently no-oped"); } /// @notice Switch to the same mon on a non-turn-0 must silently no-op. @@ -816,14 +816,14 @@ contract SignedCommitManagerEngineSafetyTest is SignedCommitManagerTestBase { // Turn 0: p0 switches to mon 1, p1 to mon 0. _executeDualSigned(battleKey, 0, SWITCH_MOVE_INDEX, uint16(1), SWITCH_MOVE_INDEX, 0); - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[0], 1); + assertEq(engine.getBattleContext(battleKey).p0ActiveMonIndex, 1); // Turn 1: p1 is committer. p1 tries to switch to their own active mon (0). p0 NO_OP. _executeDualSigned(battleKey, 1, SWITCH_MOVE_INDEX, 0, NO_OP_MOVE_INDEX, 0); assertEq(engine.getTurnIdForBattleState(battleKey), 2, "turn should advance"); - uint256[] memory active = engine.getActiveMonIndexForBattleState(battleKey); - assertEq(active[1], 0, "p1 still on mon 0 - same-mon switch silent no-op"); + BattleContext memory active = engine.getBattleContext(battleKey); + assertEq(active.p1ActiveMonIndex, 0, "p1 still on mon 0 - same-mon switch silent no-op"); } function test_revertBattleNotCommiter() public { diff --git a/test/StatBoostAccParity.t.sol b/test/StatBoostAccParity.t.sol new file mode 100644 index 00000000..c1222fa9 --- /dev/null +++ b/test/StatBoostAccParity.t.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; + +import {MonStateIndexName, StatBoostType} from "../src/Enums.sol"; +import {StatBoostToApply} from "../src/Structs.sol"; +import {StatBoostLib} from "../src/lib/StatBoostLib.sol"; + +/// @notice R1.b parity invariant: the packed accumulator (incremental multiply-in / divide-out) +/// must finalize to EXACTLY the stats the legacy full recompute produces, for any source +/// set that fits the lanes — including after removing an arbitrary source. +contract StatBoostAccParity is Test { + function _mkWord(uint256 seed, uint168 key) internal pure returns (bytes32) { + // 1-2 boosted lanes per source, pct 1..100, count 1..2, mixed mul/div — the realistic + // envelope (prod uses 15-50% single-lane boosts). + uint256 numLanes = 1 + (seed & 1); + StatBoostToApply[] memory boosts = new StatBoostToApply[](numLanes); + for (uint256 i; i < numLanes; ++i) { + uint256 s = uint256(keccak256(abi.encode(seed, i))); + boosts[i] = StatBoostToApply({ + // Distinct stat per lane (well-formed input, like prod callers): packBoostData + // ORs lanes, so a same-stat collision would corrupt the word. + stat: MonStateIndexName(uint8(2 + ((seed + i) % 5))), + boostPercent: uint8(1 + (s >> 8) % 100), + boostType: (s >> 16) & 1 == 0 ? StatBoostType.Multiply : StatBoostType.Divide + }); + } + return StatBoostLib.packBoostData(key, seed & 2 == 0, boosts); + } + + function _referenceStats(bytes32[] memory words, uint256 skipIdx, uint32[5] memory baseStats) + internal + pure + returns (uint32[5] memory) + { + uint32[5] memory numBoosts; + uint256[5] memory accNum; + for (uint256 i; i < words.length; ++i) { + if (i == skipIdx) { + continue; + } + (,, uint8[5] memory bp, uint8[5] memory bc, bool[5] memory im) = StatBoostLib.unpackBoostData(words[i]); + StatBoostLib.accumulateBoosts(baseStats, bp, bc, im, numBoosts, accNum); + } + return StatBoostLib.finalizeBoostedStats(baseStats, numBoosts, accNum); + } + + function test_repro() public pure { + _run(3, 12, 41968); + } + + /// Regression: a 100% Divide zeroes a lane's numerator. The recompute used to read a 0 + /// numerator as "lane not seeded yet" and restart from baseStats, resurrecting the stat and + /// diverging from the accumulator (which seeds off the boost count). + function test_zeroedLaneDoesNotReseedFromBase() public pure { + _run(14, 179, 9452); + } + + /// boostPercent is a uint8, so a Divide can be handed anything up to 255. `DENOM - pct` would + /// underflow and revert (wedging the battle mid-move), and a factor of 0 would zero the numerator + /// into a state the (numerator, count) aggregation cannot represent or invert. Both saturate. + function test_divideSaturatesInsteadOfRevertingOrZeroing() public pure { + assertEq(StatBoostLib.boostFactor(50, false), 50, "ordinary divide"); + assertEq(StatBoostLib.boostFactor(99, false), 1, "max representable reduction"); + assertEq(StatBoostLib.boostFactor(100, false), 1, "100% divide floors, not zeroes"); + assertEq(StatBoostLib.boostFactor(255, false), 1, "uint8 max divide floors, not underflows"); + assertEq(StatBoostLib.boostFactor(50, true), 150, "multiply unaffected"); + + // End to end: a 255% Divide folds in, stays invertible, and both paths agree. + uint32[5] memory baseStats = [uint32(100), 100, 100, 100, 100]; + StatBoostToApply[] memory boosts = new StatBoostToApply[](1); + boosts[0] = StatBoostToApply({ + stat: MonStateIndexName(2), boostPercent: 255, boostType: StatBoostType.Divide + }); + bytes32[] memory words = new bytes32[](1); + words[0] = StatBoostLib.packBoostData(uint168(1), true, boosts); + + (uint256 acc, bool ok) = StatBoostLib.applyWordToAcc(0, words[0], baseStats, true); + assertTrue(ok, "extreme divide folds in"); + uint32[5] memory fast = StatBoostLib.finalizeAccStats(acc, baseStats); + uint32[5] memory ref = _referenceStats(words, type(uint256).max, baseStats); + for (uint256 k; k < 5; ++k) { + assertEq(fast[k], ref[k], "extreme divide parity"); + } + + // And it divides back out cleanly rather than refusing. + (uint256 acc2, bool ok2) = StatBoostLib.applyWordToAcc(acc, words[0], baseStats, false); + assertTrue(ok2, "extreme divide inverts"); + uint32[5] memory restored = StatBoostLib.finalizeAccStats(acc2, baseStats); + for (uint256 k; k < 5; ++k) { + assertEq(restored[k], baseStats[k], "removing the source restores base"); + } + } + + function testFuzz_accMatchesRecompute(uint256 seed, uint8 numSourcesRaw, uint16 baseRaw) public pure { + _run(seed, numSourcesRaw, baseRaw); + } + + function _run(uint256 seed, uint8 numSourcesRaw, uint16 baseRaw) internal pure { + uint256 numSources = 1 + (numSourcesRaw % 5); + uint32 base = 1 + uint32(baseRaw) % 1000; + uint32[5] memory baseStats = [base, base + 7, base + 13, base + 29, base + 51]; + + bytes32[] memory words = new bytes32[](numSources); + uint256 acc; + bool allOk = true; + for (uint256 i; i < numSources; ++i) { + words[i] = _mkWord(uint256(keccak256(abi.encode(seed, "w", i))), uint168(uint160(i + 1))); + bool ok; + (acc, ok) = StatBoostLib.applyWordToAcc(acc, words[i], baseStats, true); + allOk = allOk && ok; + } + // Realistic envelopes always fit; if a pathological draw overflowed, the Engine would + // have disabled + recomputed (parity by construction) — nothing to compare here. + vm.assume(allOk); + + uint32[5] memory fast = StatBoostLib.finalizeAccStats(acc, baseStats); + uint32[5] memory ref = _referenceStats(words, type(uint256).max, baseStats); + for (uint256 k; k < 5; ++k) { + assertEq(fast[k], ref[k], "add parity"); + } + + // Divide an arbitrary source back out: must equal recompute-without-it. The one designed + // refusal is a factor-0 lane (100% Divide) — the Engine disables the accumulator there + // and recomputes from sources, which IS the reference; nothing to compare. + uint256 removeIdx = uint256(keccak256(abi.encode(seed, "r"))) % numSources; + (uint256 acc2, bool ok2) = StatBoostLib.applyWordToAcc(acc, words[removeIdx], baseStats, false); + if (!ok2) { + return; + } + uint32[5] memory fast2 = StatBoostLib.finalizeAccStats(acc2, baseStats); + uint32[5] memory ref2 = _referenceStats(words, removeIdx, baseStats); + for (uint256 k; k < 5; ++k) { + assertEq(fast2[k], ref2[k], "remove parity"); + } + } +} diff --git a/test/StatBoostMicrobench.t.sol b/test/StatBoostMicrobench.t.sol new file mode 100644 index 00000000..149a4c4d --- /dev/null +++ b/test/StatBoostMicrobench.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; + +import "../src/Constants.sol"; +import "../src/Enums.sol"; +import "../src/Structs.sol"; + +import {Engine} from "../src/Engine.sol"; +import {IEngine} from "../src/IEngine.sol"; +import {DefaultCommitManager} from "../src/commit-manager/DefaultCommitManager.sol"; +import {DefaultMatchmaker} from "../src/matchmaker/DefaultMatchmaker.sol"; +import {BattleHelper} from "./abstract/BattleHelper.sol"; +import {BoostBenchMove} from "./mocks/BoostBenchMove.sol"; +import {MockRandomnessOracle} from "./mocks/MockRandomnessOracle.sol"; +import {TestTeamRegistry} from "./mocks/TestTeamRegistry.sol"; + +/// @notice R1.0 — cost of one addStatBoost / removeStatBoost as a function of K existing +/// sources on the mon (fresh engine per K, so adds are virgin-lane; the K-slope is +/// the scan/unpack/aggregate growth, the K=0 intercept is entry-write + telescope + +/// call overhead). +contract StatBoostMicrobench is Test, BattleHelper { + function _runK(uint256 k) internal returns (uint256 addGas, uint256 removeGas) { + MockRandomnessOracle oracle = new MockRandomnessOracle(); + TestTeamRegistry registry = new TestTeamRegistry(); + Engine engine = new Engine(GAME_MONS_PER_TEAM, GAME_MOVES_PER_MON); + DefaultCommitManager mgr = new DefaultCommitManager(IEngine(address(engine))); + DefaultMatchmaker maker = new DefaultMatchmaker(engine); + BoostBenchMove bench = new BoostBenchMove(); + + uint256[] memory moves = new uint256[](1); + moves[0] = uint256(uint160(address(bench))); + Mon memory mon = Mon({ + stats: MonStats({ + hp: 100, + stamina: 100, + speed: 100, + attack: 100, + defense: 100, + specialAttack: 100, + specialDefense: 100, + type1: Type.Fire, + type2: Type.None + }), + moves: moves, + ability: 0 + }); + Mon[] memory team = new Mon[](1); + team[0] = mon; + registry.setTeam(ALICE, team); + registry.setTeam(BOB, team); + + bytes32 battleKey = _startBattle(engine, oracle, registry, maker, address(mgr)); + _commitRevealExecuteForAliceAndBob( + engine, mgr, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) + ); + _commitRevealExecuteForAliceAndBob(engine, mgr, battleKey, 0, NO_OP_MOVE_INDEX, uint16(k), 0); + addGas = bench.lastAddGas(); + removeGas = bench.lastRemoveGas(); + } + + function test_microbench_addRemoveByExistingSources() public { + console.log(""); + console.log("=== R1.0 stat-boost microbench (virgin lanes; K = existing sources) ==="); + for (uint256 k; k <= 4; k++) { + (uint256 a, uint256 r) = _runK(k); + console.log(" K =", k); + console.log(" addStatBoost :", a); + console.log(" removeStatBoost:", r); + } + } +} diff --git a/test/abstract/GasMeasure.sol b/test/abstract/GasMeasure.sol index 95fc4ead..b88b44e8 100644 --- a/test/abstract/GasMeasure.sol +++ b/test/abstract/GasMeasure.sol @@ -2,7 +2,9 @@ pragma solidity ^0.8.0; import {CommonBase} from "../../lib/forge-std/src/Base.sol"; -import {Vm} from "../../lib/forge-std/src/Vm.sol"; +import {Vm, VmSafe} from "../../lib/forge-std/src/Vm.sol"; + +import {EFFECT_SLOTS_PER_MON} from "../../src/Constants.sol"; /// @notice Shared production-faithful gas measurement: a deterministic storage/account access /// tally per measured window (one prod-tx-equivalent unit), plus a synthetic @@ -17,6 +19,16 @@ import {Vm} from "../../lib/forge-std/src/Vm.sol"; /// pure compute (non-semantic), polluting cross-version scalar diffs. The tally is /// immune to all three: opcode counts are warmth- and layout-independent. abstract contract GasMeasure is CommonBase { + // Engine storage roots and BattleConfig-relative mapping offsets are compiler-layout + // facts, not production constants. Keep them test-only: the census tests assert the + // expected reads are non-zero, so a layout change fails loudly instead of silently + // emitting a misleading zero. See `forge inspect Engine storage-layout`. + uint256 private constant ENGINE_BATTLE_CONFIG_ROOT = 7; + uint256 private constant CONFIG_P0_STATES_OFFSET = 9; + uint256 private constant CONFIG_P1_STATES_OFFSET = 10; + uint256 private constant CONFIG_P0_EFFECTS_OFFSET = 12; + uint256 private constant CONFIG_P1_EFFECTS_OFFSET = 13; + struct Tally { uint256 totalSload; uint256 coldSload; // slot's first touch in the window is this READ @@ -30,6 +42,91 @@ abstract contract GasMeasure is CommonBase { uint256 extraAccounts; // unique non-precompile accounts beyond the call target (2,600 cold each in prod) } + struct EffectStorageTally { + uint256 headerReads; + uint256 dataReads; + uint256 headerWrites; + uint256 dataWrites; + } + + /// @notice Exact accesses to the one-slot packed MonState lanes for one battle config. + /// @dev The bitmaps use lanes 0..7 for p0 and 8..15 for p1. A write-back frame would load + /// each touched lane at most once and commit each written lane at most once. + struct MonStateStorageTally { + uint256 reads; + uint256 writes; + uint16 readLanes; + uint16 writtenLanes; + uint256 uniqueTouchedLanes; + uint256 uniqueWrittenLanes; + uint256 frameStorageOps; + uint256 currentStorageOps; + uint256 removableStorageOps; + } + + /// @notice Storage traffic predicted for a lazy frame with legacy flush/invalidate barriers. + struct MonStateFrameModel { + uint256 loads; + uint256 commits; + uint256 reloadedLanes; + uint256 dirtyFlushBoundaries; + uint256 cleanInvalidationBoundaries; + uint256 modeledStorageOps; + uint256 removableStorageOps; + } + + /// @notice Conservative packed-header frame model. Root Engine execution uses a lazy + /// memory frame; legacy mechanic callbacks retain their actual storage operations. + struct PackedHeaderFrameModel { + uint256 loads; + uint256 commits; + uint256 callbackPassthroughOps; + uint256 reloadedWords; + uint256 dirtyFlushBoundaries; + uint256 cleanInvalidationBoundaries; + uint256 currentStorageOps; + uint256 modeledStorageOps; + uint256 removableStorageOps; + } + + /// @notice Whole-account storage working set for an onchain-kernel ceiling calculation. + struct StorageWorkingSet { + uint256 reads; + uint256 writes; + uint256 uniqueTouchedSlots; + uint256 uniqueWrittenSlots; + uint256 loadCommitFloorOps; + uint256 removableOps; + } + + struct CallBoundaryStorageTally { + uint256 engineRootReads; + uint256 engineRootWrites; + uint256 engineCallbackReads; + uint256 engineCallbackWrites; + uint256 externalReads; + uint256 externalWrites; + } + + uint256 internal constant STORAGE_CATEGORY_COUNT = 9; + uint8 internal constant STORAGE_OTHER = 0; + uint8 internal constant STORAGE_SHELL = 1; + uint8 internal constant STORAGE_BATTLE_DATA = 2; + uint8 internal constant STORAGE_CONFIG_HEADER = 3; + uint8 internal constant STORAGE_STATIC_CATALOG = 4; + uint8 internal constant STORAGE_MON_STATE = 5; + uint8 internal constant STORAGE_EFFECTS = 6; + uint8 internal constant STORAGE_BOOSTS = 7; + uint8 internal constant STORAGE_GLOBAL_KV = 8; + + struct EngineStorageCategoryTally { + uint256[STORAGE_CATEGORY_COUNT] reads; + uint256[STORAGE_CATEGORY_COUNT] writes; + uint256[STORAGE_CATEGORY_COUNT] uniqueTouched; + uint256[STORAGE_CATEGORY_COUNT] uniqueWritten; + bytes32 firstOtherSlot; + } + /// @dev Classify a state-diff window as ONE transaction: first touch of a slot is cold, /// subsequent touches warm. Call once per prod-tx-equivalent unit (e.g. once per turn) /// so cold/warm reflects a fresh cold-start access list. @@ -124,6 +221,721 @@ abstract contract GasMeasure is CommonBase { o.extraAccounts = a.extraAccounts + b.extraAccounts; } + /// @notice Count calls matching an exact `(accessor, account, selector)` tuple. + /// @dev Address zero is a wildcard for either address. Resume records and reverted calls + /// are excluded. This operates after the measured bracket, so it cannot perturb gas. + function _countCalls(Vm.AccountAccess[] memory accesses, address accessor, address account, bytes4 selector) + internal + pure + returns (uint256 count) + { + for (uint256 i; i < accesses.length; i++) { + Vm.AccountAccess memory a = accesses[i]; + if ( + !a.reverted && _isCallKind(a.kind) && a.data.length >= 4 + && (accessor == address(0) || a.accessor == accessor) + && (account == address(0) || a.account == account) && bytes4(a.data) == selector + ) { + count++; + } + } + } + + /// @notice Count matching ABI calls whose first argument is nonzero. + /// @dev Used to distinguish continuation calls from initial resolver calls without changing + /// production contracts or adding instrumentation to the measured execution path. + function _countCallsWithNonzeroFirstArg( + Vm.AccountAccess[] memory accesses, + address accessor, + address account, + bytes4 selector + ) internal pure returns (uint256 count) { + for (uint256 i; i < accesses.length; i++) { + Vm.AccountAccess memory a = accesses[i]; + if ( + !a.reverted && _isCallKind(a.kind) && a.data.length >= 36 + && (accessor == address(0) || a.accessor == accessor) + && (account == address(0) || a.account == account) && bytes4(a.data) == selector + ) { + uint256 firstArg; + bytes memory data = a.data; + assembly ("memory-safe") { + firstArg := mload(add(data, 36)) + } + if (firstArg != 0) { + count++; + } + } + } + } + + function _isCallKind(VmSafe.AccountAccessKind kind) private pure returns (bool) { + return kind == VmSafe.AccountAccessKind.Call || kind == VmSafe.AccountAccessKind.StaticCall + || kind == VmSafe.AccountAccessKind.DelegateCall || kind == VmSafe.AccountAccessKind.CallCode; + } + + /// @notice Count reads/writes to player EffectInstance header/data slots for one Engine + /// storage key. Player effect arrays are mappings at BattleConfig offsets 12/13; + /// each EffectInstance occupies two consecutive slots. + function _effectStorageTally(Vm.AccountAccess[] memory accesses, address engine, bytes32 storageKey) + internal + pure + returns (EffectStorageTally memory t) + { + bytes32 configBase = keccak256(abi.encode(storageKey, ENGINE_BATTLE_CONFIG_ROOT)); + bytes32 p0Root = bytes32(uint256(configBase) + CONFIG_P0_EFFECTS_OFFSET); + bytes32 p1Root = bytes32(uint256(configBase) + CONFIG_P1_EFFECTS_OFFSET); + (bytes32[] memory slotKeys, uint8[] memory slotKinds) = _buildPlayerEffectSlotTable(p0Root, p1Root); + + for (uint256 i; i < accesses.length; i++) { + Vm.StorageAccess[] memory sa = accesses[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.account != engine || a.reverted) { + continue; + } + uint256 kind = _playerEffectSlotKind(a.slot, slotKeys, slotKinds); + if (kind == 1) { + if (a.isWrite) { + t.headerWrites++; + } else { + t.headerReads++; + } + } else if (kind == 2) { + if (a.isWrite) { + t.dataWrites++; + } else { + t.dataReads++; + } + } + } + } + } + + /// @notice Count reads/writes to the 16 possible packed MonState slots for one Engine config. + function _monStateStorageTally(Vm.AccountAccess[] memory accesses, address engine, bytes32 storageKey) + internal + pure + returns (MonStateStorageTally memory t) + { + bytes32 configBase = keccak256(abi.encode(storageKey, ENGINE_BATTLE_CONFIG_ROOT)); + bytes32 p0Root = bytes32(uint256(configBase) + CONFIG_P0_STATES_OFFSET); + bytes32 p1Root = bytes32(uint256(configBase) + CONFIG_P1_STATES_OFFSET); + bytes32[16] memory laneSlots; + for (uint256 mon; mon < 8; mon++) { + laneSlots[mon] = keccak256(abi.encode(mon, p0Root)); + laneSlots[8 + mon] = keccak256(abi.encode(mon, p1Root)); + } + + for (uint256 i; i < accesses.length; i++) { + Vm.StorageAccess[] memory sa = accesses[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.account != engine || a.reverted) { + continue; + } + for (uint256 lane; lane < 16; lane++) { + if (a.slot == laneSlots[lane]) { + uint16 laneBit = uint16(1 << lane); + if (a.isWrite) { + t.writes++; + t.writtenLanes |= laneBit; + } else { + t.reads++; + t.readLanes |= laneBit; + } + break; + } + } + } + } + + t.uniqueTouchedLanes = _popcount16(t.readLanes | t.writtenLanes); + t.uniqueWrittenLanes = _popcount16(t.writtenLanes); + t.currentStorageOps = t.reads + t.writes; + t.frameStorageOps = t.uniqueTouchedLanes + t.uniqueWrittenLanes; + t.removableStorageOps = + t.currentStorageOps > t.frameStorageOps ? t.currentStorageOps - t.frameStorageOps : 0; + } + + /// @notice Count one account's exact storage working set in the recorded transaction. + /// @dev `loadCommitFloorOps` models loading each touched slot once and committing each written + /// slot once. It is an architectural ceiling: static catalog and shell slots cannot all be + /// collapsed into a mutable kernel state, but the result bounds the available storage win. + function _storageWorkingSet(Vm.AccountAccess[] memory accesses, address account) + internal + pure + returns (StorageWorkingSet memory w) + { + uint256 accessCount; + for (uint256 i; i < accesses.length; i++) { + Vm.StorageAccess[] memory sa = accesses[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + if (sa[j].account == account && !sa[j].reverted) { + accessCount++; + } + } + } + uint256 cap = 1; + while (cap < accessCount * 2 + 1) { + cap <<= 1; + } + bytes32[] memory slots = new bytes32[](cap); + bool[] memory occupied = new bool[](cap); + bool[] memory written = new bool[](cap); + uint256 mask = cap - 1; + + for (uint256 i; i < accesses.length; i++) { + Vm.StorageAccess[] memory sa = accesses[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.account != account || a.reverted) { + continue; + } + if (a.isWrite) { + w.writes++; + } else { + w.reads++; + } + uint256 idx = uint256(a.slot) & mask; + while (occupied[idx] && slots[idx] != a.slot) { + idx = (idx + 1) & mask; + } + if (!occupied[idx]) { + occupied[idx] = true; + slots[idx] = a.slot; + w.uniqueTouchedSlots++; + } + if (a.isWrite && !written[idx]) { + written[idx] = true; + w.uniqueWrittenSlots++; + } + } + } + w.loadCommitFloorOps = w.uniqueTouchedSlots + w.uniqueWrittenSlots; + uint256 currentOps = w.reads + w.writes; + w.removableOps = currentOps > w.loadCommitFloorOps ? currentOps - w.loadCommitFloorOps : 0; + } + + /// @notice Split recorded storage operations between the root Engine frame, reentrant Engine + /// callback frames entered by mechanics, and mechanic-owned storage. + function _callBoundaryStorageTally(Vm.AccountAccess[] memory accesses, address engine, address rootCaller) + internal + pure + returns (CallBoundaryStorageTally memory t) + { + for (uint256 i; i < accesses.length; i++) { + Vm.AccountAccess memory frame = accesses[i]; + Vm.StorageAccess[] memory sa = frame.storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.reverted) { + continue; + } + if (a.account == engine) { + bool callbackFrame = frame.accessor != rootCaller && frame.accessor != engine; + if (callbackFrame) { + if (a.isWrite) t.engineCallbackWrites++; + else t.engineCallbackReads++; + } else { + if (a.isWrite) t.engineRootWrites++; + else t.engineRootReads++; + } + } else if (uint160(a.account) > 0xff) { + if (a.isWrite) t.externalWrites++; + else t.externalReads++; + } + } + } + } + + /// @notice Classify the Engine storage working set into kernel-state and shell/static families. + function _engineStorageCategoryTally( + Vm.AccountAccess[] memory accesses, + address engine, + bytes32 battleKey, + bytes32 storageKey + ) internal pure returns (EngineStorageCategoryTally memory t) { + uint256 cap = 8192; + bytes32[] memory slots = new bytes32[](cap); + uint8[] memory categories = new uint8[](cap); + bool[] memory occupied = new bool[](cap); + bool[] memory seen = new bool[](cap); + bool[] memory writtenSeen = new bool[](cap); + + _insertStorageCategory(slots, categories, occupied, bytes32(uint256(1)), STORAGE_SHELL); + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(battleKey, uint256(2))), STORAGE_SHELL + ); + for (uint256 i; i < 16; i++) { + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(i, uint256(0))), STORAGE_SHELL + ); + } + + bytes32 battleBase = keccak256(abi.encode(battleKey, uint256(5))); + _insertStorageCategory(slots, categories, occupied, battleBase, STORAGE_BATTLE_DATA); + _insertStorageCategory( + slots, categories, occupied, bytes32(uint256(battleBase) + 1), STORAGE_BATTLE_DATA + ); + bytes32 multiBase = keccak256(abi.encode(battleKey, uint256(6))); + _insertStorageCategory(slots, categories, occupied, multiBase, STORAGE_BATTLE_DATA); + _insertStorageCategory(slots, categories, occupied, bytes32(uint256(multiBase) + 1), STORAGE_BATTLE_DATA); + + bytes32 configBase = keccak256(abi.encode(storageKey, ENGINE_BATTLE_CONFIG_ROOT)); + for (uint256 offset; offset <= 5; offset++) { + _insertStorageCategory( + slots, + categories, + occupied, + bytes32(uint256(configBase) + offset), + STORAGE_CONFIG_HEADER + ); + } + _insertStorageCategory( + slots, categories, occupied, bytes32(uint256(configBase) + 18), STORAGE_CONFIG_HEADER + ); + _insertStorageCategory( + slots, categories, occupied, bytes32(uint256(configBase) + 6), STORAGE_STATIC_CATALOG + ); + + _insertTeamAndStateCategories(slots, categories, occupied, configBase); + _insertEffectCategories(slots, categories, occupied, configBase); + _insertBoostCategories(slots, categories, occupied, configBase); + _insertGlobalKVCategories(slots, categories, occupied, accesses, engine, storageKey); + + uint256 mask = cap - 1; + for (uint256 i; i < accesses.length; i++) { + Vm.StorageAccess[] memory sa = accesses[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.account != engine || a.reverted) { + continue; + } + uint256 idx = uint256(a.slot) & mask; + while (occupied[idx] && slots[idx] != a.slot) { + idx = (idx + 1) & mask; + } + if (!occupied[idx]) { + occupied[idx] = true; + slots[idx] = a.slot; + categories[idx] = STORAGE_OTHER; + } + uint256 category = categories[idx]; + if (category == STORAGE_OTHER && t.firstOtherSlot == bytes32(0)) { + t.firstOtherSlot = a.slot; + } + if (a.isWrite) t.writes[category]++; + else t.reads[category]++; + if (!seen[idx]) { + seen[idx] = true; + t.uniqueTouched[category]++; + } + if (a.isWrite && !writtenSeen[idx]) { + writtenSeen[idx] = true; + t.uniqueWritten[category]++; + } + } + } + } + + function _insertTeamAndStateCategories( + bytes32[] memory slots, + uint8[] memory categories, + bool[] memory occupied, + bytes32 configBase + ) private pure { + bytes32 p0TeamRoot = bytes32(uint256(configBase) + 7); + bytes32 p1TeamRoot = bytes32(uint256(configBase) + 8); + bytes32 p0StateRoot = bytes32(uint256(configBase) + CONFIG_P0_STATES_OFFSET); + bytes32 p1StateRoot = bytes32(uint256(configBase) + CONFIG_P1_STATES_OFFSET); + for (uint256 mon; mon < 8; mon++) { + bytes32 p0Mon = keccak256(abi.encode(mon, p0TeamRoot)); + bytes32 p1Mon = keccak256(abi.encode(mon, p1TeamRoot)); + for (uint256 word; word < 6; word++) { + _insertStorageCategory( + slots, + categories, + occupied, + bytes32(uint256(p0Mon) + word), + STORAGE_STATIC_CATALOG + ); + _insertStorageCategory( + slots, + categories, + occupied, + bytes32(uint256(p1Mon) + word), + STORAGE_STATIC_CATALOG + ); + } + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(mon, p0StateRoot)), STORAGE_MON_STATE + ); + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(mon, p1StateRoot)), STORAGE_MON_STATE + ); + } + } + + function _insertEffectCategories( + bytes32[] memory slots, + uint8[] memory categories, + bool[] memory occupied, + bytes32 configBase + ) private pure { + bytes32 globalRoot = bytes32(uint256(configBase) + 11); + bytes32 p0Root = bytes32(uint256(configBase) + CONFIG_P0_EFFECTS_OFFSET); + bytes32 p1Root = bytes32(uint256(configBase) + CONFIG_P1_EFFECTS_OFFSET); + for (uint256 i; i < 256; i++) { + bytes32 header = keccak256(abi.encode(i, globalRoot)); + _insertStorageCategory(slots, categories, occupied, header, STORAGE_EFFECTS); + _insertStorageCategory( + slots, categories, occupied, bytes32(uint256(header) + 1), STORAGE_EFFECTS + ); + } + uint256 playerSlots = 8 * EFFECT_SLOTS_PER_MON; + for (uint256 i; i < playerSlots; i++) { + bytes32 p0Header = keccak256(abi.encode(i, p0Root)); + bytes32 p1Header = keccak256(abi.encode(i, p1Root)); + _insertStorageCategory(slots, categories, occupied, p0Header, STORAGE_EFFECTS); + _insertStorageCategory( + slots, categories, occupied, bytes32(uint256(p0Header) + 1), STORAGE_EFFECTS + ); + _insertStorageCategory(slots, categories, occupied, p1Header, STORAGE_EFFECTS); + _insertStorageCategory( + slots, categories, occupied, bytes32(uint256(p1Header) + 1), STORAGE_EFFECTS + ); + } + bytes32 hookRoot = bytes32(uint256(configBase) + 14); + for (uint256 i; i < 256; i++) { + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(i, hookRoot)), STORAGE_EFFECTS + ); + } + } + + function _insertBoostCategories( + bytes32[] memory slots, + uint8[] memory categories, + bool[] memory occupied, + bytes32 configBase + ) private pure { + bytes32 p0Root = bytes32(uint256(configBase) + 15); + bytes32 p1Root = bytes32(uint256(configBase) + 16); + bytes32 accRoot = bytes32(uint256(configBase) + 17); + for (uint256 i; i < 8 * 16; i++) { + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(i, p0Root)), STORAGE_BOOSTS + ); + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(i, p1Root)), STORAGE_BOOSTS + ); + } + for (uint256 lane; lane < 16; lane++) { + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(lane, accRoot)), STORAGE_BOOSTS + ); + } + } + + function _insertGlobalKVCategories( + bytes32[] memory slots, + uint8[] memory categories, + bool[] memory occupied, + Vm.AccountAccess[] memory accesses, + address engine, + bytes32 storageKey + ) private pure { + bytes32 valuesRoot = keccak256(abi.encode(storageKey, uint256(8))); + bytes32 keysRoot = keccak256(abi.encode(storageKey, uint256(9))); + bytes32[16] memory keySlots; + for (uint256 i; i < 16; i++) { + keySlots[i] = keccak256(abi.encode(i, keysRoot)); + _insertStorageCategory(slots, categories, occupied, keySlots[i], STORAGE_GLOBAL_KV); + } + // Zero-valued probes are deliberately absent from globalKVKeySlots. Decode the getter's + // key argument so those read-only value slots are still categorized exactly. + bytes4 getGlobalKVSelector = bytes4(keccak256("getGlobalKV(bytes32,uint64)")); + for (uint256 i; i < accesses.length; i++) { + Vm.AccountAccess memory frame = accesses[i]; + if (frame.account != engine || frame.data.length < 68 || bytes4(frame.data) != getGlobalKVSelector) { + continue; + } + uint256 rawKey; + bytes memory data = frame.data; + assembly ("memory-safe") { + rawKey := mload(add(data, 68)) + } + _insertStorageCategory( + slots, + categories, + occupied, + keccak256(abi.encode(uint64(rawKey), valuesRoot)), + STORAGE_GLOBAL_KV + ); + } + for (uint256 i; i < accesses.length; i++) { + Vm.StorageAccess[] memory sa = accesses[i].storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.account != engine || a.reverted) continue; + for (uint256 k; k < keySlots.length; k++) { + if (a.slot != keySlots[k]) continue; + _insertPackedGlobalKeys(slots, categories, occupied, valuesRoot, uint256(a.previousValue)); + _insertPackedGlobalKeys(slots, categories, occupied, valuesRoot, uint256(a.newValue)); + break; + } + } + } + } + + function _insertPackedGlobalKeys( + bytes32[] memory slots, + uint8[] memory categories, + bool[] memory occupied, + bytes32 valuesRoot, + uint256 packed + ) private pure { + for (uint256 lane; lane < 4; lane++) { + uint64 key = uint64(packed >> (lane * 64)); + _insertStorageCategory( + slots, categories, occupied, keccak256(abi.encode(key, valuesRoot)), STORAGE_GLOBAL_KV + ); + } + } + + function _insertStorageCategory( + bytes32[] memory slots, + uint8[] memory categories, + bool[] memory occupied, + bytes32 slot, + uint8 category + ) private pure { + uint256 mask = slots.length - 1; + uint256 idx = uint256(slot) & mask; + while (occupied[idx] && slots[idx] != slot) { + idx = (idx + 1) & mask; + } + if (!occupied[idx]) { + occupied[idx] = true; + slots[idx] = slot; + categories[idx] = category; + } + } + + /// @notice Replay the recorded access order through a lazy write-back frame model. + /// @dev Before every legacy mechanic call, dirty lanes are committed and all loaded lanes are + /// invalidated. Resolver calls are deliberately absent from `boundarySelectors` and retain + /// the frame. This remains test-only and does not perturb the measured Engine path. + function _monStateFrameModel( + Vm.AccountAccess[] memory accesses, + address engine, + bytes32 storageKey, + bytes4[] memory boundarySelectors + ) internal pure returns (MonStateFrameModel memory m) { + bytes32 configBase = keccak256(abi.encode(storageKey, ENGINE_BATTLE_CONFIG_ROOT)); + bytes32 p0Root = bytes32(uint256(configBase) + CONFIG_P0_STATES_OFFSET); + bytes32 p1Root = bytes32(uint256(configBase) + CONFIG_P1_STATES_OFFSET); + bytes32[16] memory laneSlots; + for (uint256 mon; mon < 8; mon++) { + laneSlots[mon] = keccak256(abi.encode(mon, p0Root)); + laneSlots[8 + mon] = keccak256(abi.encode(mon, p1Root)); + } + + uint16 loaded; + uint16 dirty; + uint16 everLoaded; + uint256 currentStorageOps; + for (uint256 i; i < accesses.length; i++) { + Vm.AccountAccess memory accountAccess = accesses[i]; + if (_isLegacyBoundary(accountAccess, engine, boundarySelectors)) { + if (dirty != 0) { + m.commits += _popcount16(dirty); + m.dirtyFlushBoundaries++; + } else { + m.cleanInvalidationBoundaries++; + } + loaded = 0; + dirty = 0; + } + + Vm.StorageAccess[] memory sa = accountAccess.storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.account != engine || a.reverted) { + continue; + } + for (uint256 lane; lane < 16; lane++) { + if (a.slot == laneSlots[lane]) { + uint16 laneBit = uint16(1 << lane); + currentStorageOps++; + if (loaded & laneBit == 0) { + m.loads++; + if (everLoaded & laneBit != 0) { + m.reloadedLanes++; + } + loaded |= laneBit; + everLoaded |= laneBit; + } + if (a.isWrite) { + dirty |= laneBit; + } + break; + } + } + } + } + if (dirty != 0) { + m.commits += _popcount16(dirty); + } + m.modeledStorageOps = m.loads + m.commits; + m.removableStorageOps = + currentStorageOps > m.modeledStorageOps ? currentStorageOps - m.modeledStorageOps : 0; + } + + /// @notice Model a lazy memory frame for the two BattleData words and seven direct + /// BattleConfig header words (offsets 0..5 and 18). + /// @dev Legacy external mechanics are barriers: dirty root words flush before the call, root + /// words invalidate, and Engine storage touched by callback frames remains passthrough. + /// Resolver calls are deliberately not barriers. + function _packedHeaderFrameModel( + Vm.AccountAccess[] memory accesses, + address engine, + address rootCaller, + bytes32 battleKey, + bytes32 storageKey, + bytes4[] memory boundarySelectors + ) internal pure returns (PackedHeaderFrameModel memory m) { + bytes32 battleBase = keccak256(abi.encode(battleKey, uint256(5))); + bytes32 configBase = keccak256(abi.encode(storageKey, ENGINE_BATTLE_CONFIG_ROOT)); + bytes32[9] memory headerSlots; + headerSlots[0] = battleBase; + headerSlots[1] = bytes32(uint256(battleBase) + 1); + for (uint256 offset; offset <= 5; offset++) { + headerSlots[2 + offset] = bytes32(uint256(configBase) + offset); + } + headerSlots[8] = bytes32(uint256(configBase) + 18); + + uint16 loaded; + uint16 dirty; + uint16 everLoaded; + for (uint256 i; i < accesses.length; i++) { + Vm.AccountAccess memory frame = accesses[i]; + if (_isLegacyBoundary(frame, engine, boundarySelectors)) { + if (dirty != 0) { + m.commits += _popcount16(dirty); + m.dirtyFlushBoundaries++; + } else { + m.cleanInvalidationBoundaries++; + } + loaded = 0; + dirty = 0; + } + + bool callbackFrame = frame.accessor != rootCaller && frame.accessor != engine; + Vm.StorageAccess[] memory sa = frame.storageAccesses; + for (uint256 j; j < sa.length; j++) { + Vm.StorageAccess memory a = sa[j]; + if (a.account != engine || a.reverted) continue; + for (uint256 word; word < headerSlots.length; word++) { + if (a.slot != headerSlots[word]) continue; + m.currentStorageOps++; + if (callbackFrame) { + m.callbackPassthroughOps++; + break; + } + uint16 wordBit = uint16(1 << word); + if (loaded & wordBit == 0) { + m.loads++; + if (everLoaded & wordBit != 0) m.reloadedWords++; + loaded |= wordBit; + everLoaded |= wordBit; + } + if (a.isWrite) dirty |= wordBit; + break; + } + } + } + if (dirty != 0) m.commits += _popcount16(dirty); + m.modeledStorageOps = m.loads + m.commits + m.callbackPassthroughOps; + m.removableStorageOps = m.currentStorageOps > m.modeledStorageOps + ? m.currentStorageOps - m.modeledStorageOps + : 0; + } + + function _isLegacyBoundary(Vm.AccountAccess memory a, address engine, bytes4[] memory selectors) + private + pure + returns (bool) + { + if (a.reverted || !_isCallKind(a.kind) || a.accessor != engine || a.data.length < 4) { + return false; + } + bytes4 selector = bytes4(a.data); + for (uint256 i; i < selectors.length; i++) { + if (selector == selectors[i]) { + return true; + } + } + return false; + } + + function _popcount16(uint16 value) private pure returns (uint256 count) { + while (value != 0) { + value &= value - 1; + count++; + } + } + + /// @dev Precompute the player EffectInstance header/data slots for O(1) census lookup. + function _buildPlayerEffectSlotTable(bytes32 p0Root, bytes32 p1Root) + private + pure + returns (bytes32[] memory keys, uint8[] memory kinds) + { + // 8 mons/side, 64 stable effect indices per mon. The mapping key is already the + // flattened `monIndex * EFFECT_SLOTS_PER_MON + effectIndex` value. A 4096-entry + // open-addressed table stays at 50% load for the 2048 header+data slots. + keys = new bytes32[](4096); + kinds = new uint8[](4096); + uint256 slotsPerSide = 8 * EFFECT_SLOTS_PER_MON; + for (uint256 i; i < slotsPerSide; i++) { + bytes32 p0Header = keccak256(abi.encode(i, p0Root)); + bytes32 p1Header = keccak256(abi.encode(i, p1Root)); + _insertEffectSlot(keys, kinds, p0Header, 1); + _insertEffectSlot(keys, kinds, bytes32(uint256(p0Header) + 1), 2); + _insertEffectSlot(keys, kinds, p1Header, 1); + _insertEffectSlot(keys, kinds, bytes32(uint256(p1Header) + 1), 2); + } + } + + function _insertEffectSlot(bytes32[] memory keys, uint8[] memory kinds, bytes32 key, uint8 kind) private pure { + uint256 mask = keys.length - 1; + uint256 i = uint256(key) & mask; + while (kinds[i] != 0) { + i = (i + 1) & mask; + } + keys[i] = key; + kinds[i] = kind; + } + + /// @return kind 0 = unrelated, 1 = EffectInstance header, 2 = EffectInstance data. + function _playerEffectSlotKind(bytes32 slot, bytes32[] memory keys, uint8[] memory kinds) + private + pure + returns (uint256 kind) + { + uint256 mask = keys.length - 1; + uint256 i = uint256(slot) & mask; + while (kinds[i] != 0) { + if (keys[i] == slot) { + return kinds[i]; + } + i = (i + 1) & mask; + } + return 0; + } + /// @notice Conservative production storage+account cost for a window, priced from the tally /// as if the window were ONE fresh production transaction. Assumptions, all chosen so /// a code change's benefit is never overstated: diff --git a/test/effects/EffectTest.sol b/test/effects/EffectTest.sol index f5537719..e709b6b5 100644 --- a/test/effects/EffectTest.sol +++ b/test/effects/EffectTest.sol @@ -145,11 +145,12 @@ contract EffectTest is Test, BattleHelper { // Alice and Bob both select attacks, both of them are move index 0 (do frostbite damage) _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); - // Check that both mons have an effect length of 2 (including stat boost) + // Both mons carry the frostbite status; its SpAtk debuff lives in the boost store, not + // the effect list (boost sources are no longer effect entries). (EffectInstance[] memory effects0,) = engine.getEffects(battleKey, 0, 0); (EffectInstance[] memory effects1,) = engine.getEffects(battleKey, 1, 0); - assertEq(effects0.length, 2); - assertEq(effects1.length, 2); + assertEq(effects0.length, 1); + assertEq(effects1.length, 1); // Check that both mons took 1 damage (we should round down) assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), -1); @@ -162,11 +163,11 @@ contract EffectTest is Test, BattleHelper { // Alice and Bob both select attacks, both of them are move index 0 (do frostbite damage) _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); - // Check that both mons still have an effect length of 2 (including stat boost) + // Check that both mons still carry exactly the status entry (effects0,) = engine.getEffects(battleKey, 0, 0); (effects1,) = engine.getEffects(battleKey, 1, 0); - assertEq(effects0.length, 2); - assertEq(effects1.length, 2); + assertEq(effects0.length, 1); + assertEq(effects1.length, 1); assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Hp), -2); assertEq(engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp), -2); @@ -306,7 +307,7 @@ contract EffectTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob( engine, commitManager, battleKey, SWITCH_MOVE_INDEX, NO_OP_MOVE_INDEX, uint16(1), 0 ); - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[0], 1); + assertEq(engine.getBattleContext(battleKey).p0ActiveMonIndex, 1); } /** @@ -472,11 +473,12 @@ contract EffectTest is Test, BattleHelper { // Alice and Bob both select attacks, both of them are move index 0 (apply burn status) _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); - // Check that both mons have an effect length of 2 (including stat boost) + // Both mons carry the burn status; its attack debuff lives in the boost store, not the + // effect list (boost sources are no longer effect entries). (EffectInstance[] memory burnEffects0,) = engine.getEffects(battleKey, 0, 0); (EffectInstance[] memory burnEffects1,) = engine.getEffects(battleKey, 1, 0); - assertEq(burnEffects0.length, 2); - assertEq(burnEffects1.length, 2); + assertEq(burnEffects0.length, 1); + assertEq(burnEffects1.length, 1); // Check that the attack of both mons was reduced by 50% (32/2 = 16) assertEq(engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Attack), -16); @@ -489,11 +491,11 @@ contract EffectTest is Test, BattleHelper { // Alice and Bob both select attacks again to increase burn degree _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); - // Check that both mons still have an effect length of 2 (including stat boost) + // Check that both mons still carry exactly the status entry (debuff is store-side) (EffectInstance[] memory effects0,) = engine.getEffects(battleKey, 0, 0); (EffectInstance[] memory effects1,) = engine.getEffects(battleKey, 1, 0); - assertEq(effects0.length, 2); - assertEq(effects1.length, 2); + assertEq(effects0.length, 1); + assertEq(effects1.length, 1); // Check that both mons took additional 1/8 damage (256/8 = 32) // Total damage should be 16 (first round) + 32 (second round) = 48 @@ -503,11 +505,11 @@ contract EffectTest is Test, BattleHelper { // Alice and Bob both select attacks again to increase burn degree to maximum _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); - // Check that both mons still have an effect length of 2 + // Check that both mons still carry exactly the status entry (effects0,) = engine.getEffects(battleKey, 0, 0); (effects1,) = engine.getEffects(battleKey, 1, 0); - assertEq(effects0.length, 2); - assertEq(effects1.length, 2); + assertEq(effects0.length, 1); + assertEq(effects1.length, 1); // Check that both mons took additional 1/4 damage (256/4 = 64) // Total damage should be 16 (first round) + 32 (second round) + 64 (third round) = 112 @@ -517,11 +519,11 @@ contract EffectTest is Test, BattleHelper { // Alice and Bob both select attacks again to increase burn degree to maximum _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, 0, 0, 0); - // Check that both mons still have an effect length of 2 + // Check that both mons still carry exactly the status entry (effects0,) = engine.getEffects(battleKey, 0, 0); (effects1,) = engine.getEffects(battleKey, 1, 0); - assertEq(effects0.length, 2); - assertEq(effects1.length, 2); + assertEq(effects0.length, 1); + assertEq(effects1.length, 1); // Check that both mons took another 1/4 damage (max burn degree) // Total damage should be 16 (first round) + 32 (second round) + 64 (third round) + 64 (fourth round) = 176 @@ -629,13 +631,13 @@ contract EffectTest is Test, BattleHelper { // The move should outspeed the swap, so the swap doesn't happen // So Bob's active mon index should still be 0 - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[1], 0); + assertEq(engine.getBattleContext(battleKey).p1ActiveMonIndex, 0); // Alice uses slower Zap, Bob switches to mon index 1 _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 1, SWITCH_MOVE_INDEX, 0, uint16(1)); // Bob's active mon index should be 1 (swap goes before getting Zapped) - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[1], 1); + assertEq(engine.getBattleContext(battleKey).p1ActiveMonIndex, 1); // Bob's active mon should have the Zap effect (EffectInstance[] memory zapEffects,) = engine.getEffects(battleKey, 1, 1); @@ -647,7 +649,7 @@ contract EffectTest is Test, BattleHelper { ); // Check that Bob's active mon index is now 0, and the effect is still there - assertEq(engine.getActiveMonIndexForBattleState(battleKey)[1], 0); + assertEq(engine.getBattleContext(battleKey).p1ActiveMonIndex, 0); (EffectInstance[] memory zapEffectsAfter,) = engine.getEffects(battleKey, 1, 1); assertEq(zapEffectsAfter.length, 1); diff --git a/test/effects/PreDamageHookTest.sol b/test/effects/PreDamageHookTest.sol index 046e1616..971be509 100644 --- a/test/effects/PreDamageHookTest.sol +++ b/test/effects/PreDamageHookTest.sol @@ -12,6 +12,7 @@ import {IEngine} from "../../src/IEngine.sol"; import {DefaultCommitManager} from "../../src/commit-manager/DefaultCommitManager.sol"; import {BasicEffect} from "../../src/effects/BasicEffect.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; +import {TargetLib} from "../../src/lib/TargetLib.sol"; import {DefaultMatchmaker} from "../../src/matchmaker/DefaultMatchmaker.sol"; import {IMoveSet} from "../../src/moves/IMoveSet.sol"; import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; @@ -25,23 +26,28 @@ import {TestTypeCalculator} from "../mocks/TestTypeCalculator.sol"; // PreDamage that halves the running damage. contract PreDamageHalveEffect is BasicEffect { - function getStepsBitmap() external pure override returns (uint16) { - return 0x200; // PreDamage + function getStepsBitmap() external pure override returns (uint32) { + return 0x02000200; // PreDamage + fresh PreDamage context } - function onPreDamage(IEngine engine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) - external - override - returns (bytes32, bool) - { - engine.setPreDamage(engine.getPreDamage() / 2); + function onPreDamage( + IEngine engine, + bytes32, + uint256, + bytes32 extraData, + uint256, + uint256, + uint256 hookContext, + uint256 + ) external override returns (bytes32, bool) { + engine.setPreDamage(TargetLib.hookPreDamage(hookContext) / 2); return (extraData, false); } } // PreDamage that fully absorbs damage (sets running to 0). contract PreDamageAbsorbEffect is BasicEffect { - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x200; } @@ -57,16 +63,21 @@ contract PreDamageAbsorbEffect is BasicEffect { // PreDamage that doubles the running damage. contract PreDamageDoubleEffect is BasicEffect { - function getStepsBitmap() external pure override returns (uint16) { - return 0x200; + function getStepsBitmap() external pure override returns (uint32) { + return 0x02000200; } - function onPreDamage(IEngine engine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256) - external - override - returns (bytes32, bool) - { - engine.setPreDamage(engine.getPreDamage() * 2); + function onPreDamage( + IEngine engine, + bytes32, + uint256, + bytes32 extraData, + uint256, + uint256, + uint256 hookContext, + uint256 + ) external override returns (bytes32, bool) { + engine.setPreDamage(TargetLib.hookPreDamage(hookContext) * 2); return (extraData, false); } } @@ -80,18 +91,23 @@ contract SourceCaptureEffect is BasicEffect { uint256 public preDamageCallCount; uint256 public afterDamageCallCount; - function getStepsBitmap() external pure override returns (uint16) { - return 0x240; // PreDamage | AfterDamage + function getStepsBitmap() external pure override returns (uint32) { + return 0x02000240; // PreDamage | AfterDamage + fresh context } - function onPreDamage(IEngine engine, bytes32, uint256, bytes32 extraData, uint256, uint256, uint256, uint256 source) - external - override - returns (bytes32, bool) - { + function onPreDamage( + IEngine, + bytes32, + uint256, + bytes32 extraData, + uint256, + uint256, + uint256 hookContext, + uint256 source + ) external override returns (bytes32, bool) { preDamageCallCount += 1; lastPreDamageSource = source; - lastPreDamageSeenDamage = engine.getPreDamage(); + lastPreDamageSeenDamage = TargetLib.hookPreDamage(hookContext); return (extraData, false); } diff --git a/test/effects/StatBoosts.t.sol b/test/effects/StatBoosts.t.sol index 28298890..76bb70f3 100644 --- a/test/effects/StatBoosts.t.sol +++ b/test/effects/StatBoosts.t.sol @@ -138,17 +138,11 @@ contract StatBoostsTest is Test, BattleHelper { int32 boostedStat = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName(statIndex)); assertEq(boostedStat, initialStat + 10, "Stat should be boosted by 10%"); - // Verify the effect was added to Alice's mon - (EffectInstance[] memory effects,) = engine.getEffects(battleKey, 0, 0); - bool foundEffect = false; - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == STAT_BOOST_ADDRESS) { - foundEffect = true; - break; - } - } - assertTrue(foundEffect, "Stat Boost effect should be added to mon's effects"); - uint256 effectCount = effects.length; + // Verify the source landed in the mon's boost store (boost sources are no longer + // effect-list entries) + (BattleConfigView memory cfgView,) = engine.getBattle(battleKey); + assertEq(cfgView.p0StatBoosts[0].length, 1, "Stat Boost source should be in the boost store"); + uint256 sourceCount = cfgView.p0StatBoosts[0].length; // 2. Apply another boost (+10) to the same stat console.log("2. Applying additional 1% boost to Alice's mon"); @@ -167,9 +161,9 @@ contract StatBoostsTest is Test, BattleHelper { int32 furtherBoostedStat = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName(statIndex)); assertEq(furtherBoostedStat, initialStat + 21, "Stat should be boosted by 21% total"); - // Verify no duplicate effect was added - (effects,) = engine.getEffects(battleKey, 0, 0); - assertEq(effects.length, effectCount, "No duplicate effect should be added"); + // Verify no duplicate source was added (same caller merges into one source) + (cfgView,) = engine.getBattle(battleKey); + assertEq(cfgView.p0StatBoosts[0].length, sourceCount, "No duplicate source should be added"); // Switch out the mon console.log("4. Switching out Alice's mon"); @@ -184,16 +178,9 @@ contract StatBoostsTest is Test, BattleHelper { 0 // Bob does nothing ); - // Verify the effect was removed - (effects,) = engine.getEffects(battleKey, 0, 1); - foundEffect = false; - for (uint256 i = 0; i < effects.length; i++) { - if (address(effects[i].effect) == STAT_BOOST_ADDRESS) { - foundEffect = true; - break; - } - } - assertFalse(foundEffect, "Stat Boost effect should be removed after switching out"); + // Verify the temp source expired off the switched-out mon's boost store + (cfgView,) = engine.getBattle(battleKey); + assertEq(cfgView.p0StatBoosts[0].length, 0, "Stat Boost source should be removed after switching out"); // 5. Switch back to the original mon and verify stat is reset _commitRevealExecuteForAliceAndBob( @@ -310,16 +297,9 @@ contract StatBoostsTest is Test, BattleHelper { int32 boostedStat = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName(statIndices[i])); assertEq(boostedStat, 2, "Stat should be boosted by +2"); - // Verify the effect was added - (EffectInstance[] memory statEffects,) = engine.getEffects(battleKey, 0, 0); - bool foundStatEffect = false; - for (uint256 j = 0; j < statEffects.length; j++) { - if (address(statEffects[j].effect) == STAT_BOOST_ADDRESS) { - foundStatEffect = true; - break; - } - } - assertTrue(foundStatEffect, "Stat Boost effect should be added for each stat"); + // Verify the source is in the boost store + (BattleConfigView memory statView,) = engine.getBattle(battleKey); + assertTrue(statView.p0StatBoosts[0].length > 0, "Stat Boost source should be added for each stat"); } // Switch out and verify all effects are removed @@ -333,14 +313,9 @@ contract StatBoostsTest is Test, BattleHelper { 0 // Bob does nothing ); - // Verify all effects were removed - (EffectInstance[] memory effectsAfterSwitch,) = engine.getEffects(battleKey, 0, 1); - for (uint256 i = 0; i < effectsAfterSwitch.length; i++) { - assertFalse( - address(effectsAfterSwitch[i].effect) == STAT_BOOST_ADDRESS, - "No Stat Boost effects should remain after switching out" - ); - } + // Verify all temp sources expired off the switched-out mon + (BattleConfigView memory afterView,) = engine.getBattle(battleKey); + assertEq(afterView.p0StatBoosts[0].length, 0, "No Stat Boost sources should remain after switching out"); } function test_permanentTempStatBoostInteraction() public { diff --git a/test/kernel/PackedHeaderFrameLibTest.sol b/test/kernel/PackedHeaderFrameLibTest.sol new file mode 100644 index 00000000..e6e226ad --- /dev/null +++ b/test/kernel/PackedHeaderFrameLibTest.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {Test} from "../../lib/forge-std/src/Test.sol"; + +import {BattleConfig, BattleData} from "../../src/Structs.sol"; +import {PackedHeaderFrameLib} from "../../src/kernel/PackedHeaderFrameLib.sol"; + +contract PackedHeaderFrameHarness { + using PackedHeaderFrameLib for PackedHeaderFrameLib.Frame; + + BattleData private battle; + BattleConfig private config; + + function setRaw(uint256 wordIndex, uint256 value) external { + uint256 slot = _slot(wordIndex); + assembly ("memory-safe") { + sstore(slot, value) + } + } + + function getRaw(uint256 wordIndex) external view returns (uint256 value) { + uint256 slot = _slot(wordIndex); + assembly ("memory-safe") { + value := sload(slot) + } + } + + function probeTwice(uint256 wordIndex) + external + view + returns (uint256 first, uint256 second, uint16 loadedMask, uint16 dirtyMask) + { + PackedHeaderFrameLib.Frame memory frame = PackedHeaderFrameLib.init(battle, config); + first = frame.read(wordIndex); + second = frame.read(wordIndex); + loadedMask = frame.loadedMask; + dirtyMask = frame.dirtyMask; + } + + function writeAndFlush(uint256 wordIndex, uint256 value) + external + returns (uint16 loadedBeforeFlush, uint16 dirtyBeforeFlush, uint16 dirtyAfterFlush) + { + PackedHeaderFrameLib.Frame memory frame = PackedHeaderFrameLib.init(battle, config); + frame.write(wordIndex, value); + loadedBeforeFlush = frame.loadedMask; + dirtyBeforeFlush = frame.dirtyMask; + frame.flush(); + dirtyAfterFlush = frame.dirtyMask; + } + + function readBattleState() + external + view + returns ( + address p0, + address p1, + uint8 winner, + uint8 switchFlag, + uint16 active, + uint40 timestamp, + uint16 turn, + uint8 buffered + ) + { + PackedHeaderFrameLib.Frame memory frame = PackedHeaderFrameLib.init(battle, config); + return ( + frame.p0(), + frame.p1(), + frame.winnerIndex(), + frame.playerSwitchForTurnFlag(), + frame.activeMonIndex(), + frame.lastExecuteTimestamp(), + frame.turnId(), + frame.numBuffered() + ); + } + + function advanceTurnAndFlush(uint8 switchFlag, uint40 timestamp) external { + PackedHeaderFrameLib.Frame memory frame = PackedHeaderFrameLib.init(battle, config); + frame.advanceTurn(switchFlag, timestamp); + frame.flush(); + } + + function dirtyInvalidateReverts() external returns (bool reverted) { + PackedHeaderFrameLib.Frame memory frame = PackedHeaderFrameLib.init(battle, config); + frame.write(0, 1); + try this.invalidate(frame) {} catch { + reverted = true; + } + } + + function invalidate(PackedHeaderFrameLib.Frame memory frame) external pure { + frame.invalidate(); + } + + function _slot(uint256 wordIndex) private view returns (uint256 slot) { + uint256 battleSlot; + uint256 configSlot; + assembly ("memory-safe") { + battleSlot := battle.slot + configSlot := config.slot + } + if (wordIndex < 2) return battleSlot + wordIndex; + if (wordIndex < 8) return configSlot + wordIndex - 2; + if (wordIndex == 8) return configSlot + 18; + revert(); + } +} + +contract PackedHeaderFrameLibTest is Test { + PackedHeaderFrameHarness private harness; + + function setUp() public { + harness = new PackedHeaderFrameHarness(); + for (uint256 word; word < 9; word++) { + harness.setRaw(word, 1000 + word); + } + } + + function test_lazyReadLoadsWordOnce() public view { + (uint256 first, uint256 second, uint16 loadedMask, uint16 dirtyMask) = harness.probeTwice(5); + assertEq(first, 1005); + assertEq(second, 1005); + assertEq(loadedMask, uint16(1 << 5)); + assertEq(dirtyMask, 0); + } + + function test_fullWordWriteDoesNotRequirePriorReadAndFlushesOnlyDirtyWord() public { + (uint16 loaded, uint16 dirtyBefore, uint16 dirtyAfter) = harness.writeAndFlush(8, 7777); + assertEq(loaded, uint16(1 << 8)); + assertEq(dirtyBefore, uint16(1 << 8)); + assertEq(dirtyAfter, 0); + assertEq(harness.getRaw(8), 7777); + for (uint256 word; word < 8; word++) { + assertEq(harness.getRaw(word), 1000 + word); + } + } + + function test_dirtyFrameCannotInvalidateWithoutFlush() public { + assertTrue(harness.dirtyInvalidateReverts()); + } + + function test_wordBounds() public { + vm.expectRevert(PackedHeaderFrameLib.HeaderWordOutOfBounds.selector); + harness.probeTwice(9); + } + + function test_battleStateTypedAccessorsMatchStoragePacking() public { + address p0 = address(0x1234567890123456789012345678901234567890); + address p1 = address(0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD); + uint256 staticWord = uint256(uint160(p1)) | (uint256(9) << 160) | (uint256(11) << 176); + uint256 stateWord = uint256(uint160(p0)) | (uint256(2) << 160) | (uint256(1) << 168) + | (uint256(0x0703) << 176) | (uint256(123456) << 192) | (uint256(41) << 232) + | (uint256(6) << 248); + harness.setRaw(0, staticWord); + harness.setRaw(1, stateWord); + + ( + address gotP0, + address gotP1, + uint8 winner, + uint8 switchFlag, + uint16 active, + uint40 timestamp, + uint16 turn, + uint8 buffered + ) = harness.readBattleState(); + assertEq(gotP0, p0); + assertEq(gotP1, p1); + assertEq(winner, 2); + assertEq(switchFlag, 1); + assertEq(active, 0x0703); + assertEq(timestamp, 123456); + assertEq(turn, 41); + assertEq(buffered, 6); + } + + function test_advanceTurnPreservesUnrelatedPackedFields() public { + uint256 original = uint256(uint160(address(0x1234))) | (uint256(2) << 160) | (uint256(1) << 168) + | (uint256(0x0703) << 176) | (uint256(99) << 192) | (uint256(41) << 232) + | (uint256(6) << 248); + harness.setRaw(1, original); + harness.advanceTurnAndFlush(2, 777); + + uint256 expected = uint256(uint160(address(0x1234))) | (uint256(2) << 160) | (uint256(2) << 168) + | (uint256(0x0703) << 176) | (uint256(777) << 192) | (uint256(42) << 232) + | (uint256(6) << 248); + assertEq(harness.getRaw(1), expected); + } +} diff --git a/test/mocks/AfterDamageReboundEffect.sol b/test/mocks/AfterDamageReboundEffect.sol index fd0767d1..17407c66 100644 --- a/test/mocks/AfterDamageReboundEffect.sol +++ b/test/mocks/AfterDamageReboundEffect.sol @@ -10,7 +10,7 @@ import {BasicEffect} from "../../src/effects/BasicEffect.sol"; contract AfterDamageReboundEffect is BasicEffect { // Steps: AfterDamage - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x40; } diff --git a/test/mocks/AlwaysRejectsEffect.sol b/test/mocks/AlwaysRejectsEffect.sol index 3adb1bd7..44a78bd4 100644 --- a/test/mocks/AlwaysRejectsEffect.sol +++ b/test/mocks/AlwaysRejectsEffect.sol @@ -16,7 +16,7 @@ contract AlwaysRejectsEffect is BasicEffect { } // ALWAYS_APPLIES_BIT (0x8000) | RoundEnd (0x04) - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8004; } diff --git a/test/mocks/BoostBenchMove.sol b/test/mocks/BoostBenchMove.sol new file mode 100644 index 00000000..599e834c --- /dev/null +++ b/test/mocks/BoostBenchMove.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../src/Constants.sol"; +import "../../src/Enums.sol"; +import "../../src/Structs.sol"; + +import {IEngine} from "../../src/IEngine.sol"; +import {IMoveSet} from "../../src/moves/IMoveSet.sol"; + +/// @dev Distinct msg.sender per boost source (sources are keyed by caller). Memory prep happens +/// before the gas bracket so the measurement is the engine call alone (+ ~constant CALL +/// overhead, identical across K). +contract BoostSatellite { + function boost(IEngine engine, uint256 playerIndex, uint256 monIndex, uint8 pct) external returns (uint256 g) { + StatBoostToApply[] memory b = new StatBoostToApply[](1); + b[0] = StatBoostToApply({stat: MonStateIndexName.Attack, boostPercent: pct, boostType: StatBoostType.Multiply}); + uint256 g0 = gasleft(); + engine.addStatBoost(playerIndex, monIndex, b, StatBoostFlag.Perm); + g = g0 - gasleft(); + } + + function unboost(IEngine engine, uint256 playerIndex, uint256 monIndex) external returns (uint256 g) { + uint256 g0 = gasleft(); + engine.removeStatBoost(playerIndex, monIndex, StatBoostFlag.Perm); + g = g0 - gasleft(); + } +} + +/// @dev R1.0 microbench driver: extraData = K (0..5) existing sources to install, then measures +/// one more add (the K+1th source) and its removal. Results parked in public storage. +contract BoostBenchMove is IMoveSet { + BoostSatellite[6] public sats; + uint256 public lastAddGas; + uint256 public lastRemoveGas; + + constructor() { + for (uint256 i; i < 6; i++) { + sats[i] = new BoostSatellite(); + } + } + + function name() external pure returns (string memory) { + return "Boost Bench"; + } + + function move(IEngine engine, bytes32, uint256 attackerPlayerIndex, uint256, uint256, uint256, uint16 extraData, uint256) + external + { + uint256 k = uint256(extraData) & 0x7; + for (uint256 i; i < k; i++) { + sats[i].boost(engine, attackerPlayerIndex, 0, uint8(10 + i)); + } + lastAddGas = sats[k].boost(engine, attackerPlayerIndex, 0, 50); + lastRemoveGas = sats[k].unboost(engine, attackerPlayerIndex, 0); + } + + function priority(IEngine, bytes32, uint256) public pure returns (uint32) { + return 0; + } + + function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { + return 0; + } + + function moveType(IEngine, bytes32) public pure returns (Type) { + return Type.Air; + } + + function moveClass(IEngine, bytes32) public pure returns (MoveClass) { + return MoveClass.Self; + } + + function getMeta(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex) + external + pure + returns (MoveMeta memory) + { + return MoveMeta({ + moveType: moveType(engine, battleKey), + moveClass: moveClass(engine, battleKey), + priority: priority(engine, battleKey, attackerPlayerIndex), + stamina: stamina(engine, battleKey, attackerPlayerIndex, attackerMonIndex), + basePower: 0 + }); + } +} diff --git a/test/mocks/DummyStatus.sol b/test/mocks/DummyStatus.sol index f13b5515..ee618b70 100644 --- a/test/mocks/DummyStatus.sol +++ b/test/mocks/DummyStatus.sol @@ -11,7 +11,7 @@ contract DummyStatus is BasicEffect { } // Steps: None - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x00; } } diff --git a/test/mocks/FreshMoveContextAbility.sol b/test/mocks/FreshMoveContextAbility.sol new file mode 100644 index 00000000..4891c45e --- /dev/null +++ b/test/mocks/FreshMoveContextAbility.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {MOVE_INDEX_MASK, NO_OP_MOVE_INDEX, NO_SLOT} from "../../src/Constants.sol"; +import {IAbility} from "../../src/abilities/IAbility.sol"; +import {BasicEffect} from "../../src/effects/BasicEffect.sol"; +import {IEffect} from "../../src/effects/IEffect.sol"; +import {IEngine} from "../../src/IEngine.sol"; +import {TargetLib} from "../../src/lib/TargetLib.sol"; + +uint256 constant TEST_CONTEXT_STATUS_CLASS = 15; + +contract RewriteMoveAfterMove is BasicEffect { + function getStepsBitmap() external pure override returns (uint32) { + return 0x8080; + } + + function onAfterMove( + IEngine engine, + bytes32 battleKey, + uint256, + bytes32 extraData, + uint256 targetIndex, + uint256 monIndex, + uint256 activesPacked + ) external override returns (bytes32, bool) { + uint256 ownSlot = TargetLib.slotOfMon(activesPacked, targetIndex, monIndex); + if (ownSlot != NO_SLOT) { + engine.setMoveForSlot(battleKey, targetIndex, ownSlot & 1, NO_OP_MOVE_INDEX, 0); + } + return (extraData, false); + } +} + +contract FreshMoveContextAbility is IAbility, BasicEffect { + IEffect private immutable REWRITER; + uint8 public observedMoveIndex; + + constructor() { + REWRITER = IEffect(address(new RewriteMoveAfterMove())); + } + + function name() public pure override(IAbility, BasicEffect) returns (string memory) { + return "Fresh Move Context"; + } + + function activateOnSwitch(IEngine engine, bytes32, uint256 playerIndex, uint256 monIndex) external override { + // Stable effect-array order: the rewriter runs before this observer. + engine.addEffect(playerIndex, monIndex, REWRITER, bytes32(0)); + engine.addEffect(playerIndex, monIndex, IEffect(address(this)), bytes32(0)); + } + + function getStepsBitmap() external pure override returns (uint32) { + return 0x00808080; // AfterMove context | ALWAYS_APPLIES | AfterMove + } + + function onAfterMove(IEngine, bytes32, uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, uint256 context) + external + override + returns (bytes32, bool) + { + uint256 ownSlot = TargetLib.slotOfMon(context, targetIndex, monIndex); + observedMoveIndex = uint8(TargetLib.hookMoveWordAt(context, ownSlot)) & MOVE_INDEX_MASK; + return (extraData, false); + } +} + +contract ContextTestStatus is BasicEffect { + function getStepsBitmap() external pure override returns (uint32) { + return uint32(0x8000 | (TEST_CONTEXT_STATUS_CLASS << 10)); + } +} + +contract AddStatusAtRoundEnd is BasicEffect { + IEffect private immutable STATUS; + + constructor(IEffect status) { + STATUS = status; + } + + function getStepsBitmap() external pure override returns (uint32) { + return 0x8004; + } + + function onRoundEnd(IEngine engine, bytes32, uint256, bytes32 extraData, uint256 side, uint256 mon, uint256) + external + override + returns (bytes32, bool) + { + engine.addEffect(side, mon, STATUS, bytes32(0)); + return (extraData, false); + } +} + +contract FreshStatusContextAbility is IAbility, BasicEffect { + IEffect private immutable WRITER; + uint8 public observedStatusClass; + + constructor() { + IEffect status = IEffect(address(new ContextTestStatus())); + WRITER = IEffect(address(new AddStatusAtRoundEnd(status))); + } + + function name() public pure override(IAbility, BasicEffect) returns (string memory) { + return "Fresh Status Context"; + } + + function activateOnSwitch(IEngine engine, bytes32, uint256 playerIndex, uint256 monIndex) external override { + engine.addEffect(playerIndex, monIndex, WRITER, bytes32(0)); + engine.addEffect(playerIndex, monIndex, IEffect(address(this)), bytes32(0)); + } + + function getStepsBitmap() external pure override returns (uint32) { + return 0x00048004; // RoundEnd context | ALWAYS_APPLIES | RoundEnd + } + + function onRoundEnd(IEngine, bytes32, uint256, bytes32 extraData, uint256 side, uint256 mon, uint256 context) + external + override + returns (bytes32, bool) + { + observedStatusClass = uint8(TargetLib.hookStatusClass(context, side, mon)); + return (extraData, false); + } +} diff --git a/test/mocks/InstantDeathEffect.sol b/test/mocks/InstantDeathEffect.sol index 7e3a3f92..07fa8d52 100644 --- a/test/mocks/InstantDeathEffect.sol +++ b/test/mocks/InstantDeathEffect.sol @@ -14,7 +14,7 @@ contract InstantDeathEffect is BasicEffect { } // Steps: RoundEnd - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x04; } diff --git a/test/mocks/InstantDeathOnSwitchInEffect.sol b/test/mocks/InstantDeathOnSwitchInEffect.sol index 2d5bf221..dedf8830 100644 --- a/test/mocks/InstantDeathOnSwitchInEffect.sol +++ b/test/mocks/InstantDeathOnSwitchInEffect.sol @@ -14,7 +14,7 @@ contract InstantDeathOnSwitchInEffect is BasicEffect { } // Steps: OnMonSwitchIn - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x10; } diff --git a/test/mocks/MockSingletonAbility.sol b/test/mocks/MockSingletonAbility.sol index 0aaba565..730ac840 100644 --- a/test/mocks/MockSingletonAbility.sol +++ b/test/mocks/MockSingletonAbility.sol @@ -26,7 +26,7 @@ contract MockSingletonAbility is IAbility, BasicEffect { } // Steps: AfterDamage (just to verify hooks run) - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x8040; // ALWAYS_APPLIES | AfterDamage } diff --git a/test/mocks/NeverAppliesEffect.sol b/test/mocks/NeverAppliesEffect.sol index 4d7469f7..8b5d286d 100644 --- a/test/mocks/NeverAppliesEffect.sol +++ b/test/mocks/NeverAppliesEffect.sol @@ -15,7 +15,7 @@ contract NeverAppliesEffect is BasicEffect { } // RoundEnd (0x04), NO ALWAYS_APPLIES_BIT - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x04; } diff --git a/test/mocks/OnUpdateMonStateHealEffect.sol b/test/mocks/OnUpdateMonStateHealEffect.sol index c562c9d2..7beb117e 100644 --- a/test/mocks/OnUpdateMonStateHealEffect.sol +++ b/test/mocks/OnUpdateMonStateHealEffect.sol @@ -17,7 +17,7 @@ contract OnUpdateMonStateHealEffect is BasicEffect { int32 public constant HEAL_AMOUNT = 5; // Steps: OnUpdateMonState - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x100; } diff --git a/test/mocks/OneTurnStatBoost.sol b/test/mocks/OneTurnStatBoost.sol index 508abcad..f9c446de 100644 --- a/test/mocks/OneTurnStatBoost.sol +++ b/test/mocks/OneTurnStatBoost.sol @@ -14,7 +14,7 @@ contract OneTurnStatBoost is BasicEffect { } // Steps: OnApply, RoundEnd - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x05; } diff --git a/test/mocks/OrderRecorderAbility.sol b/test/mocks/OrderRecorderAbility.sol index e7c39248..64a38a70 100644 --- a/test/mocks/OrderRecorderAbility.sol +++ b/test/mocks/OrderRecorderAbility.sol @@ -27,7 +27,7 @@ contract OrderRecorderAbility is IAbility, BasicEffect { } } - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return uint16( ALWAYS_APPLIES_BIT | (1 << uint8(EffectStep.RoundStart)) | (1 << uint8(EffectStep.RoundEnd)) | (1 << uint8(EffectStep.AfterMove)) diff --git a/test/mocks/RawDamageMove.sol b/test/mocks/RawDamageMove.sol new file mode 100644 index 00000000..eb17f0b6 --- /dev/null +++ b/test/mocks/RawDamageMove.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../src/Constants.sol"; +import "../../src/Enums.sol"; +import "../../src/Structs.sol"; + +import {IEngine} from "../../src/IEngine.sol"; +import {TargetLib} from "../../src/lib/TargetLib.sol"; +import {IMoveSet} from "../../src/moves/IMoveSet.sol"; + +/// Deals an exact `int32` to the target via `dealDamage` — lets a test drive the raw damage value +/// the engine has to absorb, including the clamped `type(int32).max` the damage formula can produce. +contract RawDamageMove is IMoveSet { + int32 immutable DAMAGE; + + constructor(int32 damage) { + DAMAGE = damage; + } + + function name() external pure returns (string memory) { + return "Raw Damage Move"; + } + + function move(IEngine engine, bytes32, uint256 attackerPlayerIndex, uint256, uint256 targetBits, uint256 activesPacked, uint16, uint256) + external + { + uint256 defenderMonIndex = TargetLib.activeAt(activesPacked, TargetLib.lowestSlot(targetBits)); + engine.dealDamage((attackerPlayerIndex + 1) % 2, defenderMonIndex, DAMAGE); + } + + function priority(IEngine, bytes32, uint256) public pure returns (uint32) { + return DEFAULT_PRIORITY; + } + + function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { + return 0; + } + + function moveType(IEngine, bytes32) public pure returns (Type) { + return Type.Fire; + } + + function moveClass(IEngine, bytes32) public pure returns (MoveClass) { + return MoveClass.Physical; + } + + function getMeta(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex) + external + pure + returns (MoveMeta memory) + { + return MoveMeta({ + moveType: moveType(engine, battleKey), + moveClass: moveClass(engine, battleKey), + priority: priority(engine, battleKey, attackerPlayerIndex), + stamina: stamina(engine, battleKey, attackerPlayerIndex, attackerMonIndex), + basePower: 0 + }); + } +} diff --git a/test/mocks/SetEffectDataAttack.sol b/test/mocks/SetEffectDataAttack.sol new file mode 100644 index 00000000..cb212376 --- /dev/null +++ b/test/mocks/SetEffectDataAttack.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {MoveClass, Type} from "../../src/Enums.sol"; +import {IEngine} from "../../src/IEngine.sol"; +import {MoveMeta} from "../../src/Structs.sol"; +import {IMoveSet} from "../../src/moves/IMoveSet.sol"; + +contract SetEffectDataAttack is IMoveSet { + bytes32 immutable DATA; + + constructor(bytes32 data) { + DATA = data; + } + + function name() external pure returns (string memory) { + return "Set Effect Data"; + } + + function move(IEngine engine, bytes32, uint256, uint256, uint256, uint256, uint16 extraData, uint256) external { + uint256 targetIndex = uint256(extraData) & 0x3; + uint256 effectIndex = uint256(extraData) >> 2; + engine.editEffect(targetIndex, effectIndex, DATA); + } + + function priority(IEngine, bytes32, uint256) public pure returns (uint32) { + return 1; + } + + function stamina(IEngine, bytes32, uint256, uint256) public pure returns (uint32) { + return 0; + } + + function moveType(IEngine, bytes32) public pure returns (Type) { + return Type.Fire; + } + + function moveClass(IEngine, bytes32) public pure returns (MoveClass) { + return MoveClass.Physical; + } + + function basePower(bytes32) external pure returns (uint32) { + return 0; + } + + function getMeta(IEngine engine, bytes32 battleKey, uint256 attackerPlayerIndex, uint256 attackerMonIndex) + external + pure + returns (MoveMeta memory) + { + return MoveMeta({ + moveType: moveType(engine, battleKey), + moveClass: moveClass(engine, battleKey), + priority: priority(engine, battleKey, attackerPlayerIndex), + stamina: stamina(engine, battleKey, attackerPlayerIndex, attackerMonIndex), + basePower: 0 + }); + } +} diff --git a/test/mocks/SingleInstanceEffect.sol b/test/mocks/SingleInstanceEffect.sol index 06103886..8798c7f3 100644 --- a/test/mocks/SingleInstanceEffect.sol +++ b/test/mocks/SingleInstanceEffect.sol @@ -14,7 +14,7 @@ contract SingleInstanceEffect is BasicEffect { } // Steps: OnApply - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x01; } diff --git a/test/mocks/SpAtkDebuffEffect.sol b/test/mocks/SpAtkDebuffEffect.sol index 1d03f4a4..bf731c05 100644 --- a/test/mocks/SpAtkDebuffEffect.sol +++ b/test/mocks/SpAtkDebuffEffect.sol @@ -1,13 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; +import {ALWAYS_APPLIES_BIT, STATUS_CLASS_SHIFT} from "../../src/Constants.sol"; import {MonStateIndexName, StatBoostFlag, StatBoostType} from "../../src/Enums.sol"; import {IEngine} from "../../src/IEngine.sol"; import {StatBoostToApply} from "../../src/Structs.sol"; import {StatusEffect} from "../../src/effects/status/StatusEffect.sol"; +import {TEST_STATUS_CLASS} from "./TestStatusClass.sol"; contract SpAtkDebuffEffect is StatusEffect { + uint256 constant STATUS_CLASS = TEST_STATUS_CLASS; + uint8 constant SP_ATTACK_PERCENT = 50; function name() public pure override returns (string memory) { @@ -15,22 +19,19 @@ contract SpAtkDebuffEffect is StatusEffect { } // Steps: OnApply, OnRemove - function getStepsBitmap() external pure override returns (uint16) { - return 0x09; + function getStepsBitmap() external pure override returns (uint32) { + return 0x09 | uint16(STATUS_CLASS << STATUS_CLASS_SHIFT) | ALWAYS_APPLIES_BIT; } function onApply( IEngine engine, - bytes32 battleKey, - uint256 rng, + bytes32, + uint256, bytes32 extraData, uint256 targetIndex, uint256 monIndex, - uint256 activesPacked + uint256 ) public override returns (bytes32 updatedExtraData, bool removeAfterRun) { - // Call parent to set status flag - super.onApply(engine, battleKey, rng, extraData, targetIndex, monIndex, activesPacked); - // Reduce special attack by half StatBoostToApply[] memory statBoosts = new StatBoostToApply[](1); statBoosts[0] = StatBoostToApply({ @@ -42,16 +43,10 @@ contract SpAtkDebuffEffect is StatusEffect { return (extraData, false); } - function onRemove( - IEngine engine, - bytes32 battleKey, - bytes32 data, - uint256 targetIndex, - uint256 monIndex, - uint256 activesPacked - ) public override { - super.onRemove(engine, battleKey, data, targetIndex, monIndex, activesPacked); - + function onRemove(IEngine engine, bytes32, bytes32, uint256 targetIndex, uint256 monIndex, uint256) + public + override + { // Reset the special attack reduction engine.removeStatBoost(targetIndex, monIndex, StatBoostFlag.Perm); } diff --git a/test/mocks/StaminaGainKOEffect.sol b/test/mocks/StaminaGainKOEffect.sol new file mode 100644 index 00000000..93227664 --- /dev/null +++ b/test/mocks/StaminaGainKOEffect.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +import "../../src/Enums.sol"; +import "../../src/Structs.sol"; + +import {IEngine} from "../../src/IEngine.sol"; +import {BasicEffect} from "../../src/effects/BasicEffect.sol"; + +/// @notice On any positive stamina gain, deals lethal damage to the mon that gained it. A generic, +/// mon-agnostic stand-in for effects whose OnUpdateMonState hook reaches dealDamage and can therefore +/// KO a mon off an inline stamina-regen tick. +contract StaminaGainKOEffect is BasicEffect { + function name() external pure override returns (string memory) { + return "Stamina Gain KO"; + } + + // Steps: OnUpdateMonState + function getStepsBitmap() external pure override returns (uint32) { + return 0x100; + } + + function onUpdateMonState( + IEngine engine, + bytes32 battleKey, + uint256, + bytes32 extraData, + uint256 playerIndex, + uint256 monIndex, + uint256, + MonStateIndexName stateVarIndex, + int32 valueToAdd + ) external override returns (bytes32, bool) { + if (stateVarIndex == MonStateIndexName.Stamina && valueToAdd > 0) { + uint32 maxHp = engine.getMonValueForBattle(battleKey, playerIndex, monIndex, MonStateIndexName.Hp); + engine.dealDamage(playerIndex, monIndex, int32(uint32(maxHp))); + } + return (extraData, false); + } +} diff --git a/test/mocks/TempStatBoostEffect.sol b/test/mocks/TempStatBoostEffect.sol index 6464ab24..31fe6968 100644 --- a/test/mocks/TempStatBoostEffect.sol +++ b/test/mocks/TempStatBoostEffect.sol @@ -14,7 +14,7 @@ contract TempStatBoostEffect is BasicEffect { } // Steps: OnApply - function getStepsBitmap() external pure override returns (uint16) { + function getStepsBitmap() external pure override returns (uint32) { return 0x01; } diff --git a/test/mocks/TestStatusClass.sol b/test/mocks/TestStatusClass.sol new file mode 100644 index 00000000..50a2a8dc --- /dev/null +++ b/test/mocks/TestStatusClass.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +// Status class id reserved for test-only statuses: prod ids are 1..14 (validateEffectBitmaps.py +// rejects 15 in src/), so mocks can never collide with a deployed status. Mocks share this id; +// if a test ever needs two distinct mock statuses in one battle, extend the reservation to a band. +uint256 constant TEST_STATUS_CLASS = 15; diff --git a/test/mocks/WideDataEffect.sol b/test/mocks/WideDataEffect.sol new file mode 100644 index 00000000..a09188e9 --- /dev/null +++ b/test/mocks/WideDataEffect.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {ALWAYS_APPLIES_BIT} from "../../src/Constants.sol"; +import {IEngine} from "../../src/IEngine.sol"; +import {BasicEffect} from "../../src/effects/BasicEffect.sol"; + +contract WideDataEffect is BasicEffect { + bytes32 public constant INITIAL_DATA = bytes32((uint256(1) << 200) | 123); + + function name() external pure override returns (string memory) { + return "Wide Data"; + } + + function getStepsBitmap() external pure override returns (uint32) { + return uint16(1) | ALWAYS_APPLIES_BIT; + } + + function onApply(IEngine, bytes32, uint256, bytes32, uint256, uint256, uint256) + external + pure + override + returns (bytes32 updatedExtraData, bool removeAfterRun) + { + return (INITIAL_DATA, false); + } +} diff --git a/test/mons/AuroxTest.sol b/test/mons/AuroxTest.sol index f233a06d..86737c27 100644 --- a/test/mons/AuroxTest.sol +++ b/test/mons/AuroxTest.sol @@ -809,10 +809,11 @@ contract AuroxTest is Test, BattleHelper { // Alice uses volatile punch, Bob does nothing _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); - // Verify that Bob's mon index 0 has frostbite (first effect is stat boost) + // Verify that Bob's mon index 0 has frostbite (its debuff lives in the boost store, + // so the status is the only effect entry) (EffectInstance[] memory effects,) = engine.getEffects(battleKey, 1, 0); - assertEq(address(effects[1].effect), address(frostbiteStatus), "Bob's mon should have frostbite"); + assertEq(address(effects[0].effect), address(frostbiteStatus), "Bob's mon should have frostbite"); // Precomputed seed triggers burn for Bob (player 1) mockOracle.setRNG(1); @@ -820,8 +821,8 @@ contract AuroxTest is Test, BattleHelper { // Alice does nothing, Bob uses volatile punch _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, NO_OP_MOVE_INDEX, 0, 0, 0); - // Verify that Alice's mon index 0 has burn (first effect is stat boost) + // Verify that Alice's mon index 0 has burn (effects,) = engine.getEffects(battleKey, 0, 0); - assertEq(address(effects[1].effect), address(burnStatus), "Alice's mon should have burn"); + assertEq(address(effects[0].effect), address(burnStatus), "Alice's mon should have burn"); } } diff --git a/test/mons/DoublesKitTest.sol b/test/mons/DoublesKitTest.sol index ec50f4f6..5b4b0e07 100644 --- a/test/mons/DoublesKitTest.sol +++ b/test/mons/DoublesKitTest.sol @@ -135,6 +135,7 @@ contract DoublesKitTest is Test { Mon[] memory aTeam = new Mon[](3); aTeam[0] = _mkMon(IMoveSet(address(hitAndDip)), 0); aTeam[1] = _mkMon(IMoveSet(address(hitAndDip)), 0); + aTeam[1].moves[0] |= MOVE_META_TAG | MOVE_RESOLVER_TAG | (uint256(2) << 236) | (DEFAULT_PRIORITY << 244); aTeam[2] = _mkMon(IMoveSet(address(hitAndDip)), 0); Mon[] memory bTeam = new Mon[](2); bTeam[0] = _mkMon(IMoveSet(address(hitAndDip)), 0); diff --git a/test/mons/EmbursaTest.sol b/test/mons/EmbursaTest.sol index fe73967b..9a5e1de6 100644 --- a/test/mons/EmbursaTest.sol +++ b/test/mons/EmbursaTest.sol @@ -13,6 +13,7 @@ import {DefaultCommitManager} from "../../src/commit-manager/DefaultCommitManage import {IEngine} from "../../src/IEngine.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; import {IMoveSet} from "../../src/moves/IMoveSet.sol"; +import {MoveSlotLib} from "../../src/moves/MoveSlotLib.sol"; import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; import {BattleHelper} from "../abstract/BattleHelper.sol"; @@ -138,7 +139,7 @@ contract EmbursaTest is Test, BattleHelper { ); uint256[] memory moves = new uint256[](4); - moves[0] = uint256(uint160(address(heatBeacon))); + moves[0] = MoveSlotLib.packDeployed(address(heatBeacon), 0, MOVE_META_DYNAMIC) | MOVE_CONTEXT_STATUS_LANES; moves[1] = uint256(uint160(address(burnMove))); moves[2] = uint256(uint160(address(burnMove))); moves[3] = uint256(uint160(address(burnMove))); @@ -425,11 +426,11 @@ contract EmbursaTest is Test, BattleHelper { } function _aliceActive(bytes32 battleKey) internal view returns (uint256) { - return engine.getActiveMonIndexForBattleState(battleKey)[0]; + return engine.getBattleContext(battleKey).p0ActiveMonIndex; } function _bobActive(bytes32 battleKey) internal view returns (uint256) { - return engine.getActiveMonIndexForBattleState(battleKey)[1]; + return engine.getBattleContext(battleKey).p1ActiveMonIndex; } /** diff --git a/test/mons/GhouliathTest.sol b/test/mons/GhouliathTest.sol index e43f0f37..b7d1038c 100644 --- a/test/mons/GhouliathTest.sol +++ b/test/mons/GhouliathTest.sol @@ -14,6 +14,7 @@ import {IEngine} from "../../src/IEngine.sol"; import {IEffect} from "../../src/effects/IEffect.sol"; import {IMoveSet} from "../../src/moves/IMoveSet.sol"; +import {MoveSlotLib} from "../../src/moves/MoveSlotLib.sol"; import {StandardAttackFactory} from "../../src/moves/StandardAttackFactory.sol"; import {ATTACK_PARAMS} from "../../src/moves/StandardAttackStructs.sol"; import {ITypeCalculator} from "../../src/types/ITypeCalculator.sol"; @@ -541,16 +542,22 @@ contract GhouliathTest is Test, BattleHelper { assertEq(spAttackDelta, -5, "Bob's mon's special attack should be debuffed"); } - // Build two single-mon (well, duplicated) teams and start a battle. Alice carries - // [burnApplier, graveAffliction]; Bob carries two fillers. Returns the battle key after both - // mons are switched in. - function _startGraveAfflictionBattle(IMoveSet aliceMove0, IMoveSet aliceMove1) + // Grave Affliction reads the defender's status class out of its move context, so its slot + // needs the capability bit the deployment generator derives from its @move-context tag. + function _graveAfflictionSlot() internal view returns (uint256) { + return MoveSlotLib.packDeployed(address(graveAffliction), 0, MOVE_META_DYNAMIC) | MOVE_CONTEXT_STATUS_LANES; + } + + // Build two single-mon (well, duplicated) teams and start a battle. Alice carries the two + // given packed move slots; Bob carries two fillers. Returns the battle key after both mons + // are switched in. + function _startGraveAfflictionBattle(uint256 aliceMove0, uint256 aliceMove1) internal returns (bytes32 battleKey) { uint256[] memory aliceMoves = new uint256[](2); - aliceMoves[0] = uint256(uint160(address(aliceMove0))); - aliceMoves[1] = uint256(uint160(address(aliceMove1))); + aliceMoves[0] = aliceMove0; + aliceMoves[1] = aliceMove1; uint256[] memory bobMoves = new uint256[](2); bobMoves[0] = uint256(uint160(address(osteoporosis))); @@ -623,7 +630,7 @@ contract GhouliathTest is Test, BattleHelper { }) ); - bytes32 battleKey = _startGraveAfflictionBattle(burnApplier, IMoveSet(address(graveAffliction))); + bytes32 battleKey = _startGraveAfflictionBattle(uint256(uint160(address(burnApplier))), _graveAfflictionSlot()); // Alice burns Bob (move 0); Bob no-ops. _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); @@ -650,8 +657,7 @@ contract GhouliathTest is Test, BattleHelper { // With no status on the opponent, Grave Affliction is a no-op for both mons. function testGraveAffliction_noOpWhenOpponentHealthy() public { - bytes32 battleKey = - _startGraveAfflictionBattle(IMoveSet(address(graveAffliction)), IMoveSet(address(graveAffliction))); + bytes32 battleKey = _startGraveAfflictionBattle(_graveAfflictionSlot(), _graveAfflictionSlot()); // Alice uses Grave Affliction (move 0) while Bob has no status; Bob no-ops. _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, 0, 0); diff --git a/test/mons/IblivionTest.sol b/test/mons/IblivionTest.sol index dd7c6045..ccf2dcb5 100644 --- a/test/mons/IblivionTest.sol +++ b/test/mons/IblivionTest.sol @@ -486,7 +486,7 @@ contract IblivionTest is Test, BattleHelper { engine, commitManager, battleKey, SWITCH_MOVE_INDEX, SWITCH_MOVE_INDEX, uint16(0), uint16(0) ); - // At Baselight 1 (seed), Loop should give a 15% boost. + // At Baselight 1 (seed), Loop should give a % boost. assertEq(baselight.getBaselightLevel(engine, battleKey, 0, 0), 1, "Should have 1 stack"); // Get stats before Loop @@ -495,9 +495,9 @@ contract IblivionTest is Test, BattleHelper { // Alice uses Loop _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 2, NO_OP_MOVE_INDEX, 0, 0); - // Check stats are boosted (15% of 100 = 15) + // Check stats are boosted int32 attackAfter = engine.getMonStateForBattle(battleKey, 0, 0, MonStateIndexName.Attack); - assertEq(attackAfter - attackBefore, 15, "Attack should be boosted by 15%"); + assertEq(attackAfter - attackBefore, int32(int8(loop.BOOST_PERCENT_LEVEL_1())), "Attack should be boosted by %"); // Loop should be marked as active assertTrue(loop.isLoopActive(engine, battleKey, 0, 0), "Loop should be active"); diff --git a/test/mons/NirvammaTest.sol b/test/mons/NirvammaTest.sol index 015ccd2b..01a802f0 100644 --- a/test/mons/NirvammaTest.sol +++ b/test/mons/NirvammaTest.sol @@ -186,8 +186,8 @@ contract NirvammaTest is Test, BattleHelper { "Nirvamma HP after own trigger mismatch" ); // Alice should have force-swapped to filler (mon 1). - uint256[] memory active = engine.getActiveMonIndexForBattleState(battleKey); - assertEq(active[0], 1, "Alice should have force-swapped to filler"); + BattleContext memory active = engine.getBattleContext(battleKey); + assertEq(active.p0ActiveMonIndex, 1, "Alice should have force-swapped to filler"); } } @@ -215,8 +215,8 @@ contract NirvammaTest is Test, BattleHelper { int32 hpAfter = engine.getMonStateForBattle(battleKey, 1, 0, MonStateIndexName.Hp); assertLe(hpAfter, bobHpBefore - 10, "Bob's mon should take >= 10 extra damage from opp trigger"); // Bob should have force-swapped. - uint256[] memory active = engine.getActiveMonIndexForBattleState(battleKey); - assertEq(active[1], 1, "Bob should have force-swapped to filler"); + BattleContext memory active = engine.getBattleContext(battleKey); + assertEq(active.p1ActiveMonIndex, 1, "Bob should have force-swapped to filler"); } function test_hardReset_selfRemovesAfterBothFire() public { diff --git a/test/mons/PengymTest.sol b/test/mons/PengymTest.sol index 8b4cb6a7..b57d7008 100644 --- a/test/mons/PengymTest.sol +++ b/test/mons/PengymTest.sol @@ -590,20 +590,20 @@ contract PengymTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); // Active mon for Bob should be 1 - uint256 bobActiveMonIndex = engine.getActiveMonIndexForBattleState(battleKey)[1]; + uint256 bobActiveMonIndex = engine.getBattleContext(battleKey).p1ActiveMonIndex; assertEq(bobActiveMonIndex, 1, "Swap succeeded (0 -> 1)"); // Alice selects pistol squat, Bob does nothing _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); // Active mon for Bob should be 0 - bobActiveMonIndex = engine.getActiveMonIndexForBattleState(battleKey)[1]; + bobActiveMonIndex = engine.getBattleContext(battleKey).p1ActiveMonIndex; assertEq(bobActiveMonIndex, 0, "Swap succeeded (1 -> 0)"); // Alice selects pistol squat, Bob does nothing (and dies) _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); - bobActiveMonIndex = engine.getActiveMonIndexForBattleState(battleKey)[1]; + bobActiveMonIndex = engine.getBattleContext(battleKey).p1ActiveMonIndex; assertEq(bobActiveMonIndex, 0, "No swap because KO"); // Bob sends in mon index 1 @@ -622,7 +622,7 @@ contract PengymTest is Test, BattleHelper { _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); // Now Bob has mon index 2 (already took damage) and mon index 3 - bobActiveMonIndex = engine.getActiveMonIndexForBattleState(battleKey)[1]; + bobActiveMonIndex = engine.getBattleContext(battleKey).p1ActiveMonIndex; assertEq(bobActiveMonIndex, 3, "Switch forced"); // Bob switches back to mon index 2, Alice does nothing @@ -639,7 +639,7 @@ contract PengymTest is Test, BattleHelper { engine.resetCallContext(); // Alice tries to force a switch, but active mon should not change _commitRevealExecuteForAliceAndBob(engine, commitManager, battleKey, 0, NO_OP_MOVE_INDEX, uint16(0), uint16(0)); - bobActiveMonIndex = engine.getActiveMonIndexForBattleState(battleKey)[1]; + bobActiveMonIndex = engine.getBattleContext(battleKey).p1ActiveMonIndex; assertEq(bobActiveMonIndex, 3, "No mons left"); } } diff --git a/transpiler/codegen/statement.py b/transpiler/codegen/statement.py index 2243e9f3..d5a8593b 100644 --- a/transpiler/codegen/statement.py +++ b/transpiler/codegen/statement.py @@ -519,10 +519,38 @@ def generate_revert_statement(self, stmt: RevertStatement) -> str: # ASSEMBLY # ========================================================================= + def _slot_layout_of(self, var_name: str): + """(slot-0 layout, member TypeNames) for a struct-typed local, or None. + + Assembly reaching a raw slot has to be reconciled with the modelled + fields; only the statement layer knows local variable types. + """ + from ..type_system.slots import slot_zero + + registry = getattr(self._ctx, '_registry', None) + if registry is None: + return None + type_name = self._ctx.var_types.get(var_name) + if type_name is None or not getattr(type_name, 'name', None): + return None + struct = registry.struct_defs.get(type_name.name) + if struct is None: + return None + layout = slot_zero( + struct, + enum_names=registry.enums, + struct_names=registry.structs, + structs=registry.struct_defs, + ) + if layout is None: + return None + member_types = {m.name: m.type_name for m in struct.members} + return layout, member_types + def generate_assembly_statement(self, stmt: AssemblyStatement) -> str: """Generate TypeScript code for an assembly block (transpiled from Yul).""" yul_code = stmt.block.code - ts_code = self._yul_transpiler.transpile(yul_code) + ts_code = self._yul_transpiler.transpile(yul_code, self._slot_layout_of) if self._yul_transpiler.unmodelable: self._ctx.current_function_unmodelable = True lines = [] diff --git a/transpiler/codegen/yul.py b/transpiler/codegen/yul.py index 24d4ea07..23c8b253 100644 --- a/transpiler/codegen/yul.py +++ b/transpiler/codegen/yul.py @@ -612,6 +612,9 @@ def __init__(self, known_constants: set = None): self._known_constants = known_constants or set() self._warnings: List[str] = [] self._unmodelable = False + # var name -> (SlotLayout, {member: TypeName}); set per block by the + # caller, which is the only layer that knows local variable types. + self._slot_resolver = None @property def warnings(self) -> List[str]: @@ -624,7 +627,95 @@ def unmodelable(self) -> bool: cannot be faithfully simulated — the caller should stub the function.""" return self._unmodelable - def transpile(self, yul_code: str) -> str: + # ===================================================================== + # Whole-slot access against the modelled fields + # ===================================================================== + + def _slot_target(self, expr, slot_vars: Dict[str, str]): + """(var name, layout, member types) for a slot expression, or None.""" + if self._slot_resolver is None: + return None + name = None + if isinstance(expr, YulSlotAccess): + name = expr.variable + elif isinstance(expr, YulIdentifier) and expr.name in slot_vars: + name = slot_vars[expr.name] + if name is None: + return None + resolved = self._slot_resolver(name) + if resolved is None: + return None + layout, member_types = resolved + return name, layout, member_types + + @staticmethod + def _member_pack(access: str, tn) -> Optional[str]: + """Widen a member to its slot bits as a bigint expression.""" + t = (tn.name or '').strip() if tn is not None else '' + if not t or (tn is not None and (tn.is_array or tn.is_mapping)): + return None + if t == 'bool': + return f'({access} ? 1n : 0n)' + if t.startswith('uint') or t.startswith('bytes') or t == 'address': + return f'BigInt({access})' + if t.startswith('int'): + bits = t[3:] or '256' + return f'BigInt.asUintN({bits}, BigInt({access}))' + # enums are numbers; every other user-defined name is a contract ref + return f'BigInt({access}?._contractAddress ?? {access} ?? 0n)' + + @staticmethod + def _member_unpack(word: str, tn, bits: int) -> Optional[str]: + """Narrow a slot-bit slice back to a member value.""" + t = (tn.name or '').strip() if tn is not None else '' + mask = f'((1n << {bits}n) - 1n)' + if not t or (tn is not None and (tn.is_array or tn.is_mapping)): + return None + if t == 'bool': + return f'((({word}) & {mask}) !== 0n)' + if t.startswith('uint'): + return f'((({word}) & {mask}))' + if t.startswith('int'): + return f'BigInt.asIntN({t[3:] or "256"}, ({word}))' + if t == 'address' or not (t.startswith('bytes')): + return f'Contract.at((({word}) & {mask}))' + return None + + def _emit_slot_read(self, target) -> Optional[str]: + name, layout, member_types = target + terms = [] + for f in layout.fields: + packed = self._member_pack(f'{name}.{f.name}', member_types.get(f.name)) + if packed is None: + return None + terms.append(packed if f.bit_offset == 0 else f'({packed} << {f.bit_offset}n)') + if layout.has_free_bits: + # Bits no member declares still need somewhere to live; the shadow + # slot store keeps carrying exactly those. + terms.append( + f'(this._storageRead(this._getStorageKey({name} as any)) ' + f'& {hex(layout.free_mask)}n)' + ) + return '(' + ' | '.join(terms) + ')' if terms else '0n' + + def _emit_slot_write(self, target, value: str, prefix: str) -> Optional[str]: + name, layout, member_types = target + tmp = '__slotWord' + lines = [f'{prefix}const {tmp} = BigInt({value});'] + for f in layout.fields: + src = tmp if f.bit_offset == 0 else f'({tmp} >> {f.bit_offset}n)' + val = self._member_unpack(src, member_types.get(f.name), f.bit_width) + if val is None: + return None + lines.append(f'{prefix}{name}.{f.name} = {val};') + if layout.has_free_bits: + lines.append( + f'{prefix}this._storageWrite(this._getStorageKey({name} as any), ' + f'{tmp} & {hex(layout.free_mask)}n);' + ) + return '\n'.join(lines) + + def transpile(self, yul_code: str, slot_resolver=None) -> str: """ Transpile a Yul assembly block to TypeScript. @@ -636,6 +727,7 @@ def transpile(self, yul_code: str) -> str: """ self._warnings = [] self._unmodelable = False + self._slot_resolver = slot_resolver slot_vars: Dict[str, str] = {} try: @@ -897,6 +989,11 @@ def _generate_call_statement( if func == 'sstore' and len(call.arguments) >= 2: slot_expr = call.arguments[0] value = self._generate_expression(call.arguments[1], slot_vars) + target = self._slot_target(slot_expr, slot_vars) + if target is not None: + emitted = self._emit_slot_write(target, value, prefix) + if emitted is not None: + return emitted if isinstance(slot_expr, YulIdentifier) and slot_expr.name in slot_vars: return f'{prefix}this._storageWrite({slot_vars[slot_expr.name]} as any, {value});' slot = self._generate_expression(slot_expr, slot_vars) @@ -997,6 +1094,11 @@ def _generate_function_call( if func == 'sload': if args: slot_expr = args[0] + target = self._slot_target(slot_expr, slot_vars) + if target is not None: + emitted = self._emit_slot_read(target) + if emitted is not None: + return emitted if isinstance(slot_expr, YulIdentifier) and slot_expr.name in slot_vars: return f'this._storageRead({slot_vars[slot_expr.name]} as any)' slot = self._generate_expression(slot_expr, slot_vars) diff --git a/transpiler/codegen_rs/README.md b/transpiler/codegen_rs/README.md index 9ef5c6db..81ef76ff 100644 --- a/transpiler/codegen_rs/README.md +++ b/transpiler/codegen_rs/README.md @@ -119,5 +119,5 @@ substrate. What still runs: bins) that loads the roster from `drool/*.csv` + `src/mons/*.json` at runtime. The Rust side may diverge from TS freely (rng stream and CPU decisions included); port-backs to the game's CPU mode carry no - bit-identicality requirement. The TS arena driver - (`sims/src/arena/game.ts`) remains as the port-back reference. + bit-identicality requirement. Ports land in munch and are gated there + against `sim-tests/arena/cpu-reference.json`. diff --git a/transpiler/codegen_rs/definition.py b/transpiler/codegen_rs/definition.py index bfed6235..199910ef 100644 --- a/transpiler/codegen_rs/definition.py +++ b/transpiler/codegen_rs/definition.py @@ -16,6 +16,7 @@ from typing import List, Optional, TYPE_CHECKING from ..parser.ast_nodes import EnumDefinition, StateVariableDeclaration, StructDefinition +from ..type_system.slots import SLOT0_FREE_FIELD from .rust_types import rust_ident from .soltypes import SolType @@ -94,11 +95,22 @@ def generate_struct(self, struct: StructDefinition) -> str: if not has_mapping: derives.append('PartialEq') + layout = self._symbols.slot_layouts.get(struct.name) + overlay = layout if layout is not None and layout.has_free_bits else None + lines = [] lines.append(f'#[derive({", ".join(derives)})]') lines.append(f'pub struct {name} {{') for fname, ftype in fields: lines.append(f' pub {rust_ident(fname)}: {self._types.rust_type(ftype)},') + if overlay is not None: + lines.append( + ' /// Slot-0 bits no member declares. Yul reaches the raw slot, so this' + ) + lines.append( + ' /// carries what the typed fields cannot represent.' + ) + lines.append(f' pub {SLOT0_FREE_FIELD}: U256,') lines.append('}') lines.append('') lines.append(f'impl Default for {name} {{') @@ -106,6 +118,8 @@ def generate_struct(self, struct: StructDefinition) -> str: lines.append(' Self {') for fname, ftype in fields: lines.append(f' {rust_ident(fname)}: {self._types.default_value(ftype)},') + if overlay is not None: + lines.append(f' {SLOT0_FREE_FIELD}: U256::ZERO,') lines.append(' }') lines.append(' }') lines.append('}') diff --git a/transpiler/codegen_rs/expression.py b/transpiler/codegen_rs/expression.py index c695c180..6cc402db 100644 --- a/transpiler/codegen_rs/expression.py +++ b/transpiler/codegen_rs/expression.py @@ -43,6 +43,7 @@ ADDRESS, BOOL, BYTES, BYTES32, INTLIT, STRING, UINT256, UNKNOWN, SolType, TypeInferencer, common_type, parse_elementary, uint, ) +from ..type_system.slots import SLOT0_FREE_FIELD from .rust_types import RustTypeConverter, rust_ident if TYPE_CHECKING: @@ -753,6 +754,11 @@ def field_value(arg: Expression, ftype: SolType) -> str: else: for (fname, ftype), arg in zip(fields, call.arguments): parts.append(f'{rust_ident(fname)}: {field_value(arg, ftype)}') + # Structs whose raw slot is touched by Yul carry a generated + # undeclared-bit member, which no Solidity initializer names. + layout = self._symbols.slot_layouts.get(name) + if layout is not None and layout.has_free_bits: + parts.append(f'{SLOT0_FREE_FIELD}: U256::ZERO') return f'{rust_ident(name)} {{ {", ".join(parts)} }}', t def _value_of(self, arg: Expression, ptype: SolType) -> str: @@ -870,6 +876,13 @@ def _emit_direct_call(self, fn_path: str, sig, arguments: List[Expression]) -> s arg_names.append('unimplemented!("unlowerable storage param")') continue if low[0] == '!selector': + # An argument that IS a selector already gets forwarded; + # re-wrapping it would move the selector into the new + # closure and poison every later use in this function. + fwd = self._forwarded_selector(arg) + if fwd is not None: + arg_names.append(fwd) + continue place = self._selector_place_of(arg, lines) tmp = self._ctx.fresh_temp('a') lines.append( @@ -927,6 +940,27 @@ def _emit_call_with_shared_base(self, fn_path: str, sig, arguments, base_name: s self._ctx.storage_locals = saved return f'{{ let {tmp} = &mut {base_place}; {fn_path}({args}) }}' + def _forwarded_selector(self, arg: Expression) -> Optional[str]: + """Pass an existing selector straight through, or None to build one. + + A selector-lowered parameter is already a `&dyn Fn`; a deferred local + is a `Box` that only needs reborrowing. + """ + if not isinstance(arg, Identifier): + return None + info = self._ctx.storage_locals.get(arg.name) + if info is None: + return None + root = info.get('root') + if not root: + return None + name = rust_ident(arg.name) + if root[0] == '!selector': + return f'{name}_sel' + if root[0] == '!deferred_sel': + return f'&*{name}_sel' + return None + def _selector_place_of(self, arg: Expression, hoists: List[str]) -> str: """Place text for a selector-lowered storage argument, valid inside a `move |world: &mut World|` closure: the closure param shadows `world` diff --git a/transpiler/codegen_rs/statement.py b/transpiler/codegen_rs/statement.py index dc8a19a6..5a470f5d 100644 --- a/transpiler/codegen_rs/statement.py +++ b/transpiler/codegen_rs/statement.py @@ -42,6 +42,7 @@ VariableDeclarationStatement, WhileStatement, ) +from ..type_system.slots import SLOT0_FREE_FIELD from .soltypes import BOOL, SolType, UNKNOWN, UINT256 from .rust_types import rust_ident @@ -742,6 +743,103 @@ def _gen_revert(self, stmt: RevertStatement) -> str: # Inline assembly: known-block registry (see _KNOWN_YUL_NOTE) # ------------------------------------------------------------------ + # ------------------------------------------------------------------ + # Whole-slot access (`sload(x.slot)` / `sstore(x.slot, v)`) + # ------------------------------------------------------------------ + + @staticmethod + def _u256_literal(value: int) -> str: + limbs = [(value >> (64 * i)) & 0xFFFFFFFFFFFFFFFF for i in range(4)] + return f'U256::from_limbs([{", ".join(f"{l}u64" for l in limbs)}])' + + def _slot_place(self, ident: str): + """(place code, slot-0 layout, member SolTypes) for a `.slot`.""" + place, t = self._expr.emit_typed(Identifier(name=ident)) + if t is None or t.kind != 'struct' or not t.name: + return None + layout = self._symbols.slot_layouts.get(t.name) + if layout is None: + return None + return place, layout, dict(self._symbols.structs.get(t.name, [])) + + def _member_to_word(self, code: str, t: SolType) -> Optional[str]: + """Widen a member to its slot bits. None when the type isn't modeled.""" + if t is None: + return None + if t.kind in ('address', 'interface', 'contract'): + return f'rt::address_to_u256({code})' + if t.kind == 'bool': + return f'U256::from({code} as u8)' + if t.kind == 'enum': + return f'U256::from({code} as u8)' + if t.kind == 'uint': + return f'U256::from({code})' + if t.kind == 'bytes_fixed' and t.bytes_n == 32: + return f'rt::b256_to_u256({code})' + return None + + def _member_from_word(self, src: str, t: SolType, bits: int) -> Optional[str]: + """Narrow a slot-bit slice back to a member. None when unmodeled.""" + if t is None: + return None + if t.kind in ('address', 'interface', 'contract'): + return f'rt::address_from_u256({src})' + if t.kind == 'bool': + return f'rt::mask_bits({src}, {bits}) != U256::ZERO' + if t.kind == 'enum': + from .definition import enum_from_u8_fn + return ( + f'{rust_ident(t.name)}::{enum_from_u8_fn()}' + f'(rt::mask_bits({src}, {bits}).wrapping_to::())' + ) + if t.kind == 'uint': + return f'rt::mask_bits({src}, {bits}).wrapping_to::<{self._types.rust_type(t)}>()' + if t.kind == 'bytes_fixed' and t.bytes_n == 32: + return f'rt::u256_to_b256({src})' + return None + + def _slot_read(self, lhs_ident: str, place_ident: str) -> Optional[str]: + info = self._slot_place(place_ident) + if info is None: + return None + place, layout, ftypes = info + p = self._ctx.fresh_temp('sl') + terms = [] + for f in layout.fields: + conv = self._member_to_word(f'{p}.{rust_ident(f.name)}', ftypes.get(f.name)) + if conv is None: + return None + terms.append(conv if f.bit_offset == 0 else f'({conv} << {f.bit_offset}usize)') + if layout.has_free_bits: + terms.append(f'{p}.{SLOT0_FREE_FIELD}') + lhs, _ = self._expr.emit_typed(Identifier(name=lhs_ident)) + packed = ' | '.join(terms) if terms else 'U256::ZERO' + return f'{self.ind()}{lhs} = {{ let {p} = &{place}; {packed} }};' + + def _slot_write(self, place_ident: str, value_ident: str) -> Optional[str]: + info = self._slot_place(place_ident) + if info is None: + return None + place, layout, ftypes = info + value, _ = self._expr.emit_typed(Identifier(name=value_ident)) + v = self._ctx.fresh_temp('sv') + p = self._ctx.fresh_temp('sp') + assigns = [] + for f in layout.fields: + src = v if f.bit_offset == 0 else f'({v} >> {f.bit_offset}usize)' + conv = self._member_from_word(src, ftypes.get(f.name), f.bit_width) + if conv is None: + return None + assigns.append(f'{p}.{rust_ident(f.name)} = {conv};') + if layout.has_free_bits: + assigns.append( + f'{p}.{SLOT0_FREE_FIELD} = {v} & {self._u256_literal(layout.free_mask)};' + ) + return ( + f'{self.ind()}{{ let {v} = {value}; let {p} = &mut {place}; ' + f'{" ".join(assigns)} }}' + ) + def _gen_assembly(self, stmt: AssemblyStatement) -> str: import re # The lexer re-joins Yul with spaces around every token @@ -763,6 +861,21 @@ def _gen_assembly(self, stmt: AssemblyStatement) -> str: f'{{ *{p} = crate::world::cleared_mon_state(); }} }}' ) + # Shape 4: whole-slot read/write on a struct place. The model stores + # each member separately, so the slot word is packed from (or split + # back into) those members plus the generated undeclared-bit overlay. + flat = re.sub(r'\s+', '', code) + m = re.fullmatch(r'(\w+):=sload\((\w+)\.slot\)', flat) + if m: + emitted = self._slot_read(m.group(1), m.group(2)) + if emitted is not None: + return emitted + m = re.fullmatch(r'sstore\((\w+)\.slot,(\w+)\)', flat) + if m: + emitted = self._slot_write(m.group(1), m.group(2)) + if emitted is not None: + return emitted + # Shape 3: batch event-payload packer (_packBatchPayload). if 'calldataload' in code and 'shl(104' in code: return ( diff --git a/transpiler/codegen_rs/symbols.py b/transpiler/codegen_rs/symbols.py index e3222462..ab4ad799 100644 --- a/transpiler/codegen_rs/symbols.py +++ b/transpiler/codegen_rs/symbols.py @@ -82,6 +82,11 @@ def __init__(self): self.structs: Dict[str, List[Tuple[str, SolType]]] = {} # struct name -> ordered raw TypeName list (for emission-time detail) self.struct_type_names: Dict[str, list] = {} + # struct name -> raw AST definition (slot-layout computation) + self.struct_defs: Dict[str, object] = {} + # struct name -> slot-0 SlotLayout, for structs whose raw slot is read + # or written by Yul. + self.slot_layouts: Dict[str, object] = {} # Keyed by (container, name): the same constant name can exist at file # scope AND inside contracts with different types (live example: # Constants.sol's uint256 SWITCH_PRIORITY vs FairCPU's uint32 one) — @@ -211,6 +216,7 @@ def build(cls, asts: Dict[str, SourceUnit]) -> 'RustSymbols': if existing is None or len(sig.param_types) > len(existing.param_types): sym.functions[key] = sig sym.function_defs[key] = func + sym._discover_slot_overlays(asts) return sym def lookup_overload(self, container: Optional[str], name: str, nargs: int): @@ -378,12 +384,58 @@ def visit(node): found[0] = True elif cls == 'FunctionCall': fn = node.function - if type(fn).__name__ == 'Identifier': + fcls = type(fn).__name__ + if fcls == 'Identifier': if fn.name in getattr(self, 'noop_calls', set()): return sig = self.lookup_function(leaf, fn.name) or self.functions.get((None, fn.name)) if sig is not None and sig.needs_world: found[0] = True + elif fcls == 'MemberAccess': + # Mirrors compute_needs_world's member-call classification, but + # consults final needs_world flags directly (this scan runs after + # the phase-2 fixed point) instead of building call edges. + base = fn.expression + bcls = type(base).__name__ + if bcls == 'Identifier' and base.name == 'super': + for b in self.linearize_bases(leaf): + sig = self.functions.get((b, fn.member)) + if sig is not None and sig.needs_world: + found[0] = True + break + elif bcls == 'Identifier' and ( + base.name in self.libraries or base.name in self.contracts + ): + sig = self.functions.get((base.name, fn.member)) + if sig is not None and sig.needs_world: + found[0] = True + else: + # Interface-value method call: external/dispatch interfaces + # always route via world; aliased dispatch inherits the + # impl's neediness (a public-state-var getter through an + # alias is a world field read). + for iface in (self.external_interfaces + | getattr(self, 'dispatch_interfaces', set())): + if (iface, fn.member) in self.functions: + found[0] = True + break + else: + for iface, alias in self.interface_aliases.items(): + if (iface, fn.member) in self.functions: + if fn.member in self.state_vars.get(alias, {}) \ + and (alias, fn.member) not in self.functions: + found[0] = True + else: + sig = self.functions.get((alias, fn.member)) + if sig is not None and sig.needs_world: + found[0] = True + break + else: + for cname in self.contracts: + sig = self.functions.get((cname, fn.member)) + if sig is not None and sig.needs_world: + found[0] = True + break def walk(node): if node is None or found[0]: @@ -613,13 +665,13 @@ def _compute_param_lowering(self) -> None: if sig.needs_world and getattr(p, 'storage_location', '') == 'storage': if pt.kind == 'struct' and pt.name in roots: entry = roots[pt.name] - elif pt.kind == 'mapping': - # No single key addresses a nested mapping; lower - # to a SELECTOR closure that re-derives the place - # from world per use (funnel: world::sel). - entry = ('!selector', pt, None) else: - entry = ('!unsupported', pt.name if pt.name else pt.kind, UNKNOWN) + # No single key addresses this place — a nested + # mapping, or a struct reached through one (an + # array inside a mapped struct). Lower to a + # SELECTOR closure that re-derives the place from + # world per use (funnel: world::sel). + entry = ('!selector', pt, None) lowered.append(entry) while len(lowered) < len(sig.param_types): lowered.append(None) @@ -648,6 +700,18 @@ def _record_struct(self, struct, resolver) -> None: tns.append((member.name, member.type_name)) self.structs[struct.name] = fields self.struct_type_names[struct.name] = tns + self.struct_defs[struct.name] = struct + + def _discover_slot_overlays(self, asts) -> None: + """Record slot-0 layouts for structs whose raw slot is touched by Yul.""" + from ..type_system.slots import discover_slot_layouts + + self.slot_layouts = discover_slot_layouts( + asts.values(), + enum_names=set(self.enums), + struct_names=set(self.structs), + struct_defs=self.struct_defs, + ) def _record_constant(self, var, resolver, container) -> None: self.constants[(container, var.name)] = ConstSig( diff --git a/transpiler/sol2rs.py b/transpiler/sol2rs.py index 48123d2e..49d3483d 100644 --- a/transpiler/sol2rs.py +++ b/transpiler/sol2rs.py @@ -389,6 +389,9 @@ def _write_world_rs(self, engine_src: Path, symbols) -> None: lines.append(f' {f}: crate::Constants::CLEARED_MON_STATE_SENTINEL,') lines.append(' isKnockedOut: false,') lines.append(' shouldSkipTurn: false,') + # Structs whose raw slot is touched by Yul carry a generated + # undeclared-bit member; the sentinel leaves it zeroed. + lines.append(' ..Default::default()') lines.append(' }') lines.append('}') lines.append('') diff --git a/transpiler/strategies-rs/src/analysis.rs b/transpiler/strategies-rs/src/analysis.rs index 961d6df1..3811b286 100644 --- a/transpiler/strategies-rs/src/analysis.rs +++ b/transpiler/strategies-rs/src/analysis.rs @@ -9,8 +9,9 @@ use std::collections::HashMap; -use crate::arena::{build_specs, build_specs_with}; -use crate::game::{run_games_instrumented, InstrRecord, StrategyKind}; +use crate::arena::{build_doubles_specs, build_specs, build_specs_full, build_specs_with, STRAT_PAIRS}; +use crate::doubles::run_doubles_games_instrumented; +use crate::game::{run_games_instrumented, InstrRecord, BotName}; use crate::roster::{self, Roster}; /// Per-mon accumulators, split by the mon's own team result that game. @@ -55,6 +56,10 @@ pub struct MonAnalysis { pub decided: u32, pub draws: u32, pub errors: u32, + /// Doubles only: KOs both opposing slots aimed at, so no single attacker earns matrix credit. + pub ko_shared: u32, + /// Doubles only: KOs no opposing move aimed at (status, recoil, ally damage). + pub ko_incidental: u32, } /// Draw the same 4v4 field as the plain arena, run it instrumented, fold per-mon. @@ -72,7 +77,7 @@ pub fn run_mon_analysis_with( wseed: u32, seed_base: u32, threads: usize, - pairs: &[(StrategyKind, StrategyKind)], + pairs: &[(BotName, BotName)], ) -> MonAnalysis { let book = roster::address_book(); let (specs, _pair_of) = build_specs_with(roster, games, wseed, seed_base, pairs); @@ -80,6 +85,102 @@ pub fn run_mon_analysis_with( fold(&records) } +/// Singles analysis over the standard pilot basket with loadout rotation — each drafted slot equips +/// a uniform 4-subset of that mon's catalog instead of always lanes 0-3. +pub fn run_mon_analysis_rotated( + roster: &Roster, + games: usize, + wseed: u32, + seed_base: u32, + threads: usize, + pairs: Option<&[(BotName, BotName)]>, +) -> MonAnalysis { + let book = roster::address_book(); + let default_pairs: Vec<(BotName, BotName)> = STRAT_PAIRS + .iter() + .map(|&(p1, p0)| (p1, p0)) + .collect(); + let pairs = pairs.unwrap_or(&default_pairs); + let (specs, _) = build_specs_full(roster, games, wseed, seed_base, pairs, true); + let records = run_games_instrumented(&specs, &book, threads); + fold(&records) +} + +/// Doubles per-mon attribution over the DIFF_PAIRS field. KOs are credited from the killer side's +/// target nibble; `shared` (both slots aimed at the victim) and `incidental` (nothing aimed at it) +/// are counted but left out of the matrix rather than guessed at. +pub fn run_doubles_mon_analysis( + roster: &Roster, + games: usize, + wseed: u32, + seed_base: u32, + threads: usize, + rotate: bool, + search_depth: u32, +) -> MonAnalysis { + let book = roster::address_book(); + let (specs, _) = build_doubles_specs(roster, games, wseed, seed_base, rotate, search_depth); + let records = run_doubles_games_instrumented(&specs, &book, threads); + + let mut per_mon: HashMap = HashMap::new(); + let mut ko_matrix: HashMap<(u32, u32), u32> = HashMap::new(); + let (mut n_games, mut decided, mut draws) = (0u32, 0u32, 0u32); + let (mut shared, mut incidental) = (0u32, 0u32); + + for rec in &records { + n_games += 1; + let (p0_res, p1_res): (i8, i8) = match rec.winner_side { + Some(0) => { decided += 1; (1, -1) } + Some(1) => { decided += 1; (-1, 1) } + _ => { draws += 1; (0, 0) } + }; + shared += rec.kos_shared; + incidental += rec.kos_incidental; + + for (slot, &id) in rec.p0_ids.iter().enumerate() { + let st = per_mon.entry(id).or_default(); + tally_game(st, p0_res); + add_active(st, p0_res, rec.active_turns_p0.get(slot).copied().unwrap_or(0) as u64); + } + for (slot, &id) in rec.p1_ids.iter().enumerate() { + let st = per_mon.entry(id).or_default(); + tally_game(st, p1_res); + add_active(st, p1_res, rec.active_turns_p1.get(slot).copied().unwrap_or(0) as u64); + } + + for ko in &rec.kos { + *ko_matrix.entry((ko.killer_id, ko.victim_id)).or_insert(0) += 1; + let killer_res = if ko.killer_seat == 0 { p0_res } else { p1_res }; + { + let ks = per_mon.entry(ko.killer_id).or_default(); + match killer_res { + 1 => ks.kos_dealt_won += 1, + -1 => ks.kos_dealt_lost += 1, + _ => {} + } + } + { + let vs = per_mon.entry(ko.victim_id).or_default(); + match -killer_res { + 1 => vs.kos_taken_won += 1, + -1 => vs.kos_taken_lost += 1, + _ => {} + } + } + } + } + MonAnalysis { + per_mon, + ko_matrix, + games: n_games, + decided, + draws, + errors: 0, + ko_shared: shared, + ko_incidental: incidental, + } +} + pub fn fold(records: &[Result]) -> MonAnalysis { let mut per_mon: HashMap = HashMap::new(); let mut ko_matrix: HashMap<(u32, u32), u32> = HashMap::new(); @@ -137,7 +238,7 @@ pub fn fold(records: &[Result]) -> MonAnalysis { } } - MonAnalysis { per_mon, ko_matrix, games: n_games, decided, draws, errors } + MonAnalysis { per_mon, ko_matrix, games: n_games, decided, draws, errors, ko_shared: 0, ko_incidental: 0 } } fn tally_game(st: &mut MonStat, res: i8) { diff --git a/transpiler/strategies-rs/src/arena.rs b/transpiler/strategies-rs/src/arena.rs index c7c5771f..f0230177 100644 --- a/transpiler/strategies-rs/src/arena.rs +++ b/transpiler/strategies-rs/src/arena.rs @@ -3,9 +3,10 @@ //! aggregates win rates. No bun, no chomp_run_games. use chomp_engine::Structs::Mon; -use crate::doubles::{run_doubles_games, Difficulty, DoublesSpec}; +use crate::doubles::{run_doubles_games, Difficulty, DoublesEvalW, DoublesSpec}; +use crate::bot::InfoMode; use crate::evaluator::{Weights, DEFAULT_WEIGHTS}; -use crate::game::{run_games, GameSpec, StrategyKind}; +use crate::game::{run_games, GameSpec, BotName}; use crate::roster::{self, Roster, RosterMon}; const TEAM_SIZE: usize = 4; @@ -54,6 +55,26 @@ pub fn build_team_mon(m: &RosterMon) -> Mon { Mon { stats: m.stats.clone(), ability: m.ability, moves } } +/// Stamp the information mode across a batch of specs. +/// +/// Rotation counts occurrences of each matchup rather than raw spec index: +/// the pair cycle and a plain `i % 2` share a factor, which would pin every +/// matchup to one peek seat forever instead of alternating. +pub fn apply_info_mode(specs: &mut [GameSpec], pair_of: &[usize], mode: InfoMode) { + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for (i, spec) in specs.iter_mut().enumerate() { + spec.peek_seat = match mode { + InfoMode::Blind => None, + InfoMode::RotatePeek => { + let pair = pair_of.get(i).copied().unwrap_or(0); + let n = seen.entry(pair).or_insert(0); + *n += 1; + Some(((*n - 1) % 2) as u8) + } + }; + } +} + #[derive(Clone)] pub struct PairStats { pub p1_strat: &'static str, @@ -74,13 +95,35 @@ impl PairStats { /// Build every game's spec (STRAT_PAIRS rotation, two random team draws each), returning the specs /// parallel to their pair index. Exposed so trace tooling can reconstruct the exact same games. pub fn build_specs(roster: &Roster, games: usize, wseed: u32, seed_base: u32) -> (Vec, Vec) { - let pairs: Vec<(StrategyKind, StrategyKind)> = STRAT_PAIRS + let pairs: Vec<(BotName, BotName)> = STRAT_PAIRS .iter() - .map(|&(p1, p0)| (StrategyKind::parse(p1).unwrap(), StrategyKind::parse(p0).unwrap())) + .map(|&(p1, p0)| (p1, p0)) .collect(); build_specs_with(roster, games, wseed, seed_base, &pairs) } +/// Every legal 4-move loadout for a mon: each 4-subset of its catalog, in lane order. Mons with a +/// catalog of 4 or fewer yield exactly one loadout (identical to [`build_team_mon`]), so rotation is +/// a no-op for them. +pub fn loadouts(m: &RosterMon) -> Vec { + if m.catalog.len() <= 4 { + return vec![build_team_mon(m)]; + } + crate::teams::combos(m.catalog.len(), 4) + .into_iter() + .map(|pick| Mon { + stats: m.stats.clone(), + ability: m.ability, + moves: pick.iter().map(|&i| m.catalog[i].word).collect(), + }) + .collect() +} + +/// Per-mon loadout tables for a rotated draft. Indexed by mon id. +fn loadout_table(roster: &Roster) -> std::collections::HashMap> { + roster.mons.iter().map(|m| (m.id, loadouts(m))).collect() +} + /// Like `build_specs` but with an explicit [p1, p0] strategy rotation (e.g. a single no-peek-vs-peek /// pair). Team draws stay identical per seed regardless of the pilots — matched drafts. pub fn build_specs_with( @@ -88,12 +131,39 @@ pub fn build_specs_with( games: usize, wseed: u32, seed_base: u32, - pairs: &[(StrategyKind, StrategyKind)], + pairs: &[(BotName, BotName)], +) -> (Vec, Vec) { + build_specs_full(roster, games, wseed, seed_base, pairs, false) +} + +/// `build_specs_with` plus a `rotate` switch: when set, each drafted slot draws a uniform loadout +/// from that mon's full 4-subset set instead of always equipping catalog lanes 0-3. The loadout draw +/// runs on its own rng stream so team drafts stay bit-identical to an unrotated run at the same seed. +/// +/// Callers that map an equipped lane index back to a move name (trace narration via +/// `Roster::move_name`) must leave this off — with rotation the lane→catalog mapping is per-game. +pub fn build_specs_full( + roster: &Roster, + games: usize, + wseed: u32, + seed_base: u32, + pairs: &[(BotName, BotName)], + rotate: bool, ) -> (Vec, Vec) { - // Build each mon's default loadout once (13 mons), then clone per draft — avoids an O(n) find + // Build each mon's loadout set once (13 mons), then clone per draft — avoids an O(n) find // and a fresh build_team_mon on every drafted slot across tens of thousands of games. - let built: std::collections::HashMap = roster.mons.iter().map(|m| (m.id, build_team_mon(m))).collect(); + let table = loadout_table(roster); let mut rng = Wrand::new(wseed); + let mut lrng = Wrand::new(wseed ^ 0x9e37_79b9); + let equip = |ids: &[u32], lrng: &mut Wrand| -> Vec { + ids.iter() + .map(|id| { + let opts = &table[id]; + let k = if rotate && opts.len() > 1 { (lrng.next() * opts.len() as f64) as usize } else { 0 }; + opts[k.min(opts.len() - 1)].clone() + }) + .collect() + }; let mut specs = Vec::with_capacity(games); let mut pair_of = Vec::with_capacity(games); for i in 0..games { @@ -102,16 +172,19 @@ pub fn build_specs_with( // workload draw order: teams[0] (→ p0) first, teams[1] (→ p1) second. let p0_ids = draw_team(roster, &mut rng); let p1_ids = draw_team(roster, &mut rng); + let p0_team = equip(&p0_ids, &mut lrng); + let p1_team = equip(&p1_ids, &mut lrng); specs.push(GameSpec { seed: seed_base.wrapping_add(i as u32), max_turns: MAX_TURNS, mons_per_team: TEAM_SIZE as u64, - p0_team: p0_ids.iter().map(|&id| built[&id].clone()).collect(), - p1_team: p1_ids.iter().map(|&id| built[&id].clone()).collect(), + p0_team, + p1_team, p0_ids, p1_ids, p0_strategy: p0s, p1_strategy: p1s, + peek_seat: None, p0_weights: DEFAULT_WEIGHTS, p1_weights: DEFAULT_WEIGHTS, p0_search_depth: 0, @@ -127,9 +200,17 @@ pub fn build_specs_with( } /// Play `games` matchups (STRAT_PAIRS rotation, two random team draws each) and tally per pair. -pub fn run_arena(roster: &Roster, games: usize, wseed: u32, seed_base: u32, threads: usize) -> Vec { +pub fn run_arena( + roster: &Roster, + games: usize, + wseed: u32, + seed_base: u32, + threads: usize, + info: InfoMode, +) -> Vec { let book = roster::address_book(); - let (specs, pair_of) = build_specs(roster, games, wseed, seed_base); + let (mut specs, pair_of) = build_specs(roster, games, wseed, seed_base); + apply_info_mode(&mut specs, &pair_of, info); let outcomes = run_games(&specs, &book, threads, false); let mut stats: Vec = STRAT_PAIRS @@ -162,8 +243,8 @@ pub fn eval_weights_winrate( cand: &Weights, p1_search_depth: u32, p1_search_peek: bool, - p1_strat: StrategyKind, - p0_strat: StrategyKind, + p1_strat: BotName, + p0_strat: BotName, games: usize, wseed: u32, seed_base: u32, @@ -230,25 +311,20 @@ impl DiffPairStats { /// pilot (side 0) over `games` matched drafts. The Phase-3 substrate exit check. pub fn doubles_search_winrate(roster: &Roster, depth: u32, games: usize, wseed: u32, seed_base: u32, threads: usize) -> f64 { let book = roster::address_book(); - let mon = |id: u32| roster.mons.iter().find(|m| m.id == id).expect("drawn mon id in roster"); let mut rng = Wrand::new(wseed); let mut specs = Vec::with_capacity(games); for i in 0..games { - let p0_ids = draw_team(roster, &mut rng); - let p1_ids = draw_team(roster, &mut rng); - specs.push(DoublesSpec { - seed: seed_base.wrapping_add(i as u32), - max_turns: MAX_TURNS, - mons_per_team: TEAM_SIZE as u64, - p0_team: p0_ids.iter().map(|&id| build_team_mon(mon(id))).collect(), - p1_team: p1_ids.iter().map(|&id| build_team_mon(mon(id))).collect(), + let (p0_team, p0_ids) = draw_built_team(roster, &mut rng); + let (p1_team, p1_ids) = draw_built_team(roster, &mut rng); + specs.push(doubles_spec( + seed_base.wrapping_add(i as u32), + p0_team, p0_ids, + DoublesSideCfg::default(), // side 0 = epsilon-greedy Hard baseline + p1_team, p1_ids, - p0_difficulty: Difficulty::Hard, // side 0 = epsilon-greedy Hard baseline - p1_difficulty: Difficulty::Hard, // unused (side 1 searches) - p0_search_depth: 0, - p1_search_depth: depth, - }); + DoublesSideCfg { depth, ..Default::default() }, + )); } let outcomes = run_doubles_games(&specs, &book, threads); let (mut p1, mut decisive) = (0u32, 0u32); @@ -265,11 +341,28 @@ pub fn doubles_search_winrate(roster: &Roster, depth: u32, games: usize, wseed: if decisive == 0 { 0.0 } else { p1 as f64 / decisive as f64 } } -pub fn run_doubles_arena(roster: &Roster, games: usize, wseed: u32, seed_base: u32, threads: usize) -> Vec { - let book = roster::address_book(); - let mon = |id: u32| roster.mons.iter().find(|m| m.id == id).expect("drawn mon id in roster"); - +/// Build the doubles field (DIFF_PAIRS rotation, two random team draws each). `rotate` draws each +/// slot's loadout from that mon's full 4-subset set; see [`build_specs_full`]. +pub fn build_doubles_specs( + roster: &Roster, + games: usize, + wseed: u32, + seed_base: u32, + rotate: bool, + search_depth: u32, +) -> (Vec, Vec) { + let table = loadout_table(roster); let mut rng = Wrand::new(wseed); + let mut lrng = Wrand::new(wseed ^ 0x9e37_79b9); + let equip = |ids: &[u32], lrng: &mut Wrand| -> Vec { + ids.iter() + .map(|id| { + let opts = &table[id]; + let k = if rotate && opts.len() > 1 { (lrng.next() * opts.len() as f64) as usize } else { 0 }; + opts[k.min(opts.len() - 1)].clone() + }) + .collect() + }; let mut specs = Vec::with_capacity(games); let mut pair_of = Vec::with_capacity(games); for i in 0..games { @@ -277,22 +370,25 @@ pub fn run_doubles_arena(roster: &Roster, games: usize, wseed: u32, seed_base: u let (p1d, p0d) = DIFF_PAIRS[pi]; let p0_ids = draw_team(roster, &mut rng); let p1_ids = draw_team(roster, &mut rng); - specs.push(DoublesSpec { - seed: seed_base.wrapping_add(i as u32), - max_turns: MAX_TURNS, - mons_per_team: TEAM_SIZE as u64, - p0_team: p0_ids.iter().map(|&id| build_team_mon(mon(id))).collect(), - p1_team: p1_ids.iter().map(|&id| build_team_mon(mon(id))).collect(), + let p0_team = equip(&p0_ids, &mut lrng); + let p1_team = equip(&p1_ids, &mut lrng); + specs.push(doubles_spec( + seed_base.wrapping_add(i as u32), + p0_team, p0_ids, + DoublesSideCfg { difficulty: p0d, depth: search_depth, ..Default::default() }, + p1_team, p1_ids, - p0_difficulty: p0d, - p1_difficulty: p1d, - p0_search_depth: 0, - p1_search_depth: 0, - }); + DoublesSideCfg { difficulty: p1d, depth: search_depth, ..Default::default() }, + )); pair_of.push(pi); } + (specs, pair_of) +} +pub fn run_doubles_arena(roster: &Roster, games: usize, wseed: u32, seed_base: u32, threads: usize) -> Vec { + let book = roster::address_book(); + let (specs, pair_of) = build_doubles_specs(roster, games, wseed, seed_base, false, 0); let outcomes = run_doubles_games(&specs, &book, threads); let mut stats: Vec = DIFF_PAIRS @@ -310,3 +406,325 @@ pub fn run_doubles_arena(roster: &Roster, games: usize, wseed: u32, seed_base: u } stats } + +/// One side's doubles driver config: pilot difficulty (used at depth 0), search depth, eval weights. +#[derive(Clone, Copy)] +pub struct DoublesSideCfg { + /// Registered doubles bot; empty falls back to `difficulty`/`depth`. + pub bot: &'static str, + pub difficulty: Difficulty, + pub depth: u32, + pub eval: DoublesEvalW, +} +impl Default for DoublesSideCfg { + fn default() -> Self { + Self { bot: "", difficulty: Difficulty::Hard, depth: 0, eval: DoublesEvalW::default() } + } +} + +/// Draw a team and materialize its default-loadout mons. +fn draw_built_team(roster: &Roster, rng: &mut Wrand) -> (Vec, Vec) { + let ids = draw_team(roster, rng); + let team = ids.iter().map(|&id| build_team_mon(roster.mon_by_id(id).expect("drawn mon id in roster"))).collect(); + (team, ids) +} + +/// Fill a DoublesSpec from per-side configs — the one place the spec's field mapping lives. +#[allow(clippy::too_many_arguments)] +fn doubles_spec( + seed: u32, + p0_team: Vec, + p0_ids: Vec, + p0: DoublesSideCfg, + p1_team: Vec, + p1_ids: Vec, + p1: DoublesSideCfg, +) -> DoublesSpec { + DoublesSpec { + seed, + max_turns: MAX_TURNS, + mons_per_team: TEAM_SIZE as u64, + p0_team, + p1_team, + p0_ids, + p1_ids, + p0_difficulty: p0.difficulty, + p1_difficulty: p1.difficulty, + p0_bot: p0.bot, + p1_bot: p1.bot, + p0_search_depth: p0.depth, + p1_search_depth: p1.depth, + p0_eval: p0.eval, + p1_eval: p1.eval, + } +} + +pub struct DoublesAbResult { + /// Candidate's win share of decisive games. + pub share: f64, + pub cand_wins: u32, + pub base_wins: u32, + pub draws: u32, +} + +/// A/B two search configs: every drawn team pair plays twice with the same teams and seats but the +/// CONFIGS exchanged, so team-matchup luck and any engine side bias cancel per pair. Returns the +/// candidate (`cand`) side's record vs the baseline (`base`). +pub fn doubles_ab_winrate( + roster: &Roster, + cand: DoublesSideCfg, + base: DoublesSideCfg, + games: usize, + wseed: u32, + seed_base: u32, + threads: usize, +) -> DoublesAbResult { + let book = roster::address_book(); + let mut rng = Wrand::new(wseed); + // Weight A/Bs compare two SEARCH configs, so depth 0 is bumped to 1 — otherwise the + // comparison silently falls back to the un-evaluated greedy pilot. A named bot opts out: + // its name selects the pilot and this would misreport what actually played. + let bump = |c: DoublesSideCfg| { + if c.bot.is_empty() { DoublesSideCfg { depth: c.depth.max(1), ..c } } else { c } + }; + let (cand, base) = (bump(cand), bump(base)); + let pairs = (games / 2).max(1); + let mut specs = Vec::with_capacity(pairs * 2); + for i in 0..pairs { + let (p0_team, p0_ids) = draw_built_team(roster, &mut rng); + let (p1_team, p1_ids) = draw_built_team(roster, &mut rng); + // Game A: candidate in the p1 seat; game B: same everything, configs swapped. + for &(c1, c0) in &[(cand, base), (base, cand)] { + specs.push(doubles_spec( + seed_base.wrapping_add(i as u32), + p0_team.clone(), + p0_ids.clone(), + c0, + p1_team.clone(), + p1_ids.clone(), + c1, + )); + } + } + let outcomes = run_doubles_games(&specs, &book, threads); + let (mut cand_wins, mut base_wins, mut draws) = (0u32, 0u32, 0u32); + for (i, o) in outcomes.iter().enumerate() { + let cand_seat = if i % 2 == 0 { 1 } else { 0 }; + match o.winner_side { + Some(s) if s == cand_seat => cand_wins += 1, + Some(_) => base_wins += 1, + None => draws += 1, + } + } + let decisive = cand_wins + base_wins; + DoublesAbResult { + share: if decisive == 0 { 0.0 } else { cand_wins as f64 / decisive as f64 }, + cand_wins, + base_wins, + draws, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::roster::load_roster; + use std::collections::HashSet; + + fn chomp_root() -> std::path::PathBuf { + std::env::var("CHOMP_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("..") + }) + } + + /// Each mon's loadout set is exactly C(catalog, 4), and every catalog move reaches some loadout — + /// the level-6 moves that the default lanes-0-3 loadout can never equip. + #[test] + fn loadouts_cover_every_catalog_move() { + let roster = load_roster(&chomp_root()); + for m in &roster.mons { + let ls = loadouts(m); + let expected = if m.catalog.len() <= 4 { 1 } else { combos_len(m.catalog.len(), 4) }; + assert_eq!(ls.len(), expected, "{}: expected {expected} loadouts, got {}", m.name, ls.len()); + for l in &ls { + assert_eq!(l.moves.len(), 4, "{}: loadout must equip 4 lanes", m.name); + } + let seen: HashSet<_> = ls.iter().flat_map(|l| l.moves.iter().copied()).collect(); + for c in &m.catalog { + assert!(seen.contains(&c.word), "{}: catalog move {} unreachable", m.name, c.name); + } + } + } + + fn combos_len(n: usize, k: usize) -> usize { + (0..k).fold(1usize, |acc, i| acc * (n - i) / (i + 1)) + } + + /// Rotation leaves the team draft untouched (matched drafts) but does vary the equipped moves. + #[test] + fn rotation_preserves_drafts_and_varies_loadouts() { + let roster = load_roster(&chomp_root()); + let pairs = [(crate::bots::GREEDY, crate::bots::GREEDY)]; + let (plain, _) = build_specs_full(&roster, 300, 0xbeefcafe, 10_000, &pairs, false); + let (rot, _) = build_specs_full(&roster, 300, 0xbeefcafe, 10_000, &pairs, true); + + for (a, b) in plain.iter().zip(rot.iter()) { + assert_eq!(a.p0_ids, b.p0_ids, "rotation must not disturb the p0 draft"); + assert_eq!(a.p1_ids, b.p1_ids, "rotation must not disturb the p1 draft"); + } + // The plain field equips one fixed loadout per mon; the rotated field must show more. + let variants = |specs: &[GameSpec]| -> usize { + let mut s: HashSet<(u32, Vec)> = HashSet::new(); + for sp in specs { + for (ids, team) in [(&sp.p0_ids, &sp.p0_team), (&sp.p1_ids, &sp.p1_team)] { + for (id, mon) in ids.iter().zip(team.iter()) { + s.insert((*id, mon.moves.clone())); + } + } + } + s.len() + }; + assert_eq!(variants(&plain), roster.mons.len(), "unrotated field should equip one loadout per mon"); + assert!(variants(&rot) > variants(&plain), "rotated field should equip more than one loadout per mon"); + } +} + + +// --------------------------------------------------------------------------- +// Bench: score one bot against a ladder of opponents +// --------------------------------------------------------------------------- + +/// One bot's record against one opponent, aggregated over BOTH seat orders. +/// +/// Seats are not perfectly symmetric — mirror matchups sit a point or two off +/// 50% — so every row plays each draft from both sides and sums. That cancels +/// the seat term instead of leaving it in the reported number. +pub struct BenchRow { + pub opponent: &'static str, + pub games: u32, + /// Independent drafts behind `games`. Equal to `games` here; a matched-pair + /// construction would halve it, and the interval depends on THIS number. + pub drafts: u32, + pub wins: u32, + pub losses: u32, + pub draws: u32, +} + +impl BenchRow { + /// Win share over decisive games. + pub fn win_rate(&self) -> f64 { + let decisive = self.wins + self.losses; + if decisive == 0 { + 0.0 + } else { + self.wins as f64 / decisive as f64 + } + } + + /// Half-width of the 95% interval on `win_rate` (normal approximation). + pub fn ci95(&self) -> f64 { + let decisive = (self.wins + self.losses) as f64; + if decisive == 0.0 { + return 0.0; + } + let p = self.win_rate(); + 1.96 * (p * (1.0 - p) / decisive).sqrt() + } +} + +/// Score `cand` against every opponent in `ladder` (singles). +pub fn bench_singles( + roster: &Roster, + cand: &'static str, + ladder: &[&'static str], + games: usize, + wseed: u32, + seed_base: u32, + threads: usize, + info: InfoMode, +) -> Vec { + let book = roster::address_book(); + ladder + .iter() + .map(|&opponent| { + // Alternating entries put the candidate in each seat for half the drafts. + let pairs = [(cand, opponent), (opponent, cand)]; + let (mut specs, pair_of) = build_specs_with(roster, games, wseed, seed_base, &pairs); + apply_info_mode(&mut specs, &pair_of, info); + let outcomes = run_games(&specs, &book, threads, false); + + let mut row = + BenchRow { opponent, games: 0, drafts: 0, wins: 0, losses: 0, draws: 0 }; + for (i, outcome) in outcomes.iter().enumerate() { + // pair 0 seats the candidate at p1, pair 1 at p0. + let cand_seat = if pair_of[i] == 0 { 1u8 } else { 0u8 }; + row.games += 1; + row.drafts += 1; + // An engine panic surfaces as Err; count it with the draws + // rather than silently crediting either side. + match outcome.as_ref().ok().and_then(|o| o.winner_seat) { + Some(s) if s == cand_seat => row.wins += 1, + Some(_) => row.losses += 1, + None => row.draws += 1, + } + } + row + }) + .collect() +} + +/// Score `cand` against every opponent in `ladder` (doubles). +/// +/// Independent drafts, one per game, with the candidate's seat alternating +/// across games. Deliberately NOT the matched-pair construction used by +/// `doubles_ab_winrate`: replaying each draft with the pilots swapped buys +/// precision by halving the number of distinct battles, which is the right +/// trade for "is this tweak better" and the wrong one for a ladder. +pub fn bench_doubles( + roster: &Roster, + cand: &'static str, + ladder: &[&'static str], + games: usize, + wseed: u32, + seed_base: u32, + threads: usize, +) -> Vec { + let book = roster::address_book(); + ladder + .iter() + .map(|&opponent| { + let mut rng = Wrand::new(wseed); + let mut specs = Vec::with_capacity(games); + for i in 0..games { + let (p0_team, p0_ids) = draw_built_team(roster, &mut rng); + let (p1_team, p1_ids) = draw_built_team(roster, &mut rng); + // Alternate which seat the candidate pilots, one fresh draft each. + let (c0, c1) = if i % 2 == 0 { + (DoublesSideCfg { bot: opponent, ..Default::default() }, + DoublesSideCfg { bot: cand, ..Default::default() }) + } else { + (DoublesSideCfg { bot: cand, ..Default::default() }, + DoublesSideCfg { bot: opponent, ..Default::default() }) + }; + specs.push(doubles_spec( + seed_base.wrapping_add(i as u32), + p0_team, p0_ids, c0, + p1_team, p1_ids, c1, + )); + } + let outcomes = run_doubles_games(&specs, &book, threads); + let mut row = BenchRow { opponent, games: 0, drafts: 0, wins: 0, losses: 0, draws: 0 }; + for (i, o) in outcomes.iter().enumerate() { + let cand_seat = if i % 2 == 0 { 1u8 } else { 0u8 }; + row.games += 1; + row.drafts += 1; + match o.winner_side { + Some(sd) if sd == cand_seat => row.wins += 1, + Some(_) => row.losses += 1, + None => row.draws += 1, + } + } + row + }) + .collect() +} diff --git a/transpiler/strategies-rs/src/bin/analyze.rs b/transpiler/strategies-rs/src/bin/analyze.rs index 21fec0a5..c80c557d 100644 --- a/transpiler/strategies-rs/src/bin/analyze.rs +++ b/transpiler/strategies-rs/src/bin/analyze.rs @@ -5,8 +5,11 @@ //! //! Data (drool/*.csv, src/mons/*.json) is read relative to CHOMP_ROOT. -use chomp_strategies::analysis::{run_mon_analysis, run_mon_analysis_with}; -use chomp_strategies::game::StrategyKind; +use chomp_strategies::analysis::{ + run_doubles_mon_analysis, run_mon_analysis, run_mon_analysis_rotated, run_mon_analysis_with, +}; +use chomp_strategies::bots; +use chomp_strategies::game::BotName; use chomp_strategies::roster::load_roster; use std::path::PathBuf; @@ -45,17 +48,39 @@ fn main() { let p1 = arg(&args, "--p1"); let p0 = arg(&args, "--p0"); + // --rotate equips a uniform 4-subset of each mon's catalog per drafted slot (default: lanes 0-3). + let rotate = args.iter().any(|a| a == "--rotate"); + let mode = arg(&args, "--mode").unwrap_or_else(|| "singles".to_string()); + // Doubles only: 0 = epsilon-greedy pilot (damaging moves only), >=1 = maximin search (also + // enumerates setup moves), so this toggles whether setup mons can be piloted at all. + let search_depth = arg_u(&args, "--search-depth", 0) as u32; + if mode == "doubles" && search_depth >= 1 { + eprintln!(" pilot: doubles maximin search d{search_depth} (setup moves enumerated)"); + } + if rotate { + eprintln!(" loadouts: rotated (uniform 4-subset per drafted slot)"); + } + let started = std::time::Instant::now(); - let a = match (&p1, &p0) { - (Some(p1s), Some(p0s)) => { - let pair = ( - StrategyKind::parse(p1s).expect("bad --p1 strategy"), - StrategyKind::parse(p0s).expect("bad --p0 strategy"), - ); - eprintln!(" pilots: p1={p1s} vs p0={p0s}"); - run_mon_analysis_with(&roster, games, seed, seed_base, threads, &[pair]) + let a = if mode == "doubles" { + run_doubles_mon_analysis(&roster, games, seed, seed_base, threads, rotate, search_depth) + } else { + match (&p1, &p0) { + (Some(p1s), Some(p0s)) => { + let pair = ( + bots::parse(p1s).expect("bad --p1 strategy"), + bots::parse(p0s).expect("bad --p0 strategy"), + ); + eprintln!(" pilots: p1={p1s} vs p0={p0s}"); + if rotate { + run_mon_analysis_rotated(&roster, games, seed, seed_base, threads, Some(&[pair])) + } else { + run_mon_analysis_with(&roster, games, seed, seed_base, threads, &[pair]) + } + } + _ if rotate => run_mon_analysis_rotated(&roster, games, seed, seed_base, threads, None), + _ => run_mon_analysis(&roster, games, seed, seed_base, threads), } - _ => run_mon_analysis(&roster, games, seed, seed_base, threads), }; let elapsed = started.elapsed().as_secs_f64(); @@ -63,12 +88,21 @@ fn main() { let mut ids: Vec = a.per_mon.keys().copied().collect(); ids.sort_by(|&x, &y| a.per_mon[&y].win_rate().partial_cmp(&a.per_mon[&x].win_rate()).unwrap()); - println!( - "\n{:<12} {:>6} {:>7} | {:>8} {:>7} {:>7} | {:>8} {:>8}", - "mon", "win%", "games", "act.t/g", "KOd/g", "KOt/g", "KOd/g·W", "KOd/g·L" - ); + let attributed = !a.ko_matrix.is_empty(); + if attributed { + println!( + "\n{:<12} {:>6} {:>7} | {:>8} {:>7} {:>7} | {:>8} {:>8}", + "mon", "win%", "games", "act.t/g", "KOd/g", "KOt/g", "KOd/g·W", "KOd/g·L" + ); + } else { + println!("\n{:<12} {:>6} {:>7}", "mon", "win%", "games"); + } for id in &ids { let s = &a.per_mon[id]; + if !attributed { + println!("{:<12} {:>5.1}% {:>7}", roster.mon_name(*id), s.win_rate() * 100.0, s.games()); + continue; + } let g = s.games().max(1) as f64; let wg = s.games_won.max(1) as f64; let lg = s.games_lost.max(1) as f64; @@ -86,22 +120,39 @@ fn main() { } // Mon-vs-mon KO matrix (row KOs col), raw attributed counts. - println!("\nKO matrix (row mon KOs column mon — attributed counts):"); - print!("{:<9}", ""); - for id in &ids { - print!("{:>7}", short(&roster.mon_name(*id))); - } - println!(); - for kid in &ids { - print!("{:<9}", short(&roster.mon_name(*kid))); - for vid in &ids { - if kid == vid { - print!("{:>7}", "·"); - } else { - print!("{:>7}", a.ko_matrix.get(&(*kid, *vid)).copied().unwrap_or(0)); - } + if a.ko_matrix.is_empty() { + println!("\nKO matrix: n/a (no attributed KOs)"); + } else { + println!("\nKO matrix (row mon KOs column mon — attributed counts):"); + print!("{:<9}", ""); + for id in &ids { + print!("{:>7}", short(&roster.mon_name(*id))); } println!(); + for kid in &ids { + print!("{:<9}", short(&roster.mon_name(*kid))); + for vid in &ids { + if kid == vid { + print!("{:>7}", "·"); + } else { + print!("{:>7}", a.ko_matrix.get(&(*kid, *vid)).copied().unwrap_or(0)); + } + } + println!(); + } + } + + // Doubles credits a KO from the killer side's target nibble, so state the coverage rather than + // letting the matrix read as the full KO count. + if a.ko_shared > 0 || a.ko_incidental > 0 { + let credited: u32 = a.ko_matrix.values().sum(); + let total = credited + a.ko_shared + a.ko_incidental; + println!( + "\nKO attribution: {credited} credited ({:.0}%) · {} co-kills (both slots aimed) · {} incidental (status/recoil)", + 100.0 * credited as f64 / total.max(1) as f64, + a.ko_shared, + a.ko_incidental + ); } eprintln!( diff --git a/transpiler/strategies-rs/src/bin/arena.rs b/transpiler/strategies-rs/src/bin/arena.rs index 2ea77fb9..f1ec0251 100644 --- a/transpiler/strategies-rs/src/bin/arena.rs +++ b/transpiler/strategies-rs/src/bin/arena.rs @@ -5,9 +5,14 @@ //! Data (drool/*.csv, src/mons/*.json) is read relative to CHOMP_ROOT (default: the repo root //! inferred from the crate location). -use chomp_strategies::arena::{doubles_search_winrate, eval_weights_winrate, run_arena, run_doubles_arena}; +use chomp_strategies::arena::{ + doubles_ab_winrate, doubles_search_winrate, eval_weights_winrate, run_arena, run_doubles_arena, DoublesSideCfg, +}; +use chomp_strategies::doubles::DoublesEvalW; use chomp_strategies::evaluator::{Weights, DEFAULT_WEIGHTS, N_FEATURES}; -use chomp_strategies::game::StrategyKind; +use chomp_strategies::bot::InfoMode; +use chomp_strategies::bots; +use chomp_strategies::game::BotName; use chomp_strategies::roster::load_roster; use std::path::PathBuf; @@ -43,19 +48,50 @@ fn main() { let threads = arg_u(&args, "--threads", default_threads as u64) as usize; let mode = arg(&args, "--mode").unwrap_or_else(|| "singles".to_string()); + // Blind is the default: simultaneous moves, symmetric seats. + let info = arg(&args, "--info") + .map(|v| InfoMode::parse(&v).expect("--info: blind | rotate")) + .unwrap_or(InfoMode::Blind); let chomp_root = std::env::var("CHOMP_ROOT").map(PathBuf::from).unwrap_or_else(|_| { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("..") }); let roster = load_roster(&chomp_root); - eprintln!("arena[{mode}]: {} mons · {} games · {} threads · seed {:#x}", roster.mons.len(), games, threads, seed); + eprintln!("arena[{mode}/{}]: {} mons · {} games · {} threads · seed {:#x}", info.label(), roster.mons.len(), games, threads, seed); // A/B a candidate linear weight vector vs the frozen baseline. `--search-depth N` (≥1) makes p1 // play no-peek maximin search at depth N over the weights; 0 = 1-ply greedy over them. let search_depth = arg_u(&args, "--search-depth", 0) as u32; let peek = args.iter().any(|a| a == "--peek"); // peek-at-root best-response for the search seat + // Doubles eval-weight A/B: candidate (side flags below) vs the default-weight baseline, both + // searching. Same teams/seats per pair with configs exchanged, so only the config differs. + // --mode doubles-ab [--depth N] [--base-depth N] [--wboost F] [--wstatus F] [--wskip F] [--gateko] + if mode == "doubles-ab" { + let argf = |flag: &str, def: f64| arg(&args, flag).map(|v| v.parse().expect("float flag")).unwrap_or(def); + let depth = arg_u(&args, "--depth", 1) as u32; + let base_depth = arg_u(&args, "--base-depth", depth as u64) as u32; + let eval = DoublesEvalW { + w_boost: argf("--wboost", 0.0), + w_status: argf("--wstatus", 0.0), + w_skip: argf("--wskip", 0.0), + gate_ko: args.iter().any(|a| a == "--gateko"), + ..DoublesEvalW::default() + }; + let cand = DoublesSideCfg { depth, eval, ..Default::default() }; + let base = DoublesSideCfg { depth: base_depth, ..Default::default() }; + let started = std::time::Instant::now(); + let r = doubles_ab_winrate(&roster, cand, base, games, seed, seed_base, threads); + let elapsed = started.elapsed().as_secs_f64(); + println!( + "cand d{depth} boost={} status={} skip={} gateko={} vs base d{base_depth} default · {} games · win {:.1}% ({}-{}-{} draws)", + eval.w_boost, eval.w_status, eval.w_skip, eval.gate_ko, games, r.share * 100.0, r.cand_wins, r.base_wins, r.draws + ); + eprintln!("{games} games in {elapsed:.2}s ({:.0} games/s)", games as f64 / elapsed); + return; + } + // Doubles maximin search vs the epsilon-greedy Hard baseline (Phase-3 substrate check). if mode == "doubles" && search_depth >= 1 { let started = std::time::Instant::now(); @@ -76,7 +112,7 @@ fn main() { if let Some((label, cand)) = cand { let started = std::time::Instant::now(); let wr = eval_weights_winrate( - &roster, &cand, search_depth, peek, StrategyKind::Greedy, StrategyKind::Greedy, games, seed, seed_base, threads, + &roster, &cand, search_depth, peek, bots::GREEDY, bots::GREEDY, games, seed, seed_base, threads, ); let elapsed = started.elapsed().as_secs_f64(); let m = if search_depth >= 1 { format!("d{search_depth}-search") } else { "1-ply".to_string() }; @@ -95,7 +131,7 @@ fn main() { ); } } else { - for s in &run_arena(&roster, games, seed, seed_base, threads) { + for s in &run_arena(&roster, games, seed, seed_base, threads, info) { println!( "{:>9} {:>9} {:>6} {:>6.1}% {:>4}-{:>3}-{:<4}", s.p1_strat, s.p0_strat, s.games, s.p1_rate() * 100.0, s.p1_wins, s.p0_wins, s.draws diff --git a/transpiler/strategies-rs/src/bin/bench.rs b/transpiler/strategies-rs/src/bin/bench.rs new file mode 100644 index 00000000..951ce2da --- /dev/null +++ b/transpiler/strategies-rs/src/bin/bench.rs @@ -0,0 +1,107 @@ +//! Score a bot against the baseline ladder. +//! +//! cargo run --release -p chomp-strategies --bin bench -- --bot greedy +//! cargo run --release -p chomp-strategies --bin bench -- --bot mybot --mode doubles +//! +//! Every row draws independent drafts and alternates which seat the candidate +//! pilots, because the seats are not perfectly symmetric; and every row prints +//! its own draft count and 95% interval, because a deep bot's row is +//! necessarily thinner than a shallow one's. +//! +//! Data (drool/*.csv, src/mons/*.json) is read relative to CHOMP_ROOT. + +use chomp_strategies::arena::{bench_doubles, bench_singles, BenchRow}; +use chomp_strategies::bot::InfoMode; +use chomp_strategies::bots; +use chomp_strategies::roster::load_roster; +use std::path::PathBuf; + +/// The published singles ladder, weakest first. +const SINGLES_LADDER: &[&str] = &[ + bots::RANDOM, + bots::GREEDY, + bots::HEURISTIC, + bots::OVERRIDE, + bots::NOPEEK, + bots::SEARCH_D1, +]; + +/// The published doubles ladder, weakest first. +const DOUBLES_LADDER: &[&str] = + &[bots::DOUBLES_EASY, bots::DOUBLES_MEDIUM, bots::DOUBLES_HARD, bots::DOUBLES_SEARCH_D1]; + +fn arg(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() +} + +fn arg_u(args: &[String], flag: &str, def: u64) -> u64 { + match arg(args, flag) { + Some(v) if v.starts_with("0x") => u64::from_str_radix(&v[2..], 16).unwrap_or(def), + Some(v) => v.parse().unwrap_or(def), + None => def, + } +} + +fn print_rows(bot: &str, rows: &[BenchRow], elapsed: f64) { + println!("\n{:>18} {:>7} {:>8} {:>13} {:>12}", "opponent", "drafts", "win%", "95% CI", "w-l-draw"); + for r in rows { + println!( + "{:>18} {:>7} {:>7.1}% ±{:>11.1}% {:>4}-{:>3}-{:<4}", + r.opponent, + r.drafts, + r.win_rate() * 100.0, + r.ci95() * 100.0, + r.wins, + r.losses, + r.draws + ); + } + let beat: Vec<&str> = + rows.iter().filter(|r| r.win_rate() - r.ci95() > 0.5).map(|r| r.opponent).collect(); + println!(); + if beat.is_empty() { + println!("{bot} does not clear 50% against any ladder bot (beyond its interval)."); + } else { + println!("{bot} beats: {}", beat.join(", ")); + } + eprintln!("\n{elapsed:.2}s"); +} + +fn main() { + let args: Vec = std::env::args().collect(); + let bot = arg(&args, "--bot").unwrap_or_else(|| { + eprintln!("usage: bench --bot [--mode singles|doubles] [--games N] [--info blind|rotate]"); + eprintln!("registered singles bots: {}", bots::NAMES.join(", ")); + eprintln!("registered doubles bots: {}", bots::DOUBLES_NAMES.join(", ")); + std::process::exit(2); + }); + let bot = bots::parse(&bot).unwrap_or_else(|| { + eprintln!("unknown bot {bot:?}; registered: {}", bots::NAMES.join(", ")); + std::process::exit(2); + }); + + let mode = arg(&args, "--mode").unwrap_or_else(|| "singles".to_string()); + let games = arg_u(&args, "--games", 2000) as usize; + let seed = arg_u(&args, "--seed", 0xbeefcafe) as u32; + let seed_base = arg_u(&args, "--seed-base", 10_000) as u32; + let default_threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + let threads = arg_u(&args, "--threads", default_threads as u64) as usize; + let info = arg(&args, "--info") + .map(|v| InfoMode::parse(&v).expect("--info: blind | rotate")) + .unwrap_or(InfoMode::Blind); + + let chomp_root = std::env::var("CHOMP_ROOT").map(PathBuf::from).unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("..") + }); + let roster = load_roster(&chomp_root); + + let started = std::time::Instant::now(); + let rows = if mode == "doubles" { + eprintln!("bench[doubles]: {bot} · {games} games/opponent · seed {seed:#x}"); + bench_doubles(&roster, bot, DOUBLES_LADDER, games, seed, seed_base, threads) + } else { + eprintln!("bench[singles/{}]: {bot} · {games} games/opponent · seed {seed:#x}", info.label()); + bench_singles(&roster, bot, SINGLES_LADDER, games, seed, seed_base, threads, info) + }; + print_rows(bot, &rows, started.elapsed().as_secs_f64()); +} diff --git a/transpiler/strategies-rs/src/bin/cert.rs b/transpiler/strategies-rs/src/bin/cert.rs index 7fb0746e..dc594a47 100644 --- a/transpiler/strategies-rs/src/bin/cert.rs +++ b/transpiler/strategies-rs/src/bin/cert.rs @@ -7,7 +7,8 @@ use chomp_strategies::analysis::run_mon_analysis; use chomp_strategies::arena::build_team_mon; use chomp_strategies::evaluator::DEFAULT_WEIGHTS; -use chomp_strategies::game::{play_game, GameSpec, StrategyKind}; +use chomp_strategies::bots; +use chomp_strategies::game::{play_game, GameSpec, BotName}; use chomp_strategies::matrix::compute_static_matrix; use chomp_strategies::roster::{self, load_roster}; use std::path::PathBuf; @@ -49,8 +50,9 @@ fn main() { p1_team: vec![build_team_mon(&roster.mons[i])], p0_ids: vec![m.ids[j]], p1_ids: vec![m.ids[i]], - p0_strategy: StrategyKind::Greedy, - p1_strategy: StrategyKind::Greedy, + p0_strategy: bots::GREEDY, + p1_strategy: bots::GREEDY, + peek_seat: None, p0_weights: DEFAULT_WEIGHTS, p1_weights: DEFAULT_WEIGHTS, p0_search_depth: 0, diff --git a/transpiler/strategies-rs/src/bin/findpanic.rs b/transpiler/strategies-rs/src/bin/findpanic.rs new file mode 100644 index 00000000..412eac53 --- /dev/null +++ b/transpiler/strategies-rs/src/bin/findpanic.rs @@ -0,0 +1,79 @@ +//! Locate the game(s) in an arena batch that panic the engine, and print enough to replay one. +//! cargo run --release -p chomp-strategies --bin findpanic -- --games 20000 + +use chomp_strategies::arena::{build_specs, build_specs_full, STRAT_PAIRS}; +use chomp_strategies::bots; +use chomp_strategies::game::{run_games_instrumented, BotName}; +use chomp_strategies::roster::{self, load_roster}; +use std::path::PathBuf; + +fn arg(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() +} +fn arg_u(args: &[String], flag: &str, def: u64) -> u64 { + match arg(args, flag) { + Some(v) if v.starts_with("0x") => u64::from_str_radix(&v[2..], 16).unwrap_or(def), + Some(v) => v.parse().unwrap_or(def), + None => def, + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let games = arg_u(&args, "--games", 20_000) as usize; + let seed = arg_u(&args, "--seed", 0xbeefcafe) as u32; + let seed_base = arg_u(&args, "--seed-base", 10_000) as u32; + let rotate = args.iter().any(|a| a == "--rotate"); + + let chomp_root = std::env::var("CHOMP_ROOT").map(PathBuf::from).unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("..") + }); + let roster = load_roster(&chomp_root); + let book = roster::address_book(); + + let pairs: Vec<(BotName, BotName)> = STRAT_PAIRS + .iter() + .map(|&(p1, p0)| (p1, p0)) + .collect(); + let (specs, _) = if rotate { + build_specs_full(&roster, games, seed, seed_base, &pairs, true) + } else { + build_specs(&roster, games, seed, seed_base) + }; + + eprintln!("findpanic: {games} games · rotate={rotate} · seed {seed:#x}"); + // Single-threaded so the panic message interleaves with the game it came from. + let results = run_games_instrumented(&specs, &book, 1); + + let mut found = 0; + for (i, r) in results.iter().enumerate() { + let Err(e) = r else { continue }; + found += 1; + let s = &specs[i]; + let names = |ids: &[u32]| ids.iter().map(|&x| roster.mon_name(x)).collect::>().join(", "); + println!("\n=== PANIC at spec index {i} ==="); + println!(" error {e}"); + println!(" game seed {} (arena seed {seed:#x}, seed_base {seed_base})", s.seed); + println!(" p0 {:?} [{}]", s.p0_ids, names(&s.p0_ids)); + println!(" p1 {:?} [{}]", s.p1_ids, names(&s.p1_ids)); + println!(" pilots p0={:?} p1={:?}", s.p0_strategy, s.p1_strategy); + for (side, team) in [("p0", &s.p0_team), ("p1", &s.p1_team)] { + for (k, mon) in team.iter().enumerate() { + let id = if side == "p0" { s.p0_ids[k] } else { s.p1_ids[k] }; + let mv: Vec = mon + .moves + .iter() + .map(|w| { + roster + .mon_by_id(id) + .and_then(|m| m.catalog.iter().find(|c| c.word == *w)) + .map(|c| c.name.clone()) + .unwrap_or_else(|| "?".into()) + }) + .collect(); + println!(" {side}[{k}] {:<11} {}", roster.mon_name(id), mv.join(" / ")); + } + } + } + println!("\n{found} panicking game(s) out of {games}"); +} diff --git a/transpiler/strategies-rs/src/bin/mock2.rs b/transpiler/strategies-rs/src/bin/mock2.rs index a9fccd74..6472981a 100644 --- a/transpiler/strategies-rs/src/bin/mock2.rs +++ b/transpiler/strategies-rs/src/bin/mock2.rs @@ -1,7 +1,8 @@ //! T2 mock-move A/B — loop-driven mock moves (conditional power from live state), no engine edits. //! cargo run --release -p chomp-strategies --bin mock2 -- --games 10000 -use chomp_strategies::game::StrategyKind; +use chomp_strategies::bots; +use chomp_strategies::game::BotName; use chomp_strategies::mock2::{batch1, run_mock_ab, run_mock_ab_with}; use chomp_strategies::roster::load_roster; use std::path::PathBuf; @@ -47,7 +48,7 @@ fn main() { } let replaced = roster.move_name(roster.mons.iter().find(|x| x.name == m.mon).unwrap().id, m.lane as u8); let (base, mocked) = if ceiling { - run_mock_ab_with(&roster, m, games, seed, seed_base, threads, &[(StrategyKind::NoPeekExpect, StrategyKind::NoPeekExpect)]) + run_mock_ab_with(&roster, m, games, seed, seed_base, threads, &[(bots::NOPEEK, bots::NOPEEK)]) } else { run_mock_ab(&roster, m, games, seed, seed_base, threads) }; diff --git a/transpiler/strategies-rs/src/bin/montrace.rs b/transpiler/strategies-rs/src/bin/montrace.rs new file mode 100644 index 00000000..fe28bb0b --- /dev/null +++ b/transpiler/strategies-rs/src/bin/montrace.rs @@ -0,0 +1,99 @@ +//! Per-mon doubles diagnostic — what does one mon actually DO in its doubles games? +//! Reports move usage, survival, and whether its setup ever lands. +//! cargo run --release -p chomp-strategies --bin montrace -- --mon Iblivion --games 400 + +use chomp_strategies::arena::build_doubles_specs; +use chomp_strategies::doubles::play_doubles_game_instrumented; +use chomp_strategies::roster::{self, load_roster}; +use std::path::PathBuf; + +fn arg(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() +} +fn arg_u(args: &[String], flag: &str, def: u64) -> u64 { + match arg(args, flag) { + Some(v) if v.starts_with("0x") => u64::from_str_radix(&v[2..], 16).unwrap_or(def), + Some(v) => v.parse().unwrap_or(def), + None => def, + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let games = arg_u(&args, "--games", 400) as usize; + let seed = arg_u(&args, "--seed", 0xbeefcafe) as u32; + let seed_base = arg_u(&args, "--seed-base", 10_000) as u32; + let target = arg(&args, "--mon").unwrap_or_else(|| "Iblivion".to_string()); + let search_depth = arg_u(&args, "--search-depth", 0) as u32; + let rotate = args.iter().any(|a| a == "--rotate"); + + let chomp_root = std::env::var("CHOMP_ROOT").map(PathBuf::from).unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("..") + }); + let roster = load_roster(&chomp_root); + let book = roster::address_book(); + let tid = roster.mons.iter().find(|m| m.name == target).expect("unknown --mon").id; + + let (specs, _) = build_doubles_specs(&roster, games, seed, seed_base, rotate, search_depth); + + // Move-index usage over the tracked mon's active turns (125 = switch, 126 = no-op/rest). + let mut usage = [0u64; 128]; + let (mut n, mut wins, mut losses) = (0u64, 0u64, 0u64); + let (mut sum_active, mut sum_turns) = (0u64, 0u64); + let mut ever_boosted = 0u64; // games where the mon's Attack delta ever went positive + let mut sum_min_hp = 0.0f64; + + for spec in &specs { + for side in 0u8..2 { + let ids = if side == 0 { &spec.p0_ids } else { &spec.p1_ids }; + let Some(mon) = ids.iter().position(|&x| x == tid) else { continue }; + let tr = play_doubles_game_instrumented(spec, &book, Some((side, mon))); + n += 1; + match tr.winner_side { + Some(w) if w == side => wins += 1, + Some(_) => losses += 1, + None => {} + } + sum_active += tr.tracked_active_turns as u64; + sum_turns += tr.turns as u64; + let mut boosted = false; + let mut min_hp = 100.0f64; + for r in &tr.rows { + if r.active { + usage[r.move_index as usize] += 1; + } + if r.atk_delta > 0 { + boosted = true; + } + if r.hp_pct < min_hp { + min_hp = r.hp_pct; + } + } + if boosted { + ever_boosted += 1; + } + sum_min_hp += min_hp; + } + } + + let nf = n.max(1) as f64; + println!("\n{target} (id {tid}) · {n} appearances over {games} doubles games · search d{search_depth}"); + println!(" win/loss {wins}-{losses} ({:.1}%)", wins as f64 / (wins + losses).max(1) as f64 * 100.0); + println!(" active turns/game {:.2} (game length {:.2})", sum_active as f64 / nf, sum_turns as f64 / nf); + println!(" min HP reached {:.1}% (mean over appearances)", sum_min_hp / nf); + println!(" setup ever landed {:.1}% of appearances (Attack delta > 0 at any point)", ever_boosted as f64 / nf * 100.0); + + let total: u64 = usage.iter().sum(); + println!("\n move usage over {total} acting turns:"); + for (mi, &c) in usage.iter().enumerate() { + if c == 0 { + continue; + } + let label = match mi { + 125 => "".to_string(), + 126 => "".to_string(), + _ => roster.move_name(tid, mi as u8), + }; + println!(" {:<20} {:>7} {:>5.1}%", label, c, c as f64 / total.max(1) as f64 * 100.0); + } +} diff --git a/transpiler/strategies-rs/src/bin/statpeak.rs b/transpiler/strategies-rs/src/bin/statpeak.rs new file mode 100644 index 00000000..51e348f2 --- /dev/null +++ b/transpiler/strategies-rs/src/bin/statpeak.rs @@ -0,0 +1,72 @@ +//! Distribution of the largest combat stat any mon reaches — stat boosts are multiplicative and +//! stack per source, and MAX_BOOSTED_STAT lets one reach int32::max. +//! cargo run --release -p chomp-strategies --bin statpeak -- --games 20000 + +use chomp_strategies::arena::{build_specs, build_specs_full, STRAT_PAIRS}; +use chomp_strategies::bots; +use chomp_strategies::game::{run_games_instrumented, BotName}; +use chomp_strategies::roster::{self, load_roster}; +use std::path::PathBuf; + +fn arg(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() +} +fn arg_u(args: &[String], flag: &str, def: u64) -> u64 { + match arg(args, flag) { + Some(v) if v.starts_with("0x") => u64::from_str_radix(&v[2..], 16).unwrap_or(def), + Some(v) => v.parse().unwrap_or(def), + None => def, + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let games = arg_u(&args, "--games", 20_000) as usize; + let seed = arg_u(&args, "--seed", 0xbeefcafe) as u32; + let seed_base = arg_u(&args, "--seed-base", 10_000) as u32; + let rotate = args.iter().any(|a| a == "--rotate"); + + let chomp_root = std::env::var("CHOMP_ROOT").map(PathBuf::from).unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("..") + }); + let roster = load_roster(&chomp_root); + let book = roster::address_book(); + let pairs: Vec<(BotName, BotName)> = STRAT_PAIRS + .iter() + .map(|&(p1, p0)| (p1, p0)) + .collect(); + let (specs, _) = if rotate { + build_specs_full(&roster, games, seed, seed_base, &pairs, true) + } else { + build_specs(&roster, games, seed, seed_base) + }; + let recs = run_games_instrumented(&specs, &book, 8); + + let mut peaks: Vec<(i64, u32)> = recs.iter().filter_map(|r| r.as_ref().ok()).map(|r| (r.peak_stat, r.peak_stat_mon)).collect(); + peaks.sort_by_key(|p| p.0); + let n = peaks.len(); + let pct = |q: f64| peaks[((n as f64 - 1.0) * q) as usize]; + println!("\npeak combat stat per game ({n} games)"); + for (lbl, q) in [("p50", 0.50), ("p90", 0.90), ("p99", 0.99), ("p99.9", 0.999)] { + println!(" {lbl:>6} {:>16}", pct(q).0); + } + let (mx, mon) = peaks[n - 1]; + println!(" {:>6} {:>16} ({})", "max", mx, roster.mon_name(mon)); + + const I32MAX: i64 = 2_147_483_647; + for thresh in [10_000i64, 1_000_000, 100_000_000, I32MAX] { + let c = peaks.iter().filter(|p| p.0 >= thresh).count(); + println!(" games with a stat >= {thresh:>13}: {c:>6} ({:.3}%)", 100.0 * c as f64 / n as f64); + } + // Which mon holds the peak in the most extreme games. + let mut worst: Vec<(i64, u32)> = peaks.iter().rev().take(200).copied().collect(); + worst.sort_by_key(|p| p.1); + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for (_, m) in &worst { *counts.entry(*m).or_default() += 1; } + let mut cv: Vec<_> = counts.into_iter().collect(); + cv.sort_by_key(|c| std::cmp::Reverse(c.1)); + println!("\n peak holder in the 200 most extreme games:"); + for (m, c) in cv.iter().take(6) { + println!(" {:<12} {c:>4}", roster.mon_name(*m)); + } +} diff --git a/transpiler/strategies-rs/src/bin/tracesearch.rs b/transpiler/strategies-rs/src/bin/tracesearch.rs index a8af0894..0de48d49 100644 --- a/transpiler/strategies-rs/src/bin/tracesearch.rs +++ b/transpiler/strategies-rs/src/bin/tracesearch.rs @@ -6,7 +6,8 @@ use chomp_strategies::arena::{build_team_mon, draw_team, Wrand}; use chomp_strategies::evaluator::{Weights, DEFAULT_WEIGHTS, N_FEATURES}; -use chomp_strategies::game::{narrate_game, run_games_traced, GameSpec, StrategyKind}; +use chomp_strategies::bots; +use chomp_strategies::game::{narrate_game, run_games_traced, GameSpec, BotName}; use chomp_strategies::roster::{self, load_roster}; use chomp_strategies::view::{NO_OP_INDEX, SWITCH_MOVE_INDEX}; use std::path::PathBuf; @@ -61,7 +62,7 @@ fn main() { let seed_base = arg_u(&args, "--seed-base", 10_000) as u32; let narrate_n = arg_u(&args, "--narrate", 0) as usize; let p0_name = arg(&args, "--p0").unwrap_or_else(|| "greedy".to_string()); - let p0_strat = StrategyKind::parse(&p0_name).expect("--p0: greedy / heuristic / override / ..."); + let p0_strat = bots::parse(&p0_name).expect("--p0: greedy / heuristic / override / ..."); let peek = args.iter().any(|a| a == "--peek"); let mixed = args.iter().any(|a| a == "--mixed"); let weights = weights_arg(&args); @@ -88,7 +89,8 @@ fn main() { p0_ids, p1_ids, p0_strategy: p0_strat, - p1_strategy: StrategyKind::Greedy, // ignored — p1_search_depth overrides to the search + p1_strategy: bots::GREEDY, // ignored — p1_search_depth overrides to the search + peek_seat: None, p0_weights: DEFAULT_WEIGHTS, p1_weights: weights, p0_search_depth: 0, diff --git a/transpiler/strategies-rs/src/bot.rs b/transpiler/strategies-rs/src/bot.rs new file mode 100644 index 00000000..931acf67 --- /dev/null +++ b/transpiler/strategies-rs/src/bot.rs @@ -0,0 +1,130 @@ +//! The bot interface — what a benchmark entrant implements. +//! +//! A bot is handed the live battle and decides one turn. It may fork the +//! engine through `ctx.sim` and play hypotheticals forward: the forward model +//! is the real transpiled engine, not an approximation of it. +//! +//! Two rules matter for comparable results: +//! +//! - **Draw only from `ctx.rng`.** It is this seat's private stream, so how +//! much a bot draws cannot shift the battle or its opponent. +//! - **Read the opponent's move only through `ctx.peek`.** It is `None` in +//! blind mode, so a bot cannot accidentally depend on seeing a reveal. + +use crate::evaluator::Weights; +use crate::jsrng::JsRng; +use crate::sim::Sim; +use crate::view::{BattleView, Mv, Seat}; + +/// How much a seat is told about the opponent's move. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InfoMode { + /// Genuinely simultaneous: neither seat sees the other's move. Matches + /// production play, and keeps the seats symmetric. + Blind, + /// One seat sees the reveal, alternating per game so that over a batch + /// each seat peeks equally often. + RotatePeek, +} + +impl InfoMode { + pub fn parse(name: &str) -> Option { + match name { + "blind" => Some(InfoMode::Blind), + "rotate" | "peek" => Some(InfoMode::RotatePeek), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + InfoMode::Blind => "blind", + InfoMode::RotatePeek => "rotate-peek", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BattleMode { + Singles, + Doubles, +} + +/// One turn's submission: a single move, or one per active slot in doubles. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Single(Mv), + Slots([Mv; 2]), +} + +impl Action { + /// The singles move, taking slot 0 if a doubles bot answered. + pub fn single(self) -> Mv { + match self { + Action::Single(mv) => mv, + Action::Slots([mv, _]) => mv, + } + } +} + +/// Everything a bot may look at when deciding. +pub struct DecisionCtx<'a> { + /// The live battle. Fork it to evaluate hypotheticals. + pub sim: &'a mut Sim, + /// Which physical side this bot pilots. + pub seat: Seat, + /// Which mode this game is. + pub mode: BattleMode, + /// Singles position snapshot. `None` in doubles, where a single active pair + /// cannot describe the board — doubles bots read `sim` directly. + pub view: Option<&'a BattleView>, + /// The opponent's move this turn — `None` in blind mode. + pub peek: Option, + /// This seat's private stream. + pub rng: &'a mut JsRng, +} + +impl<'a> DecisionCtx<'a> { + /// The singles snapshot. Panics in doubles, where it does not exist. + pub fn singles_view(&self) -> &'a BattleView { + self.view.expect("singles_view() called in doubles; read ctx.sim instead") + } +} + +/// Tuning a built-in bot reads at construction. Entrants are free to ignore it. +#[derive(Clone, Copy, Debug)] +pub struct BotConfig { + pub weights: Weights, + /// Eval weights for the doubles search tier; ignored by every other bot. + pub doubles_eval: crate::doubles::DoublesEvalW, + pub search_depth: u32, + pub search_peek: bool, + pub search_mixed: bool, +} + +impl Default for BotConfig { + fn default() -> Self { + BotConfig { + weights: crate::evaluator::DEFAULT_WEIGHTS, + doubles_eval: crate::doubles::DoublesEvalW::default(), + search_depth: 0, + search_peek: false, + search_mixed: false, + } + } +} + +pub trait Bot: Send { + /// Registry name; also the label in result tables. + fn name(&self) -> &'static str; + + /// Modes this bot can pilot. Tables skip it for the rest. + fn modes(&self) -> &'static [BattleMode] { + &[BattleMode::Singles] + } + + /// Clear per-game state. Called once before each game. + fn reset(&mut self) {} + + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action; +} diff --git a/transpiler/strategies-rs/src/bots.rs b/transpiler/strategies-rs/src/bots.rs new file mode 100644 index 00000000..9e05bcb4 --- /dev/null +++ b/transpiler/strategies-rs/src/bots.rs @@ -0,0 +1,403 @@ +//! The built-in bots and the registry that names them. +//! +//! Each one wraps a decision module; the modules keep the logic, the bots own +//! the per-game state. Adding an entrant means adding a struct here and one +//! line to [`build`]. + +use crate::bot::{Action, BattleMode, Bot, BotConfig, DecisionCtx}; +use crate::evaluator::Weights; +use crate::greedy; +use crate::heuristic::{self, HeuristicState}; +use crate::nopeek; +use crate::override_cpu::{self, OverrideState}; +use crate::search; +use crate::doubles::{self, Difficulty, DoublesEvalW, SlotMove}; +use crate::view::{calculate_valid_moves, pick_uniform, Mv, NO_OP_INDEX}; + +pub const RANDOM: &str = "random"; +pub const GREEDY: &str = "greedy"; +pub const HEURISTIC: &str = "heuristic"; +pub const OVERRIDE: &str = "override"; +pub const NOPEEK: &str = "nopeek"; +pub const NOPEEK_WC: &str = "nopeek-wc"; +pub const SEARCH: &str = "search"; +pub const EXAMPLE: &str = "example"; +/// Search bots at a pinned depth. Depth is the single biggest lever a bot has, +/// so the ladder names it rather than hiding it in config. +pub const SEARCH_D1: &str = "search-d1"; +pub const SEARCH_D2: &str = "search-d2"; +pub const DOUBLES_SEARCH_D1: &str = "doubles-search-d1"; +pub const DOUBLES_SEARCH_D2: &str = "doubles-search-d2"; +pub const DOUBLES_EASY: &str = "doubles-easy"; +pub const DOUBLES_MEDIUM: &str = "doubles-medium"; +pub const DOUBLES_HARD: &str = "doubles-hard"; +pub const DOUBLES_SEARCH: &str = "doubles-search"; + +/// Every registered bot, in ladder order (weakest first). +pub const NAMES: &[&str] = + &[RANDOM, EXAMPLE, GREEDY, HEURISTIC, OVERRIDE, NOPEEK, NOPEEK_WC, SEARCH, SEARCH_D1, SEARCH_D2]; + +/// Registered doubles bots, weakest first. +pub const DOUBLES_NAMES: &[&str] = &[ + DOUBLES_EASY, + DOUBLES_MEDIUM, + DOUBLES_HARD, + DOUBLES_SEARCH, + DOUBLES_SEARCH_D1, + DOUBLES_SEARCH_D2, +]; + +const DOUBLES_ONLY: &[BattleMode] = &[BattleMode::Doubles]; + +fn slot_to_mv(m: SlotMove) -> Mv { + Mv { move_index: m.move_index, extra_data: m.extra_data } +} + +/// Epsilon-greedy per slot; the difficulty is how often it takes the best option. +pub struct DoublesGreedyBot { + name: &'static str, + difficulty: Difficulty, +} + +impl Bot for DoublesGreedyBot { + fn name(&self) -> &'static str { + self.name + } + fn modes(&self) -> &'static [BattleMode] { + DOUBLES_ONLY + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let bk = ctx.sim.battle_key; + let (a, b) = doubles::pick_side_moves(ctx.sim, bk, ctx.seat.cpu, self.difficulty, ctx.rng); + Action::Slots([slot_to_mv(a), slot_to_mv(b)]) + } +} + +/// Joint maximin search over both of the side's slots. +pub struct DoublesSearchBot { + depth: u32, + eval: DoublesEvalW, +} + +impl Bot for DoublesSearchBot { + fn name(&self) -> &'static str { + match self.depth { + 1 => DOUBLES_SEARCH_D1, + 2 => DOUBLES_SEARCH_D2, + _ => DOUBLES_SEARCH, + } + } + fn modes(&self) -> &'static [BattleMode] { + DOUBLES_ONLY + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let bk = ctx.sim.battle_key; + let (a, b) = doubles::search_side_moves(ctx.sim, bk, ctx.seat.cpu, self.depth, &self.eval); + Action::Slots([slot_to_mv(a), slot_to_mv(b)]) + } +} + +/// The move a bot with nothing legal to do submits. +fn no_op() -> Mv { + Mv { move_index: NO_OP_INDEX, extra_data: 0 } +} + +/// Uniform over the legal actions — the floor a real entrant must clear. +pub struct RandomBot; + +impl Bot for RandomBot { + fn name(&self) -> &'static str { + RANDOM + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let valid = calculate_valid_moves(ctx.sim, ctx.seat, ctx.singles_view().bk, ctx.rng); + let all: Vec = valid + .moves + .iter() + .chain(valid.switches.iter()) + .chain(valid.no_op.iter()) + .copied() + .collect(); + let pick = pick_uniform(all.len(), ctx.rng).map(|i| all[i]).unwrap_or_else(no_op); + Action::Single(pick) + } +} + +/// 1-ply best response on the forward model + linear evaluator. +pub struct GreedyBot { + weights: Weights, +} + +impl Bot for GreedyBot { + fn name(&self) -> &'static str { + GREEDY + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let pm = ctx.peek.unwrap_or_else(|| Mv { move_index: 0, extra_data: 0 }); + Action::Single(greedy::decide(ctx.sim, ctx.seat, ctx.singles_view(), pm, ctx.rng, &self.weights)) + } +} + +/// The on-chain heuristic ladder. +pub struct HeuristicBot { + weights: Weights, + state: HeuristicState, +} + +impl Bot for HeuristicBot { + fn name(&self) -> &'static str { + HEURISTIC + } + fn reset(&mut self) { + self.state = HeuristicState::default(); + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let pm = ctx.peek.unwrap_or_else(|| Mv { move_index: 0, extra_data: 0 }); + Action::Single(heuristic::decide( + ctx.sim, + ctx.seat, + ctx.singles_view(), + pm, + ctx.rng, + &mut self.state, + &self.weights, + )) + } +} + +/// Scripted per-mon overrides, falling back to the heuristic. +pub struct OverrideBot { + weights: Weights, + state: OverrideState, +} + +impl Bot for OverrideBot { + fn name(&self) -> &'static str { + OVERRIDE + } + fn reset(&mut self) { + self.state = OverrideState::default(); + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let pm = ctx.peek.unwrap_or_else(|| Mv { move_index: 0, extra_data: 0 }); + Action::Single(override_cpu::decide( + ctx.sim, + ctx.seat, + ctx.singles_view(), + pm, + ctx.rng, + &mut self.state, + &self.weights, + )) + } +} + +/// Greedy that never reads the reveal, aggregating over the opponent's replies. +pub struct NoPeekBot { + weights: Weights, + worst_case: bool, +} + +impl Bot for NoPeekBot { + fn name(&self) -> &'static str { + if self.worst_case { + NOPEEK_WC + } else { + NOPEEK + } + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let mv = if self.worst_case { + nopeek::decide_worst(ctx.sim, ctx.seat, ctx.singles_view(), ctx.rng, &self.weights) + } else { + nopeek::decide_expect(ctx.sim, ctx.seat, ctx.singles_view(), ctx.rng, &self.weights) + }; + Action::Single(mv) + } +} + +/// Simultaneous-move maximin search over the evaluator. +pub struct SearchBot { + weights: Weights, + depth: u32, + peek: bool, + mixed: bool, +} + +impl Bot for SearchBot { + fn name(&self) -> &'static str { + match self.depth { + 1 => SEARCH_D1, + 2 => SEARCH_D2, + _ => SEARCH, + } + } + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let pm = ctx.peek.unwrap_or_else(|| Mv { move_index: 0, extra_data: 0 }); + Action::Single(search::decide( + ctx.sim, + ctx.seat, + ctx.singles_view(), + pm, + &self.weights, + self.depth, + self.peek, + self.mixed, + ctx.rng, + )) + } +} + +/// Build a registered bot, or None if the name is unknown. +/// +/// A configured search depth outranks the name: the seat searches over that +/// bot's evaluator instead of playing it. That is the pre-trait behaviour and +/// the arena's weight/depth sweeps depend on it. +pub fn build(name: &str, cfg: BotConfig) -> Option> { + let pinned = matches!(name, SEARCH_D1 | SEARCH_D2 | DOUBLES_SEARCH_D1 | DOUBLES_SEARCH_D2); + if cfg.search_depth >= 1 && name != RANDOM && !pinned && !DOUBLES_NAMES.contains(&name) { + return Some(Box::new(SearchBot { + weights: cfg.weights, + depth: cfg.search_depth, + peek: cfg.search_peek, + mixed: cfg.search_mixed, + })); + } + let w = cfg.weights; + match name { + RANDOM => Some(Box::new(RandomBot)), + EXAMPLE => Some(Box::new(crate::example_bot::ExampleBot::new(w))), + GREEDY => Some(Box::new(GreedyBot { weights: w })), + HEURISTIC => Some(Box::new(HeuristicBot { weights: w, state: HeuristicState::default() })), + OVERRIDE => Some(Box::new(OverrideBot { weights: w, state: OverrideState::default() })), + NOPEEK => Some(Box::new(NoPeekBot { weights: w, worst_case: false })), + NOPEEK_WC => Some(Box::new(NoPeekBot { weights: w, worst_case: true })), + SEARCH_D1 | SEARCH_D2 => Some(Box::new(SearchBot { + weights: w, + depth: if name == SEARCH_D1 { 1 } else { 2 }, + peek: cfg.search_peek, + mixed: cfg.search_mixed, + })), + DOUBLES_SEARCH_D1 | DOUBLES_SEARCH_D2 => Some(Box::new(DoublesSearchBot { + depth: if name == DOUBLES_SEARCH_D1 { 1 } else { 2 }, + eval: cfg.doubles_eval, + })), + SEARCH => Some(Box::new(SearchBot { + weights: w, + depth: cfg.search_depth.max(1), + peek: cfg.search_peek, + mixed: cfg.search_mixed, + })), + DOUBLES_EASY => Some(Box::new(DoublesGreedyBot { name: DOUBLES_EASY, difficulty: Difficulty::Easy })), + DOUBLES_MEDIUM => { + Some(Box::new(DoublesGreedyBot { name: DOUBLES_MEDIUM, difficulty: Difficulty::Medium })) + } + DOUBLES_HARD => Some(Box::new(DoublesGreedyBot { name: DOUBLES_HARD, difficulty: Difficulty::Hard })), + DOUBLES_SEARCH => Some(Box::new(DoublesSearchBot { + depth: cfg.search_depth.max(1), + eval: cfg.doubles_eval, + })), + _ => None, + } +} + +/// Resolve a name (e.g. from a CLI flag) to its registry entry. +pub fn parse(name: &str) -> Option<&'static str> { + NAMES.iter().chain(DOUBLES_NAMES.iter()).copied().find(|&n| n == name) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::arena::build_team_mon; + use crate::game::{play_game, GameSpec}; + use crate::roster::{address_book, load_roster}; + use std::path::PathBuf; + + /// Every registered bot can pilot a full game. This is the smoke test an + /// entrant runs first: if a new bot is in `NAMES`, it plays here. + #[test] + fn every_registered_bot_plays() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join(".."); + let root = std::env::var("CHOMP_ROOT").map(PathBuf::from).unwrap_or(root); + let roster = load_roster(&root); + let book = address_book(); + let ids: Vec = roster.mons.iter().take(4).map(|m| m.id).collect(); + let team: Vec<_> = roster.mons.iter().take(4).map(build_team_mon).collect(); + + for &name in NAMES { + assert!(parse(name).is_some(), "{name} missing from the registry"); + let spec = GameSpec { + seed: 7, + max_turns: 60, + mons_per_team: 4, + p0_team: team.clone(), + p1_team: team.clone(), + p0_ids: ids.clone(), + p1_ids: ids.clone(), + p0_strategy: name, + p1_strategy: GREEDY, + peek_seat: None, + p0_weights: crate::evaluator::DEFAULT_WEIGHTS, + p1_weights: crate::evaluator::DEFAULT_WEIGHTS, + p0_search_depth: 0, + p1_search_depth: 0, + p0_search_peek: false, + p1_search_peek: false, + p0_search_mixed: false, + p1_search_mixed: false, + }; + let out = play_game(&spec, &book, false); + assert!(out.turns > 0, "{name} produced no turns"); + } + } + + /// Every registered doubles bot can pilot a full doubles game. + #[test] + fn every_registered_doubles_bot_plays() { + use crate::doubles::{play_doubles_game, DoublesEvalW, DoublesSpec, Difficulty}; + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join(".."); + let root = std::env::var("CHOMP_ROOT").map(PathBuf::from).unwrap_or(root); + let roster = load_roster(&root); + let book = address_book(); + let ids: Vec = roster.mons.iter().take(4).map(|m| m.id).collect(); + let team: Vec<_> = roster.mons.iter().take(4).map(build_team_mon).collect(); + + for &name in DOUBLES_NAMES { + assert!(parse(name).is_some(), "{name} missing from the registry"); + let spec = DoublesSpec { + seed: 11, + max_turns: 60, + mons_per_team: 4, + p0_team: team.clone(), + p1_team: team.clone(), + p0_ids: ids.clone(), + p1_ids: ids.clone(), + p0_difficulty: Difficulty::Hard, + p1_difficulty: Difficulty::Hard, + p0_bot: name, + p1_bot: DOUBLES_HARD, + p0_search_depth: 0, + p1_search_depth: 0, + p0_eval: DoublesEvalW::default(), + p1_eval: DoublesEvalW::default(), + }; + let out = play_doubles_game(&spec, &book); + assert!(out.turns > 0, "{name} produced no turns"); + } + } + + /// Salts depend on the seed alone, so swapping one seat's bot cannot change + /// the battle the other seat faces. This is what makes entrants comparable. + #[test] + fn seat_streams_are_independent() { + use crate::jsrng::{derive, random_salt, STREAM_SALT}; + for seed in [1u32, 42, 0xbeef] { + let mut a = derive(seed, STREAM_SALT); + let mut b = derive(seed, STREAM_SALT); + for _ in 0..8 { + assert_eq!(random_salt(&mut a), random_salt(&mut b)); + } + } + } +} diff --git a/transpiler/strategies-rs/src/breadth.rs b/transpiler/strategies-rs/src/breadth.rs index 14a4cd74..90c6f0d6 100644 --- a/transpiler/strategies-rs/src/breadth.rs +++ b/transpiler/strategies-rs/src/breadth.rs @@ -7,7 +7,8 @@ use std::collections::HashMap; use crate::arena::build_specs_with; -use crate::game::{run_games_breadth, StrategyKind}; +use crate::bots; +use crate::game::{run_games_breadth, BotName}; use crate::roster::{self, Roster}; /// Top two within this many eval-score units = "within noise". @@ -52,7 +53,7 @@ pub struct BreadthAnalysis { pub fn run_breadth_analysis(roster: &Roster, games: usize, wseed: u32, seed_base: u32, threads: usize) -> BreadthAnalysis { let book = roster::address_book(); - let pairs = [(StrategyKind::Greedy, StrategyKind::Greedy)]; + let pairs = [(bots::GREEDY, bots::GREEDY)]; let (specs, _) = build_specs_with(roster, games, wseed, seed_base, &pairs); let results = run_games_breadth(&specs, &book, threads); diff --git a/transpiler/strategies-rs/src/doubles.rs b/transpiler/strategies-rs/src/doubles.rs index d20ed2c5..b052710e 100644 --- a/transpiler/strategies-rs/src/doubles.rs +++ b/transpiler/strategies-rs/src/doubles.rs @@ -20,11 +20,15 @@ use chomp_engine::Engine; use chomp_engine::Structs::{Mon, MoveMeta}; use chomp_rt::{Address, B256, U256}; -use crate::jsrng::{random_salt, JsRng}; +use crate::bot::{Action, BattleMode, Bot, BotConfig, DecisionCtx}; +use crate::bots; +use crate::jsrng::{derive, random_salt, JsRng, STREAM_P0, STREAM_P1, STREAM_SALT}; use crate::roster::{input_type_of, target_spec_of, InputType, TargetSpec}; use crate::shared::{build_damage_calc_context, estimate_damage_meta}; use crate::sim::{pack_side, Sim}; -use crate::view::{decode_meta, mon_current_stamina, mon_max_hp, mon_state, move_slot, turn_id, Seat}; +use crate::view::{ + decode_meta, mon_current_stamina, mon_max_hp, mon_skip_turn, mon_state, move_slot, stat_delta_score, turn_id, Seat, +}; const SWITCH: u8 = 125; const NO_OP: u8 = 126; @@ -155,15 +159,56 @@ fn damaging_options(sim: &mut Sim, bk: B256, cpu_side: u8, my_mon: u32) -> Vec<( if meta.moveClass != MoveClass::Physical && meta.moveClass != MoveClass::Special { continue; // only weigh damaging moves } - let dmg = estimate_damage_meta(&mut ctx, meta); - if dmg > 0 { - options.push((SlotMove { move_index: *mi, extra_data: target_bits(t_abs) }, dmg)); - } + let mv = SlotMove { move_index: *mi, extra_data: target_bits(t_abs) }; + // A hand-written getMeta() may quote 0 for a move that does deal damage, so 0 means + // "unknown", not "harmless" — dropping it here empties the option set and leaves the slot + // resting all game. Simulate instead of trusting a declared number: nominal power is not + // damage-this-turn for delayed (Q5), multi-hit (Bubble Bop), or spent (Sneak Attack) moves. + let dmg = if meta.basePower != 0 { + estimate_damage_meta(&mut ctx, meta) + } else { + probe_damage(sim, bk, cpu_side, my_mon, mv, opp_side, t_mon) + }; + options.push((mv, dmg)); } } options } +/// Exact damage for one (move, target), by simulating it: fork a turn where only `my_mon`'s slot +/// acts and read how much HP the target actually lost. Used for moves whose power the static quote +/// can't know; costs one fork, so it runs only on moves whose `getMeta()` quotes no power. +fn probe_damage( + sim: &mut Sim, + bk: B256, + cpu_side: u8, + my_mon: u32, + mv: SlotMove, + opp_side: u8, + t_mon: usize, +) -> i64 { + let slots = active_slots(sim, bk); + let [a0, a1] = side_slots(cpu_side); + // Place the probed action on whichever of my slots holds the acting mon; everything else rests, + // so the observed HP change is attributable to this move alone. + let (m0, m1) = if slots[a0] == my_mon { + (mv, NO_OP_MOVE) + } else if slots[a1] == my_mon { + (NO_OP_MOVE, mv) + } else { + return 0; // not an active slot this turn + }; + let mine = pack_side(m0.move_index, m0.extra_data, m1.move_index, m1.extra_data, 0); + let theirs = pack_side(NO_OP, 0, NO_OP, 0, 0); + let (side0, side1) = if cpu_side == 0 { (mine, theirs) } else { (theirs, mine) }; + + let before = mon_state(sim, OBS, bk, opp_side, t_mon, MonStateIndexName::Hp); + let fork = sim.apply_hypothetical_slot(bk, side0, side1); + let after = mon_state(sim, OBS, fork, opp_side, t_mon, MonStateIndexName::Hp); + sim.dispose_fork(fork); + (before - after).max(0) // hp deltas run negative; damage is how much further it fell +} + /// Greedy attack for one acting mon: the affordable damaging (move, target-slot) with the highest /// estimated damage, softened by `difficulty` (epsilon-greedy). None when nothing damaging is /// affordable (caller rests). @@ -210,7 +255,7 @@ pub fn pick_side_moves( let is_forced = flag != 2; // 2 == both slots take a normal action let mask = flag & 0x0f; // which absolute slots must forced-switch let slots = active_slots(sim, bk); - let team_size = u64::try_from(Engine::getTeamSize(&mut sim.world, bk, U256::from(cpu_side))).unwrap_or(0) as usize; + let team_size = sim.team_size_phys(U256::from(cpu_side)); let m0 = decide_slot(sim, bk, cpu_side, difficulty, is_forced, mask, &slots, team_size, a0, rng); let m1 = decide_slot(sim, bk, cpu_side, difficulty, is_forced, mask, &slots, team_size, a1, rng); @@ -265,8 +310,40 @@ const D_W_HP: f64 = 1.0; const D_W_KO: f64 = 150.0; const D_W_STAMINA: f64 = 2.0; -fn team_size_of(sim: &mut Sim, bk: B256, side: u8) -> usize { - u64::try_from(Engine::getTeamSize(&mut sim.world, bk, U256::from(side))).unwrap_or(0) as usize +/// Doubles eval weights. Defaults reproduce the frozen baseline (hp/ko/stamina, corpses counted), +/// so every existing caller is unchanged; the extra terms exist to be A/B'd in the arena. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct DoublesEvalW { + pub w_hp: f64, + pub w_ko: f64, + pub w_stamina: f64, + /// Per Σ(delta/base) unit over a live active's five combat stats (the TS twin's 0.4-per-% ≡ 40 here). + pub w_boost: f64, + /// Per live opposing active carrying a status class, minus ours (statuses hurt their carrier). + pub w_status: f64, + /// Per live opposing active flagged ShouldSkipTurn, minus ours. + pub w_skip: f64, + /// Exclude KO'd actives from the per-active terms — a corpse holds its lane (and stat deltas) + /// until the forced switch, but its stamina/boosts/status are worth nothing. + pub gate_ko: bool, +} + +impl Default for DoublesEvalW { + fn default() -> Self { + Self { + w_hp: D_W_HP, + w_ko: D_W_KO, + w_stamina: D_W_STAMINA, + w_boost: 0.0, + w_status: 0.0, + w_skip: 0.0, + gate_ko: false, + } + } +} + +fn team_size_of(sim: &mut Sim, _bk: B256, side: u8) -> usize { + sim.team_size_phys(U256::from(side)) } /// Σ hp% over a side's whole roster. @@ -283,28 +360,49 @@ fn side_roster_hp(sim: &mut Sim, bk: B256, side: u8, team_size: usize) -> f64 { sum } -/// Σ current stamina over a side's two active slots. -fn side_active_stamina(sim: &mut Sim, bk: B256, side: u8, slots: &[u32; 4]) -> i64 { - let mut sum = 0; +/// Per-active term sums (stamina, boost, status, skip) over a side's two active slots. Feature +/// reads are skipped while their weight is 0 so the default eval costs what it always did. +fn side_active_terms(sim: &mut Sim, bk: B256, side: u8, slots: &[u32; 4], w: &DoublesEvalW, ko: u32) -> (f64, f64, f64, f64) { + let (mut stam, mut boost, mut status, mut skip) = (0.0f64, 0.0f64, 0.0f64, 0.0f64); for &abs in &side_slots(side) { let mon = slots[abs]; - if mon != EMPTY_LANE { - sum += mon_current_stamina(sim, OBS, bk, side, mon as usize); + if mon == EMPTY_LANE || (w.gate_ko && ko & (1 << mon) != 0) { + continue; + } + stam += mon_current_stamina(sim, OBS, bk, side, mon as usize) as f64; + if w.w_boost != 0.0 { + boost += stat_delta_score(sim, OBS, bk, side, mon as usize); + } + if w.w_status != 0.0 + && Engine::getMonStatusClass(&mut sim.world, bk, U256::from(side), U256::from(mon)) != U256::from(0u64) + { + status += 1.0; + } + if w.w_skip != 0.0 && mon_skip_turn(sim, OBS, bk, side, mon as usize) { + skip += 1.0; } } - sum + (stam, boost, status, skip) } /// Linear 2-slot position value, `cpu_side`-perspective (higher = better). -fn doubles_eval(sim: &mut Sim, bk: B256, cpu_side: u8) -> f64 { +fn doubles_eval(sim: &mut Sim, bk: B256, cpu_side: u8, w: &DoublesEvalW) -> f64 { let opp_side = 1 - cpu_side; let cpu_ts = team_size_of(sim, bk, cpu_side); let opp_ts = team_size_of(sim, bk, opp_side); let hp = side_roster_hp(sim, bk, cpu_side, cpu_ts) - side_roster_hp(sim, bk, opp_side, opp_ts); - let ko = (ko_bitmap(sim, bk, opp_side).count_ones() as i64 - ko_bitmap(sim, bk, cpu_side).count_ones() as i64) as f64; + let my_ko = ko_bitmap(sim, bk, cpu_side); + let op_ko = ko_bitmap(sim, bk, opp_side); + let ko = (op_ko.count_ones() as i64 - my_ko.count_ones() as i64) as f64; let slots = active_slots(sim, bk); - let stam = (side_active_stamina(sim, bk, cpu_side, &slots) - side_active_stamina(sim, bk, opp_side, &slots)) as f64; - D_W_HP * hp + D_W_KO * ko + D_W_STAMINA * stam + let (my_stam, my_boost, my_status, my_skip) = side_active_terms(sim, bk, cpu_side, &slots, w, my_ko); + let (op_stam, op_boost, op_status, op_skip) = side_active_terms(sim, bk, opp_side, &slots, w, op_ko); + w.w_hp * hp + + w.w_ko * ko + + w.w_stamina * (my_stam - op_stam) + + w.w_boost * (my_boost - op_boost) + + w.w_status * (op_status - my_status) + + w.w_skip * (op_skip - my_skip) } /// Terminal value if a side is fully KO'd, else None. Mate-distance discounted: @@ -442,12 +540,12 @@ fn forced_joint_model(sim: &mut Sim, bk: B256, side: u8, mask: u32, slots: &[u32 /// Recursive maximin value at `bk`, cpu_side-perspective. Forced half-turns are resolved /// deterministically and recursed WITHOUT consuming depth (they don't burn horizon; the chain is /// bounded by roster size / the terminal check). -fn search_value(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> f64 { +fn search_value(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32, w: &DoublesEvalW) -> f64 { if let Some(v) = terminal(sim, bk, cpu_side, depth) { return v; } if depth == 0 { - return doubles_eval(sim, bk, cpu_side); + return doubles_eval(sim, bk, cpu_side, w); } let flag = Engine::getBattleContext(&mut sim.world, bk).playerSwitchForTurnFlag as u32; if flag != 2 { @@ -456,10 +554,10 @@ fn search_value(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> f64 { let mine = forced_joint_model(sim, bk, cpu_side, mask, &slots); let theirs = forced_joint_model(sim, bk, 1 - cpu_side, mask, &slots); if mine == (NO_OP_MOVE, NO_OP_MOVE) && theirs == (NO_OP_MOVE, NO_OP_MOVE) { - return doubles_eval(sim, bk, cpu_side); // no legal resolution — don't loop + return doubles_eval(sim, bk, cpu_side, w); // no legal resolution — don't loop } let child = fork_joint(sim, bk, cpu_side, mine, theirs); - let v = search_value(sim, child, cpu_side, depth); + let v = search_value(sim, child, cpu_side, depth, w); sim.dispose_fork(child); return v; } @@ -471,7 +569,7 @@ fn search_value(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> f64 { let mut worst = f64::INFINITY; for &theirs in &opp { let child = fork_joint(sim, bk, cpu_side, mine, theirs); - let v = search_value(sim, child, cpu_side, depth - 1); + let v = search_value(sim, child, cpu_side, depth - 1, w); sim.dispose_fork(child); if v < worst { worst = v; @@ -490,9 +588,9 @@ fn search_value(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> f64 { /// Pick `cpu_side`'s two slot moves by depth-`depth` joint maximin (turn 0 → leads; forced-switch → /// first-legal bench; normal turn → search). A reverting hypothetical mid-search is contained to /// this decision (fall back to resting both slots) rather than aborting the game. -pub fn search_side_moves(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> (SlotMove, SlotMove) { +pub fn search_side_moves(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32, w: &DoublesEvalW) -> (SlotMove, SlotMove) { let saved_fc = sim.fork_counter(); - match catch_unwind(AssertUnwindSafe(|| search_side_moves_inner(sim, bk, cpu_side, depth))) { + match catch_unwind(AssertUnwindSafe(|| search_side_moves_inner(sim, bk, cpu_side, depth, w))) { Ok(m) => m, Err(_) => { sim.set_fork_counter(saved_fc); @@ -501,7 +599,7 @@ pub fn search_side_moves(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> ( } } -fn search_side_moves_inner(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> (SlotMove, SlotMove) { +fn search_side_moves_inner(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32, w: &DoublesEvalW) -> (SlotMove, SlotMove) { let [a0, a1] = side_slots(cpu_side); let sw = |i: u16| SlotMove { move_index: SWITCH, extra_data: i }; @@ -526,7 +624,7 @@ fn search_side_moves_inner(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> let mut worst = f64::INFINITY; for &theirs in &opp { let child = fork_joint(sim, bk, cpu_side, mine, theirs); - let v = search_value(sim, child, cpu_side, depth.saturating_sub(1)); + let v = search_value(sim, child, cpu_side, depth.saturating_sub(1), w); sim.dispose_fork(child); if v < worst { worst = v; @@ -577,7 +675,7 @@ fn search_side_moves_inner(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> let mut best_val = f64::NEG_INFINITY; for &mine in &combos { let child = fork_joint(sim, bk, cpu_side, mine, theirs); - let v = search_value(sim, child, cpu_side, depth); + let v = search_value(sim, child, cpu_side, depth, w); sim.dispose_fork(child); if v > best_val { best_val = v; @@ -597,7 +695,7 @@ fn search_side_moves_inner(sim: &mut Sim, bk: B256, cpu_side: u8, depth: u32) -> let mut worst = f64::INFINITY; for &theirs in &opp { let child = fork_joint(sim, bk, cpu_side, mine, theirs); - let v = search_value(sim, child, cpu_side, depth - 1); + let v = search_value(sim, child, cpu_side, depth - 1, w); sim.dispose_fork(child); if v < worst { worst = v; @@ -626,9 +724,16 @@ pub struct DoublesSpec { pub p1_ids: Vec, pub p0_difficulty: Difficulty, pub p1_difficulty: Difficulty, + /// Registered bot per side. Empty falls back to the difficulty/search + /// fields above, which is how the existing sweeps configure a side. + pub p0_bot: &'static str, + pub p1_bot: &'static str, /// Per-side search depth: 0 = epsilon-greedy at the difficulty; ≥1 = joint maximin search. pub p0_search_depth: u32, pub p1_search_depth: u32, + /// Per-side eval weights for the search tiers (ignored at depth 0). + pub p0_eval: DoublesEvalW, + pub p1_eval: DoublesEvalW, } pub struct DoublesOutcome { @@ -637,10 +742,57 @@ pub struct DoublesOutcome { pub turns: u32, } +/// The registered bot a side plays, or the one its difficulty/search config names. +fn doubles_bot(spec: &DoublesSpec, side: usize) -> Box { + let (name, depth, diff, eval) = if side == 0 { + (spec.p0_bot, spec.p0_search_depth, spec.p0_difficulty, spec.p0_eval) + } else { + (spec.p1_bot, spec.p1_search_depth, spec.p1_difficulty, spec.p1_eval) + }; + let name = if !name.is_empty() { + name + } else if depth > 0 { + bots::DOUBLES_SEARCH + } else { + match diff { + Difficulty::Easy => bots::DOUBLES_EASY, + Difficulty::Medium => bots::DOUBLES_MEDIUM, + Difficulty::Hard => bots::DOUBLES_HARD, + } + }; + let cfg = BotConfig { search_depth: depth, doubles_eval: eval, ..BotConfig::default() }; + bots::build(name, cfg).unwrap_or_else(|| panic!("unknown doubles bot {name:?}")) +} + +/// One side's two slot moves for this turn. +fn decide_side(sim: &mut Sim, bot: &mut dyn Bot, side: u8, rng: &mut JsRng) -> [SlotMove; 2] { + let mut ctx = DecisionCtx { + sim, + seat: Seat { cpu: side }, + mode: BattleMode::Doubles, + view: None, + peek: None, + rng, + }; + match bot.decide(&mut ctx) { + Action::Slots([a, b]) => [ + SlotMove { move_index: a.move_index, extra_data: a.extra_data }, + SlotMove { move_index: b.move_index, extra_data: b.extra_data }, + ], + Action::Single(a) => { + [SlotMove { move_index: a.move_index, extra_data: a.extra_data }, NO_OP_MOVE] + } + } +} + /// Play one doubles game: each turn both sides pick their two slot moves (greedy CPU at the spec's /// difficulty), the moves are packed per side, and the turn executes via `execute_slot_turn`. pub fn play_doubles_game(spec: &DoublesSpec, book: &HashMap) -> DoublesOutcome { - let mut rng = JsRng::new(spec.seed); + let (mut rng0, mut rng1, mut salt_rng) = ( + derive(spec.seed, STREAM_P0), + derive(spec.seed, STREAM_P1), + derive(spec.seed, STREAM_SALT), + ); let mut sim = Sim::new_doubles( spec.mons_per_team, spec.p0_team.clone(), @@ -649,25 +801,17 @@ pub fn play_doubles_game(spec: &DoublesSpec, book: &HashMap) -> spec.p1_ids.clone(), book, ); + let mut bots_pair = [doubles_bot(spec, 0), doubles_bot(spec, 1)]; for t in 0..spec.max_turns { let w = sim.winner_index(); if w != 2 { return DoublesOutcome { winner_side: Some(w), turns: t }; } - let bk = sim.battle_key; - let (p0a, p0b) = if spec.p0_search_depth > 0 { - search_side_moves(&mut sim, bk, 0, spec.p0_search_depth) - } else { - pick_side_moves(&mut sim, bk, 0, spec.p0_difficulty, &mut rng) - }; - let (p1a, p1b) = if spec.p1_search_depth > 0 { - search_side_moves(&mut sim, bk, 1, spec.p1_search_depth) - } else { - pick_side_moves(&mut sim, bk, 1, spec.p1_difficulty, &mut rng) - }; - let salt0 = random_salt(&mut rng); - let salt1 = random_salt(&mut rng); + let [p0a, p0b] = decide_side(&mut sim, &mut *bots_pair[0], 0, &mut rng0); + let [p1a, p1b] = decide_side(&mut sim, &mut *bots_pair[1], 1, &mut rng1); + let salt0 = random_salt(&mut salt_rng); + let salt1 = random_salt(&mut salt_rng); let side0 = pack_side(p0a.move_index, p0a.extra_data, p0b.move_index, p0b.extra_data, salt0); let side1 = pack_side(p1a.move_index, p1a.extra_data, p1b.move_index, p1b.extra_data, salt1); sim.execute_slot_turn(side0, side1); @@ -711,3 +855,414 @@ pub fn run_doubles_games( }); slots.into_inner().unwrap().into_iter().map(|r| r.expect("slot filled")).collect() } + +// ── Diagnostic: per-turn trace of one mon's doubles game ──────────────────── + +/// What a tracked mon did / had on one doubles turn. +pub struct MonTurnTrace { + pub turn: u32, + pub active: bool, + pub move_index: u8, + pub hp_pct: f64, + pub stamina: i64, + /// Attack stat delta — non-zero once Loop's boost lands (the setup payoff). + pub atk_delta: i64, +} + +/// A KO credited to one attacker. Doubles can't reuse singles' "opposing active" proxy — there are +/// two of them — so credit comes from the target nibble the killer's side actually aimed. +pub struct DoublesKoEvent { + pub turn: u32, + pub killer_seat: u8, + pub killer_id: u32, + pub victim_id: u32, +} + +pub struct DoublesInstr { + pub winner_side: Option, + pub turns: u32, + pub p0_ids: Vec, + pub p1_ids: Vec, + /// Active-turn count per team slot (parallel to p0_ids / p1_ids). + pub active_turns_p0: Vec, + pub active_turns_p1: Vec, + pub kos: Vec, + /// KOs both opposing slots aimed at — a genuine co-kill, so no single attacker earns the credit. + pub kos_shared: u32, + /// KOs no opposing move aimed at (status, recoil, ally damage). + pub kos_incidental: u32, + /// Per-turn rows for the `track`ed mon, if any. + pub rows: Vec, + pub tracked_active_turns: u32, +} + +/// Was `mv` a real attack aimed at absolute slot `abs`? Switch / rest carry no target nibble. +fn aimed_at(mv: SlotMove, abs: usize) -> bool { + mv.move_index != SWITCH && mv.move_index != NO_OP && (mv.extra_data >> 12) & (1u16 << abs) != 0 +} + +/// Counterfactual: replayed from `snap` with only `mv` on `atk_side`'s slot `atk_i` and everything +/// else resting, does `victim` still go down? Lets a KO both opposing slots aimed at be credited to +/// whichever one was actually lethal — usually only one is, the other being redundant overkill. +/// +/// The real turn's salts are reused so accuracy/crit land as close to the observed turn as a +/// one-sided replay can; it is still a counterfactual, not a replay of what happened. +fn would_ko_alone( + sim: &mut Sim, + snap: B256, + atk_side: u8, + atk_i: usize, + mv: SlotMove, + victim_side: u8, + victim_mon: usize, + salts: (u128, u128), +) -> bool { + let (m0, m1) = if atk_i == 0 { (mv, NO_OP_MOVE) } else { (NO_OP_MOVE, mv) }; + let mine = pack_side(m0.move_index, m0.extra_data, m1.move_index, m1.extra_data, if atk_side == 0 { salts.0 } else { salts.1 }); + let theirs = pack_side(NO_OP, 0, NO_OP, 0, if atk_side == 0 { salts.1 } else { salts.0 }); + let (side0, side1) = if atk_side == 0 { (mine, theirs) } else { (theirs, mine) }; + let fork = sim.apply_hypothetical_slot(snap, side0, side1); + let down = ko_bitmap(sim, fork, victim_side) & (1u32 << victim_mon) != 0; + sim.dispose_fork(fork); + down +} + +/// Replay `spec` recording per-mon active turns and attributed KOs, plus optional per-turn rows for +/// one tracked `(side, mon)`. Mirrors `play_doubles_game`; the hot path stays uninstrumented. +pub fn play_doubles_game_instrumented( + spec: &DoublesSpec, + book: &HashMap, + track: Option<(u8, usize)>, +) -> DoublesInstr { + let (mut rng0, mut rng1, mut salt_rng) = ( + derive(spec.seed, STREAM_P0), + derive(spec.seed, STREAM_P1), + derive(spec.seed, STREAM_SALT), + ); + let mut sim = Sim::new_doubles( + spec.mons_per_team, + spec.p0_team.clone(), + spec.p1_team.clone(), + spec.p0_ids.clone(), + spec.p1_ids.clone(), + book, + ); + let mut out = DoublesInstr { + winner_side: None, + turns: spec.max_turns, + p0_ids: spec.p0_ids.clone(), + p1_ids: spec.p1_ids.clone(), + active_turns_p0: vec![0; spec.p0_ids.len()], + active_turns_p1: vec![0; spec.p1_ids.len()], + kos: Vec::new(), + kos_shared: 0, + kos_incidental: 0, + rows: Vec::new(), + tracked_active_turns: 0, + }; + + for t in 0..spec.max_turns { + let w = sim.winner_index(); + if w != 2 { + out.winner_side = Some(w); + out.turns = t; + return out; + } + let bk = sim.battle_key; + let (p0a, p0b) = if spec.p0_search_depth > 0 { + search_side_moves(&mut sim, bk, 0, spec.p0_search_depth, &spec.p0_eval) + } else { + pick_side_moves(&mut sim, bk, 0, spec.p0_difficulty, &mut rng0) + }; + let (p1a, p1b) = if spec.p1_search_depth > 0 { + search_side_moves(&mut sim, bk, 1, spec.p1_search_depth, &spec.p1_eval) + } else { + pick_side_moves(&mut sim, bk, 1, spec.p1_difficulty, &mut rng1) + }; + + // Slot occupancy + KO state before the turn — the victim's slot must be read pre-execute, + // since a KO'd slot may already have been vacated by the time we look. + let slots = active_slots(&mut sim, bk); + for side in 0u8..2 { + for abs in side_slots(side) { + let mon = slots[abs]; + if mon == EMPTY_LANE { + continue; + } + let counts = if side == 0 { &mut out.active_turns_p0 } else { &mut out.active_turns_p1 }; + if let Some(c) = counts.get_mut(mon as usize) { + *c += 1; + } + } + } + let ko_before = [ko_bitmap(&mut sim, bk, 0), ko_bitmap(&mut sim, bk, 1)]; + + if let Some((side, mon)) = track { + let [s0, s1] = side_slots(side); + let picked = if side == 0 { (p0a, p0b) } else { (p1a, p1b) }; + let (active, move_index) = if slots[s0] == mon as u32 { + (true, picked.0.move_index) + } else if slots[s1] == mon as u32 { + (true, picked.1.move_index) + } else { + (false, NO_OP) + }; + if active { + out.tracked_active_turns += 1; + } + let mhp = mon_max_hp(&mut sim, OBS, bk, side, mon).max(1); + let hp = mhp + mon_state(&mut sim, OBS, bk, side, mon, MonStateIndexName::Hp); + out.rows.push(MonTurnTrace { + turn: t, + active, + move_index, + hp_pct: (hp.max(0) * 100) as f64 / mhp as f64, + stamina: mon_current_stamina(&mut sim, OBS, bk, side, mon), + atk_delta: mon_state(&mut sim, OBS, bk, side, mon, MonStateIndexName::Attack), + }); + } + + let salt0 = random_salt(&mut salt_rng); + let salt1 = random_salt(&mut salt_rng); + let side0 = pack_side(p0a.move_index, p0a.extra_data, p0b.move_index, p0b.extra_data, salt0); + let side1 = pack_side(p1a.move_index, p1a.extra_data, p1b.move_index, p1b.extra_data, salt1); + // Rollback point for the co-kill counterfactuals below — the real turn is about to advance + // the battle past the state they need to be posed from. + let snap = sim.snapshot(bk); + sim.execute_slot_turn(side0, side1); + + // Fresh KOs → credit whichever opposing slot aimed at the victim's slot. Exactly one aimer + // is a clean attribution; two gets resolved by replaying each alone; none means it wasn't a + // targeted move that did it. + let bk2 = sim.battle_key; + for side in 0u8..2 { + let fresh = ko_bitmap(&mut sim, bk2, side) & !ko_before[side as usize]; + if fresh == 0 { + continue; + } + let opp = 1 - side; + let opp_moves = if opp == 0 { [p0a, p0b] } else { [p1a, p1b] }; + let (victim_ids, killer_ids) = if side == 0 { + (&spec.p0_ids, &spec.p1_ids) + } else { + (&spec.p1_ids, &spec.p0_ids) + }; + for v in 0..victim_ids.len() { + if fresh & (1u32 << v) == 0 { + continue; + } + let Some(v_abs) = side_slots(side).into_iter().find(|&a| slots[a] == v as u32) else { + out.kos_incidental += 1; // victim wasn't an active slot this turn + continue; + }; + let aimers: Vec = (0..2).filter(|&i| aimed_at(opp_moves[i], v_abs)).collect(); + if aimers.is_empty() { + out.kos_incidental += 1; // nothing targeted it — status, recoil, or ally damage + continue; + } + // Both aimed → replay each alone; the one lethal by itself earns the credit. If + // neither or both are, the KO is genuinely shared and stays out of the matrix. + let lethal: Vec = if aimers.len() == 2 { + aimers + .iter() + .copied() + .filter(|&i| { + would_ko_alone(&mut sim, snap, opp, i, opp_moves[i], side, v, (salt0, salt1)) + }) + .collect() + } else { + aimers + }; + let [i] = lethal.as_slice() else { + out.kos_shared += 1; + continue; + }; + let k_abs = side_slots(opp)[*i]; + match killer_ids.get(slots[k_abs] as usize) { + Some(&killer_id) => out.kos.push(DoublesKoEvent { + turn: t, + killer_seat: opp, + killer_id, + victim_id: victim_ids[v], + }), + None => out.kos_incidental += 1, + } + } + } + sim.dispose_fork(snap); + } + let fw = sim.winner_index(); + out.winner_side = if fw != 2 { Some(fw) } else { None }; + out +} + +#[cfg(test)] +mod pilot_tests { + use super::*; + use crate::arena::build_doubles_specs; + use crate::roster::{self, load_roster}; + + fn chomp_root() -> std::path::PathBuf { + std::env::var("CHOMP_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("..") + }) + } + + /// Every mon must be able to take a real action in doubles. A move whose hand-written + /// `getMeta()` quotes `basePower: 0` used to be dropped from the option set, which left a mon + /// with an all-custom kit (Iblivion) with nothing to pick and resting every turn of every game. + #[test] + fn every_mon_can_act_in_doubles() { + let roster = load_roster(&chomp_root()); + let book = roster::address_book(); + let (specs, _) = build_doubles_specs(&roster, 240, 0xbeefcafe, 10_000, true, 0); + + for m in &roster.mons { + let mut appearances = 0; + let mut acted = false; + for spec in &specs { + for side in 0u8..2 { + let ids = if side == 0 { &spec.p0_ids } else { &spec.p1_ids }; + let Some(mon) = ids.iter().position(|&x| x == m.id) else { continue }; + appearances += 1; + let tr = play_doubles_game_instrumented(spec, &book, Some((side, mon))); + if tr.rows.iter().any(|r| r.active && r.move_index != NO_OP && r.move_index != SWITCH) { + acted = true; + break; + } + } + if acted { + break; + } + } + assert!(appearances > 0, "{} never drafted — widen the sample", m.name); + assert!(acted, "{} never used a move in {appearances} doubles appearances", m.name); + } + } + + /// KO attribution must stay conservative and well-covered: every KO lands in exactly one of + /// credited / co-kill / incidental (no double-counting), and the co-kill replay must recover the + /// bulk of the turns where both slots aimed at the same victim — without it, coverage sits near + /// half and focus-fired mons are systematically under-counted as victims. + #[test] + fn ko_attribution_is_covered_and_conserved() { + let roster = load_roster(&chomp_root()); + let book = roster::address_book(); + let (specs, _) = build_doubles_specs(&roster, 300, 0xbeefcafe, 10_000, true, 0); + let recs = run_doubles_games_instrumented(&specs, &book, 1); + + let credited: usize = recs.iter().map(|r| r.kos.len()).sum(); + let shared: u32 = recs.iter().map(|r| r.kos_shared).sum(); + let incidental: u32 = recs.iter().map(|r| r.kos_incidental).sum(); + let total = credited as u32 + shared + incidental; + assert!(total > 0, "no KOs observed — widen the sample"); + + // A credited KO names a killer on the crediting side and a victim on the other. (killer_id + // == victim_id is legal: the two teams are drawn independently, so mirrors happen.) + for r in &recs { + for ko in &r.kos { + let (killers, victims) = if ko.killer_seat == 0 { + (&r.p0_ids, &r.p1_ids) + } else { + (&r.p1_ids, &r.p0_ids) + }; + assert!(killers.contains(&ko.killer_id), "killer not on the crediting side"); + assert!(victims.contains(&ko.victim_id), "victim not on the opposing side"); + } + } + + let coverage = credited as f64 / total as f64; + assert!(coverage > 0.6, "KO attribution coverage fell to {:.0}% — co-kill replay regressed", coverage * 100.0); + } + + /// The probe must actually price a zero-quote move. Iblivion's kit is entirely hand-written + /// `IMoveSet`, so every one of its damage options comes from simulation — if the probe silently + /// returned 0 the pilot would be picking blind even though it has candidates. + #[test] + fn probe_prices_a_zero_quote_kit() { + let roster = load_roster(&chomp_root()); + let book = roster::address_book(); + let iblivion = roster.mons.iter().find(|m| m.name == "Iblivion").expect("Iblivion in roster"); + let (specs, _) = build_doubles_specs(&roster, 240, 0xbeefcafe, 10_000, true, 0); + + let mut priced = false; + 'outer: for spec in &specs { + for side in 0u8..2 { + let ids = if side == 0 { &spec.p0_ids } else { &spec.p1_ids }; + if !ids.contains(&iblivion.id) { + continue; + } + let mut sim = Sim::new_doubles( + spec.mons_per_team, + spec.p0_team.clone(), + spec.p1_team.clone(), + spec.p0_ids.clone(), + spec.p1_ids.clone(), + &book, + ); + let bk = sim.battle_key; + // Turn 0 is the send-in; step once so both sides have live actives to price against. + sim.execute_slot_turn( + pack_side(SWITCH, 0, SWITCH, 1, 0), + pack_side(SWITCH, 0, SWITCH, 1, 0), + ); + let mon = ids.iter().position(|&x| x == iblivion.id).unwrap() as u32; + let opts = damaging_options(&mut sim, bk, side, mon); + if opts.iter().any(|&(_, d)| d > 0) { + priced = true; + break 'outer; + } + } + } + assert!(priced, "probe never produced a non-zero damage estimate for Iblivion's all-custom kit"); + } +} + +/// One instrumented doubles game, with the same engine-panic guard as the plain runner (a panic +/// yields an empty record, counted as a draw, rather than taking down the batch). +fn run_one_doubles_instrumented(spec: &DoublesSpec, book: &HashMap) -> DoublesInstr { + catch_unwind(AssertUnwindSafe(|| play_doubles_game_instrumented(spec, book, None))).unwrap_or_else(|_| { + DoublesInstr { + winner_side: None, + turns: 0, + p0_ids: spec.p0_ids.clone(), + p1_ids: spec.p1_ids.clone(), + active_turns_p0: vec![0; spec.p0_ids.len()], + active_turns_p1: vec![0; spec.p1_ids.len()], + kos: Vec::new(), + kos_shared: 0, + kos_incidental: 0, + rows: Vec::new(), + tracked_active_turns: 0, + } + }) +} + +/// Instrumented counterpart of [`run_doubles_games`]; results come back in spec order. +pub fn run_doubles_games_instrumented( + specs: &[DoublesSpec], + book: &HashMap, + threads: usize, +) -> Vec { + if threads <= 1 || specs.len() <= 1 { + return specs.iter().map(|s| run_one_doubles_instrumented(s, book)).collect(); + } + let n = threads.min(specs.len()); + let mut slots: Vec> = Vec::with_capacity(specs.len()); + slots.resize_with(specs.len(), || None); + let slots = std::sync::Mutex::new(slots); + let next = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..n { + scope.spawn(|| loop { + let idx = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if idx >= specs.len() { + break; + } + let r = run_one_doubles_instrumented(&specs[idx], book); + slots.lock().unwrap()[idx] = Some(r); + }); + } + }); + slots.into_inner().unwrap().into_iter().map(|r| r.expect("slot filled")).collect() +} diff --git a/transpiler/strategies-rs/src/evaluator.rs b/transpiler/strategies-rs/src/evaluator.rs index 42dd8a18..a30bf290 100644 --- a/transpiler/strategies-rs/src/evaluator.rs +++ b/transpiler/strategies-rs/src/evaluator.rs @@ -1,4 +1,4 @@ -//! Static position evaluation — port of `sims/src/cpu/evaluator.ts`. +//! Static position evaluation. //! The score is a linear function `φ(state)·w`: [`features`] reads the raw //! feature vector, [`dot`] weights it. [`DEFAULT_WEIGHTS`] reproduces the //! original hardcoded weights, so the default path is byte-identical. diff --git a/transpiler/strategies-rs/src/example_bot.rs b/transpiler/strategies-rs/src/example_bot.rs new file mode 100644 index 00000000..ee783d88 --- /dev/null +++ b/transpiler/strategies-rs/src/example_bot.rs @@ -0,0 +1,97 @@ +//! A worked example — copy this file to start your own bot. +//! +//! It plays a one-ply search: try every legal action, simulate it on a fork of +//! the real engine, score the resulting position, keep the best. That is the +//! whole shape of a bot, and it is already enough to beat `random` badly and +//! `heuristic` narrowly. +//! +//! To enter your own: +//! 1. copy this file to `src/my_bot.rs` and rename the struct +//! 2. add `pub mod my_bot;` to `src/lib.rs` +//! 3. add a name constant and one `build` arm in `src/bots.rs` +//! 4. `./bench.sh run --bot my-bot` + +use crate::bot::{Action, Bot, DecisionCtx}; +use crate::evaluator::Weights; +use crate::native::ForkCache; +use crate::sim::HypoMove; +use crate::view::{calculate_valid_moves, Mv, NO_OP_INDEX}; + +pub struct ExampleBot { + /// Weights the position evaluator scores with. Yours can score however you + /// like — this is just what the built-in evaluator needs. + pub weights: Weights, +} + +impl ExampleBot { + pub fn new(weights: Weights) -> Self { + ExampleBot { weights } + } +} + +impl Bot for ExampleBot { + fn name(&self) -> &'static str { + "example" + } + + fn decide(&mut self, ctx: &mut DecisionCtx<'_>) -> Action { + let view = ctx.singles_view(); + + // Every legal action this turn: moves, switches, and resting. Draw from + // ctx.rng and nothing else — that is what keeps your games comparable + // to everyone else's on the same seed. + let valid = calculate_valid_moves(ctx.sim, ctx.seat, view.bk, ctx.rng); + let candidates: Vec = valid + .moves + .iter() + .chain(valid.switches.iter()) + .chain(valid.no_op.iter()) + .copied() + .collect(); + if candidates.is_empty() { + return Action::Single(Mv { move_index: NO_OP_INDEX, extra_data: 0 }); + } + + // In blind mode ctx.peek is None and this is a guess; in peek mode it is + // the opponent's real move. Reading it through ctx.peek is the only way + // to see it, so a blind-mode bot cannot accidentally cheat. + let assumed_reply = ctx.peek.unwrap_or(Mv { move_index: 0, extra_data: 0 }); + + // On a forced-switch turn the opponent does not act at all. + let opponent_acts = view.switch_flag != 1; + + let mut forks = ForkCache::new(self.weights); + let mut best = candidates[0]; + let mut best_score = f64::NEG_INFINITY; + + for candidate in &candidates { + let their_move = opponent_acts.then(|| HypoMove { + move_index: assumed_reply.move_index, + salt: 0, + extra_data: assumed_reply.extra_data, + }); + // Fork the live battle and play both moves onto the copy. This is + // the real engine, so anything that would happen in a game happens + // here: damage rolls, abilities, effects, KOs. + let child = forks.fork( + ctx.sim, + ctx.seat, + their_move, + HypoMove { + move_index: candidate.move_index, + salt: 0, + extra_data: candidate.extra_data, + }, + ); + let score = forks.score(ctx.sim, ctx.seat, child); + if score > best_score { + best_score = score; + best = *candidate; + } + } + // Always release forks — they stay resident in the world until disposed. + forks.dispose_all(ctx.sim); + + Action::Single(best) + } +} diff --git a/transpiler/strategies-rs/src/game.rs b/transpiler/strategies-rs/src/game.rs index 45f18ac1..26072938 100644 --- a/transpiler/strategies-rs/src/game.rs +++ b/transpiler/strategies-rs/src/game.rs @@ -1,5 +1,6 @@ -//! Game loop + batch runner — port of `sims/src/arena/game.ts`'s -//! `runGameLoop` seating/peek/salt semantics, driving [`Sim`] natively. +//! Game loop + batch runner — the seating/peek/salt semantics munch's +//! arena also implements (`sim-tests/arena/game.ts`), driving [`Sim`] +//! natively. //! //! RNG stream discipline (must match TS turn-for-turn): the p0 seat //! decides first (its candidate enumeration + tie-breaks draw from the @@ -10,50 +11,29 @@ use std::collections::HashMap; use std::panic::{catch_unwind, AssertUnwindSafe}; use chomp_engine::Engine; +use chomp_engine::Enums::MonStateIndexName; use chomp_engine::Structs::Mon; use chomp_rt::{Address, B256}; use crate::native::ForkCache; +use crate::bot::{BattleMode, Bot, BotConfig, DecisionCtx}; +use crate::bots; use crate::evaluator::{Weights, DEFAULT_WEIGHTS}; use crate::greedy; use crate::heuristic::{self, HeuristicState}; -use crate::jsrng::{random_salt, JsRng}; +use crate::jsrng::{derive, random_salt, JsRng, STREAM_P0, STREAM_P1, STREAM_SALT}; use crate::nopeek; use crate::override_cpu::{self, OverrideState}; use crate::search; use crate::sim::Sim; use crate::view::{ - active_mon_indices, capture_view, ko_bitmap, mon_current_hp, mon_current_stamina, mon_max_hp, Mv, - Seat, NO_OP_INDEX, SWITCH_MOVE_INDEX, VCPU, VOPP, + active_mon_indices, capture_view, ko_bitmap, mon_current_hp, mon_current_stamina, mon_max_hp, + mon_state, mon_value, Mv, Seat, NO_OP_INDEX, SWITCH_MOVE_INDEX, VCPU, VOPP, }; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StrategyKind { - Heuristic, - Greedy, - Override, - /// No-peek greedy, expectation over the opponent's plausible replies. - NoPeekExpect, - /// No-peek greedy, worst-case (maximin) over the opponent's plausible replies. - NoPeekWorst, -} - -impl StrategyKind { - /// Names are a contract with the TS registry (`sims/src/cpu/registry.ts`) - /// and `workload.ts`'s STRAT_PAIRS — keep them in sync when adding a - /// strategy. - pub fn parse(name: &str) -> Option { - match name { - "heuristic" => Some(StrategyKind::Heuristic), - "greedy" => Some(StrategyKind::Greedy), - "override" => Some(StrategyKind::Override), - "nopeek" => Some(StrategyKind::NoPeekExpect), - "nopeek-wc" => Some(StrategyKind::NoPeekWorst), - _ => None, - } - } -} +/// A seat's bot, named by its registry key (see [`crate::bots::NAMES`]). +pub type BotName = &'static str; #[derive(Clone)] pub struct GameSpec { @@ -66,13 +46,18 @@ pub struct GameSpec { /// look up per-mon config by identity. Empty to disable (config stays inert). pub p0_ids: Vec, pub p1_ids: Vec, - pub p0_strategy: StrategyKind, - pub p1_strategy: StrategyKind, + pub p0_strategy: BotName, + pub p1_strategy: BotName, + /// Which seat sees the opponent's move this game. `None` is blind mode — + /// genuinely simultaneous, and what production play looks like. The seat + /// without the reveal always decides first, so the peeker sees a real move + /// rather than a stale one. + pub peek_seat: Option, /// Per-seat evaluator weights (default [`DEFAULT_WEIGHTS`]); lets a candidate /// weight vector face a baseline opponent in the same game. pub p0_weights: Weights, pub p1_weights: Weights, - /// Per-seat search depth: 0 = the seat plays its `StrategyKind`; ≥1 = maximin + /// Per-seat search depth: 0 = the seat plays its `BotName`; ≥1 = maximin /// search at that depth over the seat's evaluator (ignores the strategy). pub p0_search_depth: u32, pub p1_search_depth: u32, @@ -85,6 +70,36 @@ pub struct GameSpec { pub p1_search_mixed: bool, } +/// One game's generators: one per seat plus the engine's salt stream. +/// +/// Seats draw from their own stream so a bot's draw count cannot shift the +/// battle or its opponent — same seed means the same salts whoever pilots, +/// which is what makes two bots comparable on one seed. +#[derive(Clone, Copy)] +struct GameRngs { + p0: JsRng, + p1: JsRng, + salt: JsRng, +} + +impl GameRngs { + fn seat_mut(&mut self, i: usize) -> &mut JsRng { + if i == 0 { + &mut self.p0 + } else { + &mut self.p1 + } + } + + fn new(seed: u32) -> GameRngs { + GameRngs { + p0: derive(seed, STREAM_P0), + p1: derive(seed, STREAM_P1), + salt: derive(seed, STREAM_SALT), + } + } +} + /// One turn's submitted moves (physical p0/p1; None = side didn't act). #[derive(Clone, Copy, Debug)] pub struct TurnTrace { @@ -100,80 +115,93 @@ pub struct GameOutcome { pub trace: Vec, } -/// Per-seat mutable strategy state (`createState()` in the TS framework). -enum StratState { - Heuristic(HeuristicState), - Greedy, - Override(OverrideState), - NoPeekExpect, - NoPeekWorst, -} - -impl StratState { - fn new(kind: StrategyKind) -> StratState { - match kind { - StrategyKind::Heuristic => StratState::Heuristic(HeuristicState::default()), - StrategyKind::Greedy => StratState::Greedy, - StrategyKind::Override => StratState::Override(OverrideState::default()), - StrategyKind::NoPeekExpect => StratState::NoPeekExpect, - StrategyKind::NoPeekWorst => StratState::NoPeekWorst, - } - } -} - struct SeatState { seat: Seat, - state: StratState, + bot: Box, last_own_move: Mv, - weights: Weights, - search_depth: u32, - search_peek: bool, - search_mixed: bool, } -/// The two seats for a spec — the one place per-seat weights get wired in. +/// The two seats for a spec — the one place per-seat bot config gets wired in. fn init_seats(spec: &GameSpec) -> [SeatState; 2] { + let build = |name: BotName, cfg: BotConfig| { + bots::build(name, cfg).unwrap_or_else(|| panic!("unknown bot {name:?}")) + }; [ SeatState { seat: Seat { cpu: 0 }, - state: StratState::new(spec.p0_strategy), + bot: build( + spec.p0_strategy, + BotConfig { + doubles_eval: Default::default(), + weights: spec.p0_weights, + search_depth: spec.p0_search_depth, + search_peek: spec.p0_search_peek, + search_mixed: spec.p0_search_mixed, + }, + ), last_own_move: Mv { move_index: 0, extra_data: 0 }, - weights: spec.p0_weights, - search_depth: spec.p0_search_depth, - search_peek: spec.p0_search_peek, - search_mixed: spec.p0_search_mixed, }, SeatState { seat: Seat { cpu: 1 }, - state: StratState::new(spec.p1_strategy), + bot: build( + spec.p1_strategy, + BotConfig { + doubles_eval: Default::default(), + weights: spec.p1_weights, + search_depth: spec.p1_search_depth, + search_peek: spec.p1_search_peek, + search_mixed: spec.p1_search_mixed, + }, + ), last_own_move: Mv { move_index: 0, extra_data: 0 }, - weights: spec.p1_weights, - search_depth: spec.p1_search_depth, - search_peek: spec.p1_search_peek, - search_mixed: spec.p1_search_mixed, }, ] } -fn decide_one(sim: &mut Sim, s: &mut SeatState, pm: Mv, rng: &mut JsRng) -> Mv { +fn decide_one(sim: &mut Sim, s: &mut SeatState, peek: Option, rng: &mut JsRng) -> Mv { let view = capture_view(sim, s.seat, sim.battle_key); - let w = s.weights; - if s.search_depth >= 1 { - // Maximin search over the seat's evaluator (ignores the strategy). `pm` (the revealed - // opponent move) is used only when `search_peek` is set. - return search::decide(sim, s.seat, &view, pm, &w, s.search_depth, s.search_peek, s.search_mixed, rng); + let mut ctx = DecisionCtx { + sim, + seat: s.seat, + mode: BattleMode::Singles, + view: Some(&view), + peek, + rng, + }; + s.bot.decide(&mut ctx).single() +} + +/// Both seats decide one turn under the spec's information mode. +/// +/// The seat without the reveal moves first; in blind mode neither is given +/// one, so order carries no information and only fixes the rng draw sequence. +fn decide_turn( + sim: &mut Sim, + seats: &mut [SeatState; 2], + rngs: &mut GameRngs, + peek_seat: Option, + acts: [bool; 2], +) -> [Option; 2] { + let first = if peek_seat == Some(0) { 1usize } else { 0usize }; + let second = 1 - first; + let mut moves: [Option; 2] = [None, None]; + + if acts[first] { + let mv = decide_one(sim, &mut seats[first], None, rngs.seat_mut(first)); + seats[first].last_own_move = mv; + moves[first] = Some(mv); } - match &mut s.state { - StratState::Heuristic(st) => heuristic::decide(sim, s.seat, &view, pm, rng, st, &w), - StratState::Greedy => greedy::decide(sim, s.seat, &view, pm, rng, &w), - StratState::Override(st) => override_cpu::decide(sim, s.seat, &view, pm, rng, st, &w), - StratState::NoPeekExpect => nopeek::decide_expect(sim, s.seat, &view, rng, &w), - StratState::NoPeekWorst => nopeek::decide_worst(sim, s.seat, &view, rng, &w), + if acts[second] { + let peek = if peek_seat == Some(second as u8) { moves[first] } else { None }; + let mv = decide_one(sim, &mut seats[second], peek, rngs.seat_mut(second)); + seats[second].last_own_move = mv; + moves[second] = Some(mv); } + moves } pub fn play_game(spec: &GameSpec, book: &HashMap, trace: bool) -> GameOutcome { - let mut rng = JsRng::new(spec.seed); + let mut rngs = GameRngs::new(spec.seed); let mut sim = Sim::new( spec.mons_per_team, spec.p0_team.clone(), @@ -198,30 +226,16 @@ pub fn play_game(spec: &GameSpec, book: &HashMap, trace: bool) let p0_acts = flag != 1; let p1_acts = flag != 0; - // p0 seat decides first, peeking only the opponent's previous move. - let mut p0_move: Option = None; - if p0_acts { - let peek = seats[1].last_own_move; - let mv = decide_one(&mut sim, &mut seats[0], peek, &mut rng); - seats[0].last_own_move = mv; - p0_move = Some(mv); - } - // p1 seat replies with the true reveal (production semantics). - let mut p1_move: Option = None; - if p1_acts { - let peek = p0_move.unwrap_or(Mv { move_index: 0, extra_data: 0 }); - let mv = decide_one(&mut sim, &mut seats[1], peek, &mut rng); - seats[1].last_own_move = mv; - p1_move = Some(mv); - } + let [p0_move, p1_move] = + decide_turn(&mut sim, &mut seats, &mut rngs, spec.peek_seat, [p0_acts, p1_acts]); if trace { traces.push(TurnTrace { p0: p0_move, p1: p1_move }); } // Salt only for an acting side, p0 before p1. - let p0_salt = if p0_move.is_some() { random_salt(&mut rng) } else { 0 }; - let p1_salt = if p1_move.is_some() { random_salt(&mut rng) } else { 0 }; + let p0_salt = if p0_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; + let p1_salt = if p1_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; sim.execute_turn( p0_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p0_salt, @@ -249,7 +263,7 @@ pub fn narrate_game( name_mon: impl Fn(u32) -> String, name_move: impl Fn(u32, u8) -> String, ) -> GameOutcome { - let mut rng = JsRng::new(spec.seed); + let mut rngs = GameRngs::new(spec.seed); let mut sim = Sim::new( spec.mons_per_team, spec.p0_team.clone(), @@ -305,7 +319,7 @@ pub fn narrate_game( let mut p0_move: Option = None; if p0_acts { let peek = seats[1].last_own_move; - let mv = decide_one(&mut sim, &mut seats[0], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[0], Some(peek), &mut rngs.p0); seats[0].last_own_move = mv; p0_move = Some(mv); } @@ -317,14 +331,14 @@ pub fn narrate_game( // fork counter + greedy's own dispose_all leave the live decision fully unperturbed. // Run it for a search seat too (the search overrides the strategy), so `⟂ greedy→` // flags where the search diverges from the greedy baseline. - if seats[1].search_depth >= 1 || !matches!(seats[1].state, StratState::Greedy) { + if seats[1].bot.name() != bots::GREEDY { let cf_view = capture_view(&mut sim, seats[1].seat, bk); - let mut cf_rng = rng; // JsRng: Copy — a throwaway snapshot of the live stream + let mut cf_rng = rngs.p1; // JsRng: Copy — a throwaway snapshot of the live stream let saved_fc = sim.fork_counter(); - p1_greedy_cf = Some(greedy::decide(&mut sim, seats[1].seat, &cf_view, peek, &mut cf_rng, &seats[1].weights)); + p1_greedy_cf = Some(greedy::decide(&mut sim, seats[1].seat, &cf_view, peek, &mut cf_rng, &spec.p1_weights)); sim.set_fork_counter(saved_fc); } - let mv = decide_one(&mut sim, &mut seats[1], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[1], Some(peek), &mut rngs.p1); seats[1].last_own_move = mv; p1_move = Some(mv); } @@ -344,8 +358,8 @@ pub fn narrate_game( name_mon(spec.p0_ids[p0_active]), name_mon(spec.p1_ids[p1_active]), ); - let p0_salt = if p0_move.is_some() { random_salt(&mut rng) } else { 0 }; - let p1_salt = if p1_move.is_some() { random_salt(&mut rng) } else { 0 }; + let p0_salt = if p0_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; + let p1_salt = if p1_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; sim.execute_turn( p0_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p0_salt, p0_move.map(|m| m.extra_data).unwrap_or(0), p1_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p1_salt, p1_move.map(|m| m.extra_data).unwrap_or(0), @@ -446,7 +460,7 @@ pub struct TraceRecord { } pub fn play_game_traced(spec: &GameSpec, book: &HashMap) -> TraceRecord { - let mut rng = JsRng::new(spec.seed); + let mut rngs = GameRngs::new(spec.seed); let mut sim = Sim::new( spec.mons_per_team, spec.p0_team.clone(), @@ -474,14 +488,14 @@ pub fn play_game_traced(spec: &GameSpec, book: &HashMap) -> Tra let mut p0_move: Option = None; if p0_acts { let peek = seats[1].last_own_move; - let mv = decide_one(&mut sim, &mut seats[0], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[0], Some(peek), &mut rngs.p0); seats[0].last_own_move = mv; p0_move = Some(mv); } let mut p1_move: Option = None; if p1_acts { let peek = p0_move.unwrap_or(Mv { move_index: 0, extra_data: 0 }); - let mv = decide_one(&mut sim, &mut seats[1], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[1], Some(peek), &mut rngs.p1); seats[1].last_own_move = mv; p1_move = Some(mv); } @@ -491,8 +505,8 @@ pub fn play_game_traced(spec: &GameSpec, book: &HashMap) -> Tra let p0_hp_pre = mon_current_hp(&mut sim, obs, bk, VOPP, p0a); let p1_hp_pre = mon_current_hp(&mut sim, obs, bk, VCPU, p1a); - let p0_salt = if p0_move.is_some() { random_salt(&mut rng) } else { 0 }; - let p1_salt = if p1_move.is_some() { random_salt(&mut rng) } else { 0 }; + let p0_salt = if p0_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; + let p1_salt = if p1_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; sim.execute_turn( p0_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p0_salt, p0_move.map(|m| m.extra_data).unwrap_or(0), p1_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p1_salt, p1_move.map(|m| m.extra_data).unwrap_or(0), @@ -544,7 +558,7 @@ pub struct YomiSample { } pub fn play_game_yomi(spec: &GameSpec, book: &HashMap) -> Vec { - let mut rng = JsRng::new(spec.seed); + let mut rngs = GameRngs::new(spec.seed); let mut sim = Sim::new( spec.mons_per_team, spec.p0_team.clone(), @@ -575,7 +589,8 @@ pub fn play_game_yomi(spec: &GameSpec, book: &HashMap) -> Vec) -> Vec = None; if p0_acts { let peek = seats[1].last_own_move; - let mv = decide_one(&mut sim, &mut seats[0], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[0], Some(peek), &mut rngs.p0); seats[0].last_own_move = mv; p0_move = Some(mv); } let mut p1_move: Option = None; if p1_acts { let peek = p0_move.unwrap_or(Mv { move_index: 0, extra_data: 0 }); - let mv = decide_one(&mut sim, &mut seats[1], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[1], Some(peek), &mut rngs.p1); seats[1].last_own_move = mv; p1_move = Some(mv); } - let p0_salt = if p0_move.is_some() { random_salt(&mut rng) } else { 0 }; - let p1_salt = if p1_move.is_some() { random_salt(&mut rng) } else { 0 }; + let p0_salt = if p0_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; + let p1_salt = if p1_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; sim.execute_turn( p0_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p0_salt, p0_move.map(|m| m.extra_data).unwrap_or(0), p1_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p1_salt, p1_move.map(|m| m.extra_data).unwrap_or(0), @@ -653,7 +668,7 @@ fn summarize_scored(scored: &[(Mv, f64)]) -> ([f64; 4], f64, f64, f64) { } pub fn play_game_breadth(spec: &GameSpec, book: &HashMap) -> Vec { - let mut rng = JsRng::new(spec.seed); + let mut rngs = GameRngs::new(spec.seed); let mut sim = Sim::new( spec.mons_per_team, spec.p0_team.clone(), spec.p1_team.clone(), spec.p0_ids.clone(), spec.p1_ids.clone(), book, @@ -677,7 +692,7 @@ pub fn play_game_breadth(spec: &GameSpec, book: &HashMap) -> Ve let mut p0_move: Option = None; if p0_acts { let view = capture_view(&mut sim, seat0, bk); - let (chosen, scored) = greedy::decide_scored(&mut sim, seat0, &view, last1, &mut rng, &DEFAULT_WEIGHTS); + let (chosen, scored) = greedy::decide_scored(&mut sim, seat0, &view, last1, &mut rngs.p0, &DEFAULT_WEIGHTS); if !scored.is_empty() { let (lane, t1, t2, w) = summarize_scored(&scored); samples.push(BreadthSample { mon_id: spec.p0_ids[p0a], chosen_move: chosen.move_index as i16, lane_scores: lane, top1: t1, top2: t2, worst: w }); @@ -688,7 +703,7 @@ pub fn play_game_breadth(spec: &GameSpec, book: &HashMap) -> Ve if p1_acts { let peek = p0_move.unwrap_or(Mv { move_index: 0, extra_data: 0 }); let view = capture_view(&mut sim, seat1, bk); - let (chosen, scored) = greedy::decide_scored(&mut sim, seat1, &view, peek, &mut rng, &DEFAULT_WEIGHTS); + let (chosen, scored) = greedy::decide_scored(&mut sim, seat1, &view, peek, &mut rngs.p1, &DEFAULT_WEIGHTS); if !scored.is_empty() { let (lane, t1, t2, w) = summarize_scored(&scored); samples.push(BreadthSample { mon_id: spec.p1_ids[p1a], chosen_move: chosen.move_index as i16, lane_scores: lane, top1: t1, top2: t2, worst: w }); @@ -697,8 +712,8 @@ pub fn play_game_breadth(spec: &GameSpec, book: &HashMap) -> Ve p1_move = Some(chosen); } - let p0_salt = if p0_move.is_some() { random_salt(&mut rng) } else { 0 }; - let p1_salt = if p1_move.is_some() { random_salt(&mut rng) } else { 0 }; + let p0_salt = if p0_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; + let p1_salt = if p1_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; sim.execute_turn( p0_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p0_salt, p0_move.map(|m| m.extra_data).unwrap_or(0), p1_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p1_salt, p1_move.map(|m| m.extra_data).unwrap_or(0), @@ -743,10 +758,14 @@ pub struct InstrRecord { pub active_turns_p0: Vec, pub active_turns_p1: Vec, pub kos: Vec, + /// Largest (base + delta) any combat stat reached, and which mon-id held it — stat boosts are + /// multiplicative, so this shows how far compounding actually runs in a real game. + pub peak_stat: i64, + pub peak_stat_mon: u32, } pub fn play_game_instrumented(spec: &GameSpec, book: &HashMap) -> InstrRecord { - let mut rng = JsRng::new(spec.seed); + let mut rngs = GameRngs::new(spec.seed); let mut sim = Sim::new( spec.mons_per_team, spec.p0_team.clone(), @@ -764,14 +783,39 @@ pub fn play_game_instrumented(spec: &GameSpec, book: &HashMap) let mut active_turns_p0 = vec![0u32; spec.p0_ids.len()]; let mut active_turns_p1 = vec![0u32; spec.p1_ids.len()]; let mut kos: Vec = Vec::new(); + let (mut peak_stat, mut peak_stat_mon) = (0i64, 0u32); for t in 0..spec.max_turns { + // Boosts are multiplicative and stack per source, so track how far the combat stats actually + // compound; MAX_BOOSTED_STAT lets a stat reach int32::max, which the damage formula then + // scales by base power. + { + let bk_now: B256 = sim.battle_key; + for (vp, ids) in [(VOPP, &spec.p0_ids), (VCPU, &spec.p1_ids)] { + for (mi, &id) in ids.iter().enumerate() { + for ix in [ + MonStateIndexName::Attack, + MonStateIndexName::SpecialAttack, + MonStateIndexName::Defense, + MonStateIndexName::SpecialDefense, + ] { + let v = mon_value(&mut sim, obs, bk_now, vp, mi, ix) + + mon_state(&mut sim, obs, bk_now, vp, mi, ix); + if v > peak_stat { + peak_stat = v; + peak_stat_mon = id; + } + } + } + } + } let winner = sim.winner_index(); if winner != 2 { return InstrRecord { winner_seat: Some(winner), turns: t, p0_ids: spec.p0_ids.clone(), p1_ids: spec.p1_ids.clone(), active_turns_p0, active_turns_p1, kos, + peak_stat, peak_stat_mon, }; } @@ -783,14 +827,14 @@ pub fn play_game_instrumented(spec: &GameSpec, book: &HashMap) let mut p0_move: Option = None; if p0_acts { let peek = seats[1].last_own_move; - let mv = decide_one(&mut sim, &mut seats[0], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[0], Some(peek), &mut rngs.p0); seats[0].last_own_move = mv; p0_move = Some(mv); } let mut p1_move: Option = None; if p1_acts { let peek = p0_move.unwrap_or(Mv { move_index: 0, extra_data: 0 }); - let mv = decide_one(&mut sim, &mut seats[1], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[1], Some(peek), &mut rngs.p1); seats[1].last_own_move = mv; p1_move = Some(mv); } @@ -802,8 +846,8 @@ pub fn play_game_instrumented(spec: &GameSpec, book: &HashMap) let ko_before_p0 = ko_bitmap(&mut sim, obs, bk, VOPP); let ko_before_p1 = ko_bitmap(&mut sim, obs, bk, VCPU); - let p0_salt = if p0_move.is_some() { random_salt(&mut rng) } else { 0 }; - let p1_salt = if p1_move.is_some() { random_salt(&mut rng) } else { 0 }; + let p0_salt = if p0_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; + let p1_salt = if p1_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; sim.execute_turn( p0_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p0_salt, @@ -843,6 +887,7 @@ pub fn play_game_instrumented(spec: &GameSpec, book: &HashMap) turns: spec.max_turns, p0_ids: spec.p0_ids.clone(), p1_ids: spec.p1_ids.clone(), active_turns_p0, active_turns_p1, kos, + peak_stat, peak_stat_mon, } } @@ -868,7 +913,7 @@ pub fn play_game_mock( lane: usize, mock: &crate::mock2::MockMove, ) -> InstrRecord { - let mut rng = JsRng::new(spec.seed); + let mut rngs = GameRngs::new(spec.seed); let mut sim = Sim::new( spec.mons_per_team, spec.p0_team.clone(), spec.p1_team.clone(), spec.p0_ids.clone(), spec.p1_ids.clone(), book, @@ -884,7 +929,7 @@ pub fn play_game_mock( for t in 0..spec.max_turns { let winner = sim.winner_index(); if winner != 2 { - return InstrRecord { winner_seat: Some(winner), turns: t, p0_ids: spec.p0_ids.clone(), p1_ids: spec.p1_ids.clone(), active_turns_p0, active_turns_p1, kos }; + return InstrRecord { winner_seat: Some(winner), turns: t, p0_ids: spec.p0_ids.clone(), p1_ids: spec.p1_ids.clone(), active_turns_p0, active_turns_p1, kos, peak_stat: 0, peak_stat_mon: 0 }; } let bk: B256 = sim.battle_key; @@ -898,14 +943,14 @@ pub fn play_game_mock( let mut p0_move: Option = None; if p0_acts { let peek = seats[1].last_own_move; - let mv = decide_one(&mut sim, &mut seats[0], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[0], Some(peek), &mut rngs.p0); seats[0].last_own_move = mv; p0_move = Some(mv); } let mut p1_move: Option = None; if p1_acts { let peek = p0_move.unwrap_or(Mv { move_index: 0, extra_data: 0 }); - let mv = decide_one(&mut sim, &mut seats[1], peek, &mut rng); + let mv = decide_one(&mut sim, &mut seats[1], Some(peek), &mut rngs.p1); seats[1].last_own_move = mv; p1_move = Some(mv); } @@ -921,8 +966,8 @@ pub fn play_game_mock( let mhp0_before = if spec.p0_ids.get(p0a) == Some(&target_id) { mon_current_hp(&mut sim, obs, bk, VOPP, p0a) } else { i64::MIN }; let mhp1_before = if spec.p1_ids.get(p1a) == Some(&target_id) { mon_current_hp(&mut sim, obs, bk, VCPU, p1a) } else { i64::MIN }; - let p0_salt = if p0_move.is_some() { random_salt(&mut rng) } else { 0 }; - let p1_salt = if p1_move.is_some() { random_salt(&mut rng) } else { 0 }; + let p0_salt = if p0_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; + let p1_salt = if p1_move.is_some() { random_salt(&mut rngs.salt) } else { 0 }; sim.execute_turn( p0_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p0_salt, p0_move.map(|m| m.extra_data).unwrap_or(0), p1_move.map(|m| m.move_index).unwrap_or(NO_OP_INDEX), p1_salt, p1_move.map(|m| m.extra_data).unwrap_or(0), @@ -956,7 +1001,7 @@ pub fn play_game_mock( } let fw = sim.winner_index(); - InstrRecord { winner_seat: if fw != 2 { Some(fw) } else { None }, turns: spec.max_turns, p0_ids: spec.p0_ids.clone(), p1_ids: spec.p1_ids.clone(), active_turns_p0, active_turns_p1, kos } + InstrRecord { winner_seat: if fw != 2 { Some(fw) } else { None }, turns: spec.max_turns, p0_ids: spec.p0_ids.clone(), p1_ids: spec.p1_ids.clone(), active_turns_p0, active_turns_p1, kos, peak_stat: 0, peak_stat_mon: 0 } } /// Threaded mock batch (see `run_batch`); the mock's word is repacked each turn in play_game_mock. diff --git a/transpiler/strategies-rs/src/greedy.rs b/transpiler/strategies-rs/src/greedy.rs index 2180ae5b..e77513be 100644 --- a/transpiler/strategies-rs/src/greedy.rs +++ b/transpiler/strategies-rs/src/greedy.rs @@ -1,7 +1,6 @@ -//! GREEDY (eval) — port of `sims/src/cpu/strategies/greedy-eval.ts` in its -//! registry configuration (salts=1, default weights): 1-ply best response -//! on the forward model + evaluator. The risk-aware multi-salt mode is not -//! ported — the arena's `greedy` never enables it, and with one sample the +//! GREEDY (eval) — 1-ply best response on the forward model + evaluator +//! (salts=1, default weights). The risk-aware multi-salt mode is absent: +//! the arena's `greedy` never enables it, and with one sample the //! risk-adjusted score IS the sample (mean of one, exact in f64). use crate::evaluator::Weights; diff --git a/transpiler/strategies-rs/src/heuristic.rs b/transpiler/strategies-rs/src/heuristic.rs index 596c3f8b..803e79cd 100644 --- a/transpiler/strategies-rs/src/heuristic.rs +++ b/transpiler/strategies-rs/src/heuristic.rs @@ -1,8 +1,7 @@ -//! HEURISTIC CPU — port of `sims/src/cpu/strategies/hard-cpu.ts`: best-response -//! with foreknowledge (peeks the revealed move), sim-measured damage on -//! both sides, guarded free-turn setup punishment, defensive switching, -//! anti-wall pivot, and the eval-veto wrapper. Every phase, threshold and -//! rng draw mirrors the TS source order. +//! HEURISTIC CPU — best-response with foreknowledge (peeks the revealed +//! move), sim-measured damage on both sides, guarded free-turn setup +//! punishment, defensive switching, anti-wall pivot, and the eval-veto +//! wrapper. use chomp_engine::Constants; use chomp_engine::Enums::MoveClass; diff --git a/transpiler/strategies-rs/src/jsrng.rs b/transpiler/strategies-rs/src/jsrng.rs index 016aa3ad..df863040 100644 --- a/transpiler/strategies-rs/src/jsrng.rs +++ b/transpiler/strategies-rs/src/jsrng.rs @@ -1,6 +1,6 @@ -//! Seeded RNG + salt — port of `sims/src/arena/rng.ts` (mulberry32, -//! verbatim from munch). Same seed => bit-identical float stream => the -//! same team draws, tie-breaks and per-turn salts as the TS arena. +//! Seeded RNG + salt — mulberry32, matching munch's `makeRng` +//! (`src/app/services/cpu/engine-view.ts`). Same seed => bit-identical +//! float stream => the same team draws, tie-breaks and per-turn salts. /// mulberry32. Mirrors the JS coercions exactly: all state math is u32 /// wrapping (JS `>>> 0` / `Math.imul`), the output is `u32 / 2^32` — both @@ -24,6 +24,28 @@ impl JsRng { } } +/// Independent stream ids for one game's [`derive`]d generators. +pub const STREAM_P0: u32 = 0; +pub const STREAM_P1: u32 = 1; +pub const STREAM_SALT: u32 = 2; + +/// An independent stream for `(seed, stream)`. +/// +/// Each seat decides from its own generator and the engine draws salts from a +/// third, so a bot's draw count can't perturb the battle or the other seat: +/// same seed => same salts, whoever is piloting. That is what makes two bots +/// comparable on one seed, and it is why the mixing step matters — mulberry32 +/// seeded with `seed`, `seed+1`, `seed+2` would correlate across streams. +pub fn derive(seed: u32, stream: u32) -> JsRng { + let mut x = seed ^ stream.wrapping_mul(0x9e37_79b9); + x ^= x >> 16; + x = x.wrapping_mul(0x7feb_352d); + x ^= x >> 15; + x = x.wrapping_mul(0x846c_a68b); + x ^= x >> 16; + JsRng::new(x) +} + /// 26 hex nibbles -> uint104 (`randomSalt`). Each nibble is /// `Math.floor(rng() * 16)` — exact in f64 (rng() = x / 2^32, so the /// product is a dyadic rational well inside f64 precision). diff --git a/transpiler/strategies-rs/src/lib.rs b/transpiler/strategies-rs/src/lib.rs index 7d1545e4..c74759d5 100644 --- a/transpiler/strategies-rs/src/lib.rs +++ b/transpiler/strategies-rs/src/lib.rs @@ -1,15 +1,14 @@ //! Native CPU strategies + game loop over the transpiled engine. //! -//! Rust port of the arena decision stack (`sims/src/cpu/*` + the -//! `sims/src/arena/game.ts` loop). All three arena strategies — `hard`, -//! `greedy`, `override` — were ported 1:1 against the TS reference and -//! verified move-for-move by the (since retired) lockstep gates; the -//! JS-exact rng/float mirroring in here is frozen heritage from that -//! era, not an ongoing constraint. +//! This crate is the origin of the CPU stack. It began as a 1:1 port of a +//! since-deleted TS arena, verified move-for-move by the (retired) lockstep +//! gates; the JS-exact rng/float mirroring in here is frozen heritage from +//! that era, not an ongoing constraint. //! -//! The stacks are decoupled: Rust is the fast experimentation substrate -//! and may diverge freely; anything shipping to the game's CPU mode is -//! ported back to TS on its own terms (no bit-for-bit requirement). +//! Rust is the experimentation substrate and may diverge freely. Strategies +//! that ship to the game's CPU mode are hand-ported into munch +//! (`../munch/src/app/services/cpu/`) and gated on win-rate direction rather +//! than bit-identicality — see `sim-tests/arena/cpu-reference.json` there. //! //! Seat convention: strategies are written as if the CPU is p1 and the //! opponent p0 (inherited from the on-chain CPUs). The p0 seat plays @@ -19,6 +18,9 @@ #![allow(non_snake_case)] // engine call sites keep Solidity spelling +pub mod bot; +pub mod example_bot; +pub mod bots; pub mod roster; pub mod analysis; pub mod arena; diff --git a/transpiler/strategies-rs/src/mock2.rs b/transpiler/strategies-rs/src/mock2.rs index dd4ddf35..d9d28a0b 100644 --- a/transpiler/strategies-rs/src/mock2.rs +++ b/transpiler/strategies-rs/src/mock2.rs @@ -15,7 +15,7 @@ use chomp_rt::{B256, U256}; use crate::analysis::{fold, MonStat}; use crate::arena::{build_specs, build_specs_with}; -use crate::game::{run_games_instrumented, run_games_mock, GameSpec, StrategyKind}; +use crate::game::{run_games_instrumented, run_games_mock, GameSpec, BotName}; use crate::roster::{self, Roster}; use crate::sim::Sim; use crate::shared::{build_damage_calc_context, estimate_damage}; @@ -289,7 +289,7 @@ pub fn run_mock_ab(roster: &Roster, mock: &MockMove, games: usize, wseed: u32, s /// Same A/B, but under an explicit pilot rotation — e.g. the no-peek pilots as a "ceiling", which /// actually play the reads/setups the greedy basket ignores, so a conditional/yomi move shows its /// upside rather than just its floor. -pub fn run_mock_ab_with(roster: &Roster, mock: &MockMove, games: usize, wseed: u32, seed_base: u32, threads: usize, pairs: &[(StrategyKind, StrategyKind)]) -> (MonStat, MonStat) { +pub fn run_mock_ab_with(roster: &Roster, mock: &MockMove, games: usize, wseed: u32, seed_base: u32, threads: usize, pairs: &[(BotName, BotName)]) -> (MonStat, MonStat) { let (specs, _) = build_specs_with(roster, games, wseed, seed_base, pairs); run_mock_ab_on(roster, mock, specs, threads) } diff --git a/transpiler/strategies-rs/src/native.rs b/transpiler/strategies-rs/src/native.rs index f5ed5ee2..cc22a25f 100644 --- a/transpiler/strategies-rs/src/native.rs +++ b/transpiler/strategies-rs/src/native.rs @@ -1,8 +1,7 @@ -//! Port of the TS-native shared helpers hard/greedy call -//! (`sims/src/cpu/heuristic-native.ts`): band picks, anti-wall pivot, and -//! the fork-based measurement/scoring kit. Fair-info pool analysis -//! (maxPoolDamage / evaluateDefensiveSwitchFair / …) is not ported — the -//! two shipped strategies never reach it. +//! Shared helpers hard/greedy call: band picks, anti-wall pivot, and the +//! fork-based measurement/scoring kit. Fair-info pool analysis +//! (maxPoolDamage / evaluateDefensiveSwitchFair / …) is deliberately +//! absent — the two shipped strategies never reach it. use chomp_engine::moves::MoveSlotLib; use chomp_engine::Structs::MoveMeta; diff --git a/transpiler/strategies-rs/src/override_cpu.rs b/transpiler/strategies-rs/src/override_cpu.rs index a17d6948..9dedb99b 100644 --- a/transpiler/strategies-rs/src/override_cpu.rs +++ b/transpiler/strategies-rs/src/override_cpu.rs @@ -1,4 +1,4 @@ -//! OVERRIDE CPU — port of `sims/src/cpu/strategies/override-cpu.ts`: a +//! OVERRIDE CPU — a //! scripted-plan pilot wrapping the hard CPU. For the active mon it //! consults a per-mon script (ordered rules with `when` / max-uses //! gates); the first rule whose move is affordable this turn and whose diff --git a/transpiler/strategies-rs/src/roster.rs b/transpiler/strategies-rs/src/roster.rs index 389b67cd..4320dad3 100644 --- a/transpiler/strategies-rs/src/roster.rs +++ b/transpiler/strategies-rs/src/roster.rs @@ -1,6 +1,6 @@ //! Standalone roster loader for the pure-Rust arena — replaces the TS team-builder the FFI arena //! fed in. Reads `drool/*.csv` + the per-move inline JSONs and resolves each mon's move catalog to -//! the packed engine words, a faithful port of `sims/src/arena/team.ts` + `mon-builder.ts` + +//! the packed engine words, mirroring `sims/src/util/mon-builder.ts` + //! `processing/packMoves.py`. Addresses are a deterministic name→address map (the arena only needs //! deploy_all and the move/ability words to agree; the specific values are irrelevant). diff --git a/transpiler/strategies-rs/src/shared.rs b/transpiler/strategies-rs/src/shared.rs index 39bebb5e..459ec9f6 100644 --- a/transpiler/strategies-rs/src/shared.rs +++ b/transpiler/strategies-rs/src/shared.rs @@ -1,10 +1,9 @@ -//! Port of the `HeuristicCPUBase.sol` helpers hard/greedy actually reach -//! (`sims/src/cpu/heuristic-shared.ts`). TS helpers neither strategy calls -//! (mode ladder, fair-info pool analysis, random-option pick, …) are -//! deliberately not ported. Every branch/threshold/formula matches the TS -//! source; integer math stays integral (i64), `Math.floor(int/int)` sites -//! reproduce the JS f64 division + floor (identical results at these -//! magnitudes, and bit-exact by construction). +//! Shared scoring primitives hard/greedy actually reach: damage estimation, +//! KO / best-damage move finders, lead and switch selection. Helpers neither +//! strategy calls (the old mode ladder, fair-info pool analysis, +//! random-option pick, …) are deliberately absent. Integer math stays +//! integral (i64); `Math.floor(int/int)` sites reproduce the JS f64 division +//! + floor (identical results at these magnitudes). use chomp_engine::moves::{AttackCalculator, MoveSlotLib}; use chomp_engine::Enums::{MonStateIndexName, MoveClass, Type}; diff --git a/transpiler/strategies-rs/src/sim.rs b/transpiler/strategies-rs/src/sim.rs index cccdc2a7..e67fb8b8 100644 --- a/transpiler/strategies-rs/src/sim.rs +++ b/transpiler/strategies-rs/src/sim.rs @@ -81,9 +81,8 @@ pub struct Sim { pub world: World, pub battle_key: B256, pub engine_addr: Address, - /// (p0, p1) team sizes, immutable after `startBattle` — cached because - /// the transpiled `getTeamSize` getter deep-clones the whole - /// BattleConfig per call, and view captures read sizes per fork. + /// (p0, p1) team sizes, immutable after `startBattle` — cached so a size + /// read doesn't deep-clone BattleConfig; view captures read sizes per fork. team_sizes: (usize, usize), /// (p0, p1) global mon-ids per team slot — carried from the drafted teams /// so the CPU can look up per-mon config by identity (the engine only @@ -349,6 +348,7 @@ impl Sim { world.Engine._turnP1Packed = pack_turn(m.move_index, m.salt, m.extra_data); } world.Engine.storageKeyForWrite = fork; + world.Engine.battleKeyForWrite = fork; // write-gates (addEffect etc.) check this transient Engine::_executeInternal(world, fork, fork, false, false, false); // slotPacked=false (singles) world.reset_transient(); // fork tx over; capture reads boundary-clean fork @@ -372,11 +372,19 @@ impl Sim { world.Engine._turnP0Packed = Engine::_packSideTurn(side0_packed); world.Engine._turnP1Packed = Engine::_packSideTurn(side1_packed); world.Engine.storageKeyForWrite = fork; + world.Engine.battleKeyForWrite = fork; // write-gates (addEffect etc.) check this transient Engine::_executeInternal(world, fork, fork, false, false, true); // slotPacked=true, silent world.reset_transient(); fork } + /// Clone `src`'s state under a fresh key WITHOUT running a turn — a rollback point for + /// counterfactuals that can only be posed after the real turn has already advanced the battle. + /// Dispose it like any other fork. + pub fn snapshot(&mut self, src: B256) -> B256 { + self.fork_battle_from(src) + } + /// Reclaim a fork's cloned state. pub fn dispose_fork(&mut self, fork: B256) { self.world.Engine.battleData.remove(&fork); diff --git a/transpiler/strategies-rs/src/teams.rs b/transpiler/strategies-rs/src/teams.rs index 5bf99a87..bddcdd65 100644 --- a/transpiler/strategies-rs/src/teams.rs +++ b/transpiler/strategies-rs/src/teams.rs @@ -8,7 +8,8 @@ use std::collections::HashMap; use crate::arena::{build_team_mon, draw_team, Wrand}; use crate::evaluator::DEFAULT_WEIGHTS; -use crate::game::{run_games, GameSpec, StrategyKind}; +use crate::bots; +use crate::game::{run_games, GameSpec, BotName}; use crate::roster::{self, Roster}; /// All k-subsets of 0..n (lexicographic). @@ -74,8 +75,9 @@ pub fn run_team_search(roster: &Roster, games_per_team: usize, wseed: u32, seed_ p1_team: team_ids.iter().map(|&id| build_team_mon(mon(id))).collect(), p0_ids: opp, p1_ids: team_ids.clone(), - p0_strategy: StrategyKind::Greedy, - p1_strategy: StrategyKind::Greedy, + p0_strategy: bots::GREEDY, + p1_strategy: bots::GREEDY, + peek_seat: None, p0_weights: DEFAULT_WEIGHTS, p1_weights: DEFAULT_WEIGHTS, p0_search_depth: 0, diff --git a/transpiler/strategies-rs/src/view.rs b/transpiler/strategies-rs/src/view.rs index ad9b6578..7a9e036b 100644 --- a/transpiler/strategies-rs/src/view.rs +++ b/transpiler/strategies-rs/src/view.rs @@ -1,8 +1,7 @@ //! Seat translation + engine-view readers + candidate enumeration. //! -//! Port of `sims/src/cpu/engine-view.ts` and `sims/src/cpu/battle-view.ts`, -//! with the seat transposition of `sims/src/arena/transpose.ts` folded into -//! a [`Seat`] value instead of a JS Proxy. +//! The seat transposition is folded into a [`Seat`] value rather than a +//! proxy object wrapping the engine. //! //! Convention (inherited from the on-chain CPUs): strategy code sees the //! CPU as p1 and the opponent as p0 — VIRTUAL indices. `Seat` maps virtual @@ -75,11 +74,11 @@ pub fn switch_flag(sim: &mut Sim, seat: Seat, bk: B256) -> u8 { } } -/// Virtual [opp active, cpu active] (the proxied getActiveMonIndex swap). +/// Virtual [opp active, cpu active] — active indices off getBattleContext, seat-swapped. pub fn active_mon_indices(sim: &mut Sim, seat: Seat, bk: B256) -> (usize, usize) { - let r = Engine::getActiveMonIndexForBattleState(&mut sim.world, bk); - let p0 = u64::try_from(r[0]).unwrap() as usize; - let p1 = u64::try_from(r[1]).unwrap() as usize; + let ctx = Engine::getBattleContext(&mut sim.world, bk); + let p0 = ctx.p0ActiveMonIndex as usize; + let p1 = ctx.p1ActiveMonIndex as usize; if seat.flipped() { (p1, p0) } else { @@ -158,9 +157,19 @@ pub fn move_slot(sim: &mut Sim, seat: Seat, bk: B256, vp: u8, mon: usize, mi: us } /// Damage context between the two ACTIVE mons (proxied: both player-index -/// args seat-mapped). +/// args seat-mapped; the engine now takes explicit mon indices, so each +/// side's active is resolved here). pub fn damage_calc_context(sim: &mut Sim, seat: Seat, bk: B256, atk_vp: u8, def_vp: u8) -> DamageCalcContext { - Engine::getDamageCalcContext(&mut sim.world, bk, seat.phys(atk_vp), seat.phys(def_vp)) + let (opp_active, cpu_active) = active_mon_indices(sim, seat, bk); + let active_of = |vp: u8| if vp == VCPU { cpu_active } else { opp_active }; + Engine::getDamageCalcContext( + &mut sim.world, + bk, + seat.phys(atk_vp), + U256::from(active_of(atk_vp) as u64), + seat.phys(def_vp), + U256::from(active_of(def_vp) as u64), + ) } /// `TYPE_CALC.getTypeEffectiveness` — TypeCalculator delegates straight to diff --git a/transpiler/strategies-rs/src/yomi.rs b/transpiler/strategies-rs/src/yomi.rs index a4439282..7b80e570 100644 --- a/transpiler/strategies-rs/src/yomi.rs +++ b/transpiler/strategies-rs/src/yomi.rs @@ -5,7 +5,8 @@ use std::collections::HashMap; use crate::arena::build_specs_with; -use crate::game::{run_games_yomi, StrategyKind}; +use crate::bots; +use crate::game::{run_games_yomi, BotName}; use crate::roster::{self, Roster}; #[derive(Default, Clone)] @@ -41,7 +42,7 @@ pub fn run_yomi_analysis( let book = roster::address_book(); // Drive with greedy (fast); the grid itself is no-peek by construction, so the driver only // decides which positions get sampled, not the tension computed at them. - let pairs = [(StrategyKind::Greedy, StrategyKind::Greedy)]; + let pairs = [(bots::GREEDY, bots::GREEDY)]; let (specs, _) = build_specs_with(roster, games, wseed, seed_base, &pairs); let results = run_games_yomi(&specs, &book, threads); diff --git a/transpiler/transpiler-config-rust.json b/transpiler/transpiler-config-rust.json index cd193277..9b6f93b2 100644 --- a/transpiler/transpiler-config-rust.json +++ b/transpiler/transpiler-config-rust.json @@ -17,6 +17,8 @@ "lib/TargetLib.sol", "lib/ValidatorLogic.sol", "moves/IMoveSet.sol", + "moves/IMoveResolver.sol", + "moves/MoveCommandLib.sol", "moves/MoveSlotLib.sol", "moves/AttackCalculator.sol", "moves/StandardAttack.sol", @@ -73,7 +75,10 @@ ], "dispatchInterfaces": [ "IMoveSet", + "IMoveResolver", "IEffect", + "IEffectResolver", + "IStatusEffect", "IAbility" ], "duckDispatchMethods": [ diff --git a/transpiler/type_system/registry.py b/transpiler/type_system/registry.py index d33f05fb..bc30b48e 100644 --- a/transpiler/type_system/registry.py +++ b/transpiler/type_system/registry.py @@ -24,6 +24,8 @@ class TypeRegistry: def __init__(self): self.structs: Set[str] = set() + # struct name -> raw AST definition (storage slot-layout computation) + self.struct_defs: Dict[str, 'StructDefinition'] = {} self.enums: Set[str] = set() self.constants: Set[str] = set() # Literal numeric values of constants (e.g. MOVE_LANES_PER_MON -> 4), so codegen can @@ -90,6 +92,7 @@ def discover_from_ast(self, ast: 'SourceUnit', rel_path: Optional[str] = None) - # Top-level structs for struct in ast.structs: self.structs.add(struct.name) + self.struct_defs[struct.name] = struct if rel_path and rel_path != 'Structs': self.struct_paths[struct.name] = rel_path self.struct_fields[struct.name] = {} @@ -150,6 +153,7 @@ def discover_from_ast(self, ast: 'SourceUnit', rel_path: Optional[str] = None) - contract_local_structs: Set[str] = set() for struct in contract.structs: self.structs.add(struct.name) + self.struct_defs[struct.name] = struct contract_local_structs.add(struct.name) # Also record struct fields (same as top-level structs) self.struct_fields[struct.name] = {} @@ -198,6 +202,7 @@ def discover_from_ast(self, ast: 'SourceUnit', rel_path: Optional[str] = None) - def merge(self, other: 'TypeRegistry') -> None: """Merge another registry into this one.""" self.structs.update(other.structs) + self.struct_defs.update(other.struct_defs) self.enums.update(other.enums) self.constants.update(other.constants) self.interfaces.update(other.interfaces) diff --git a/transpiler/type_system/slots.py b/transpiler/type_system/slots.py new file mode 100644 index 00000000..e072aeed --- /dev/null +++ b/transpiler/type_system/slots.py @@ -0,0 +1,309 @@ +"""Solidity storage slot packing for structs. + +Both backends model storage by field identity — a Rust struct field, a TS object +property — never by slot. Inline assembly that reads or writes a whole slot +(`sload(x.slot)`) therefore has to be reconciled against every field that slot +packs, and against the bits no field claims: hand-packed Solidity treats that +padding as free real estate, so those bits carry state the declared members +cannot represent. + +This computes the packing so the Yul lowerings in both backends can turn a slot +word into field assignments and back. +""" + +from dataclasses import dataclass +from typing import Dict, List, Optional, Sequence, Set, Tuple + +from ..parser.ast_nodes import StructDefinition, TypeName, VariableDeclaration + +WORD_BYTES = 32 +WORD_BITS = 256 + +#: Generated member holding a slot's undeclared bits. Double-underscored so it +#: cannot collide with a Solidity member name. +SLOT0_FREE_FIELD = '__slot0_free' + + +@dataclass(frozen=True) +class SlotField: + """One declared member's placement inside its slot.""" + + name: str + bit_offset: int + bit_width: int + + @property + def mask(self) -> int: + return ((1 << self.bit_width) - 1) << self.bit_offset + + +@dataclass(frozen=True) +class SlotLayout: + """One 32-byte slot: the members packed into it, plus the leftover bits.""" + + index: int + fields: Tuple[SlotField, ...] + + @property + def claimed_mask(self) -> int: + m = 0 + for f in self.fields: + m |= f.mask + return m + + @property + def free_mask(self) -> int: + return ((1 << WORD_BITS) - 1) & ~self.claimed_mask + + @property + def has_free_bits(self) -> bool: + return self.free_mask != 0 + + +def _int_bytes(name: str, prefix: str) -> Optional[int]: + """`uint96` -> 12. Bare `uint`/`int` is 256-bit.""" + suffix = name[len(prefix):] + if not suffix: + return 32 + if not suffix.isdigit(): + return None + bits = int(suffix) + if bits % 8 or not 8 <= bits <= 256: + return None + return bits // 8 + + +def value_byte_size( + type_name: TypeName, + *, + enum_names: Set[str], + struct_names: Set[str], +) -> Optional[int]: + """Packed byte size of a value type; None when the type takes whole slots.""" + if type_name is None: + return None + if type_name.is_mapping or type_name.is_array: + return None + + name = (type_name.name or '').strip() + if name in ('address', 'address payable'): + return 20 + if name == 'bool': + return 1 + if name.startswith('uint'): + return _int_bytes(name, 'uint') + if name.startswith('int'): + return _int_bytes(name, 'int') + if name in ('bytes', 'string'): + return None + if name.startswith('bytes'): + n = name[5:] + return int(n) if n.isdigit() and 1 <= int(n) <= 32 else None + if name in enum_names: + return 1 + if name in struct_names: + return None + # Remaining user-defined names are contracts/interfaces, i.e. addresses. + return 20 + + +def _whole_slot_span( + type_name: TypeName, + *, + enum_names: Set[str], + struct_names: Set[str], + structs: Dict[str, StructDefinition], +) -> Optional[int]: + """Slots occupied by a member that can't share a slot; None if unknown.""" + if type_name.is_mapping: + return 1 + if type_name.is_array: + # Only a single-dimension fixed array of a sized value type is exact. + if type_name.array_dimensions > 1 or type_name.array_size is None: + return 1 # dynamic arrays occupy exactly one slot (the length) + return None + name = (type_name.name or '').strip() + if name in ('bytes', 'string'): + return 1 + if name in struct_names: + nested = structs.get(name) + if nested is None: + return None + layouts = struct_slots( + nested, enum_names=enum_names, struct_names=struct_names, structs=structs + ) + return len(layouts) if layouts else None + return None + + +def struct_slots( + struct: StructDefinition, + *, + enum_names: Set[str], + struct_names: Set[str], + structs: Optional[Dict[str, StructDefinition]] = None, +) -> List[SlotLayout]: + """Slot layout for `struct`, in declaration order. + + Members pack left-to-right into the current slot and spill to the next when + they no longer fit — Solidity's rule. Computation stops at the first member + whose slot span can't be determined, so callers get exact layouts or none at + all for a given slot, never a guess. + """ + structs = structs or {} + layouts: List[SlotLayout] = [] + pending: List[SlotField] = [] + slot = 0 + used = 0 # bytes consumed in the current slot + + def flush() -> None: + nonlocal pending + if pending: + layouts.append(SlotLayout(index=slot, fields=tuple(pending))) + pending = [] + + members: Sequence[VariableDeclaration] = struct.members or [] + for member in members: + size = value_byte_size( + member.type_name, enum_names=enum_names, struct_names=struct_names + ) + if size is None: + flush() + if used: + slot += 1 + used = 0 + span = _whole_slot_span( + member.type_name, + enum_names=enum_names, + struct_names=struct_names, + structs=structs, + ) + if span is None: + return layouts # unknown span: everything after it is unknowable + slot += span + continue + + if used + size > WORD_BYTES: + flush() + slot += 1 + used = 0 + pending.append( + SlotField(name=member.name, bit_offset=used * 8, bit_width=size * 8) + ) + used += size + + flush() + return layouts + + +def _declared_type_of(ident: str, func) -> Optional[TypeName]: + """The declared TypeName of `ident` within `func`, or None.""" + import dataclasses + + for p in list(func.parameters or []) + list(func.return_parameters or []): + if p.name == ident: + return p.type_name + + found: List[TypeName] = [] + + def walk(node): + if node is None or found: + return + if isinstance(node, (list, tuple)): + for item in node: + walk(item) + return + if isinstance(node, dict): + for item in node.values(): + walk(item) + return + if not dataclasses.is_dataclass(node): + return + if type(node).__name__ == 'VariableDeclaration' and node.name == ident: + found.append(node.type_name) + return + for f in dataclasses.fields(node): + walk(getattr(node, f.name)) + + walk(func.body) + return found[0] if found else None + + +def discover_slot_layouts( + asts, + *, + enum_names: Set[str], + struct_names: Set[str], + struct_defs: Dict[str, StructDefinition], +) -> Dict[str, SlotLayout]: + """Slot-0 layouts for every struct whose raw slot is reached by Yul. + + `asts` is any iterable of parsed SourceUnits. A struct is included only + when its layout is exactly computable, so callers get a precise packing or + nothing — never an approximation. + """ + import dataclasses + import re + + slot_idents = re.compile(r'(\w+)\s*\.\s*slot\b') + touched: Set[str] = set() + + def walk(node, fn_scope): + if node is None: + return + if isinstance(node, (list, tuple)): + for item in node: + walk(item, fn_scope) + return + if isinstance(node, dict): + for item in node.values(): + walk(item, fn_scope) + return + if not dataclasses.is_dataclass(node): + return + cls = type(node).__name__ + if cls == 'FunctionDefinition': + fn_scope = node + elif cls == 'AssemblyStatement' and fn_scope is not None: + code = getattr(getattr(node, 'block', None), 'code', '') or '' + for ident in slot_idents.findall(code): + tn = _declared_type_of(ident, fn_scope) + if tn is not None and tn.name in struct_names: + touched.add(tn.name) + for f in dataclasses.fields(node): + walk(getattr(node, f.name), fn_scope) + + for ast in asts: + walk(ast, None) + + layouts: Dict[str, SlotLayout] = {} + for name in touched: + struct = struct_defs.get(name) + if struct is None: + continue + layout = slot_zero( + struct, + enum_names=enum_names, + struct_names=struct_names, + structs=struct_defs, + ) + if layout is not None: + layouts[name] = layout + return layouts + + +def slot_zero( + struct: StructDefinition, + *, + enum_names: Set[str], + struct_names: Set[str], + structs: Optional[Dict[str, StructDefinition]] = None, +) -> Optional[SlotLayout]: + """The base slot a `.slot` expression addresses, or None if unknown.""" + layouts = struct_slots( + struct, enum_names=enum_names, struct_names=struct_names, structs=structs + ) + for layout in layouts: + if layout.index == 0: + return layout + return None