|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | +pragma solidity ^0.8.20; |
| 3 | + |
| 4 | +import "./AbilityLib.sol"; |
| 5 | +import "./UnitCatalog.sol"; |
| 6 | + |
| 7 | +/// @title ArenaCombat — deterministic ghost-autobattler combat, as an external |
| 8 | +/// linked library. |
| 9 | +/// @notice Extracted from ArenaEngine purely to keep that contract under the |
| 10 | +/// EIP-170 24,576-byte code-size limit. The combat machinery (this + the |
| 11 | +/// inlined AbilityLib event queue + UnitCatalog) deploys here once and is |
| 12 | +/// reached via DELEGATECALL, so its bytecode is NOT counted against |
| 13 | +/// ArenaEngine. Logic is a verbatim move — behavior is unchanged and the |
| 14 | +/// determinism tests pin it. |
| 15 | +/// @dev Public functions are deployed (separately) and linked; internal helpers |
| 16 | +/// inline within this library. ArenaEngine calls `simulate` once per settle |
| 17 | +/// and `simulateWithTrace` / `initialStats` once per view — one DELEGATECALL |
| 18 | +/// each, NOT per combat turn, so gas overhead is negligible. |
| 19 | +library ArenaCombat { |
| 20 | + uint8 internal constant SLOTS = 5; |
| 21 | + |
| 22 | + /// @dev One side's combat snapshot (bench + persistent buy/sell stat overlays). |
| 23 | + struct Side { |
| 24 | + uint8[SLOTS] bench; |
| 25 | + int16[SLOTS] atkOverride; |
| 26 | + int16[SLOTS] hpOverride; |
| 27 | + } |
| 28 | + |
| 29 | + /// @dev A single attack action in the deterministic trace. |
| 30 | + struct Turn { |
| 31 | + uint8 attackerSide; // 0 = match's attacker (left) side, 1 = defender (right) |
| 32 | + uint8 attackerSlot; |
| 33 | + uint8 defenderSlot; |
| 34 | + uint16 damage; |
| 35 | + bool defenderDied; |
| 36 | + } |
| 37 | + |
| 38 | + // ──────────────────── Public entry points (deployed + linked) ──────────────────── |
| 39 | + |
| 40 | + /// @notice Winner-only simulation used by settlement — skips the trace buffer. |
| 41 | + function simulate( |
| 42 | + Side memory left, |
| 43 | + Side memory right, |
| 44 | + uint256 seed, |
| 45 | + uint256 leftAgentId, |
| 46 | + uint256 rightAgentId |
| 47 | + ) public pure returns (uint256 winnerAgentId) { |
| 48 | + AbilityLib.BattleState memory state = _buildBattleState(left, right, seed); |
| 49 | + Turn[] memory nullBuf; // length 0 — _runCombat skips writes |
| 50 | + (winnerAgentId, ) = _runCombat(state, leftAgentId, rightAgentId, seed, nullBuf); |
| 51 | + } |
| 52 | + |
| 53 | + /// @notice Full turn-by-turn replay + winner. View-only; for frontends/replays. |
| 54 | + function simulateWithTrace( |
| 55 | + Side memory left, |
| 56 | + Side memory right, |
| 57 | + uint256 seed, |
| 58 | + uint256 leftAgentId, |
| 59 | + uint256 rightAgentId |
| 60 | + ) public pure returns (Turn[] memory turns, uint256 winnerAgentId) { |
| 61 | + AbilityLib.BattleState memory state = _buildBattleState(left, right, seed); |
| 62 | + Turn[] memory buf = new Turn[](128); |
| 63 | + uint256 turnCount; |
| 64 | + (winnerAgentId, turnCount) = _runCombat(state, leftAgentId, rightAgentId, seed, buf); |
| 65 | + turns = new Turn[](turnCount); |
| 66 | + for (uint256 i = 0; i < turnCount; i++) turns[i] = buf[i]; |
| 67 | + } |
| 68 | + |
| 69 | + /// @notice Per-slot ATK/HP after buy/sell overlays AND all ON_START abilities |
| 70 | + /// resolve — the true starting stats the trace runs on. |
| 71 | + function initialStats(Side memory left, Side memory right, uint256 seed) |
| 72 | + public |
| 73 | + pure |
| 74 | + returns ( |
| 75 | + uint16[SLOTS] memory leftAtk, |
| 76 | + uint16[SLOTS] memory leftHp, |
| 77 | + uint16[SLOTS] memory rightAtk, |
| 78 | + uint16[SLOTS] memory rightHp |
| 79 | + ) |
| 80 | + { |
| 81 | + AbilityLib.BattleState memory state = _buildBattleState(left, right, seed); |
| 82 | + state = AbilityLib.triggerAllOnStart(state); |
| 83 | + for (uint8 i = 0; i < SLOTS; i++) { |
| 84 | + leftAtk[i] = state.left[i].atk; |
| 85 | + leftHp[i] = state.left[i].hp; |
| 86 | + rightAtk[i] = state.right[i].atk; |
| 87 | + rightHp[i] = state.right[i].hp; |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + /// @notice Symmetric ELO update (K=32, bounded linear expected-score approx). |
| 92 | + /// Winner gains the same delta the loser drops. Pinned by tests. |
| 93 | + function eloUpdate(uint16 winnerElo, uint16 loserElo) |
| 94 | + public |
| 95 | + pure |
| 96 | + returns (uint16 newWinner, uint16 newLoser) |
| 97 | + { |
| 98 | + int256 diff = int256(uint256(winnerElo)) - int256(uint256(loserElo)); |
| 99 | + if (diff > 400) diff = 400; |
| 100 | + if (diff < -400) diff = -400; |
| 101 | + // expectedWin ≈ 0.5 + diff/800. delta = K*(1-expectedWin) = 16 - diff/25. |
| 102 | + int256 deltaW = 16 - diff / 25; |
| 103 | + if (deltaW < 1) deltaW = 1; |
| 104 | + if (deltaW > 31) deltaW = 31; |
| 105 | + |
| 106 | + int256 deltaL = deltaW; // symmetric |
| 107 | + |
| 108 | + int256 nw = int256(uint256(winnerElo)) + deltaW; |
| 109 | + int256 nl = int256(uint256(loserElo)) - deltaL; |
| 110 | + if (nw < 0) nw = 0; |
| 111 | + if (nl < 0) nl = 0; |
| 112 | + int256 maxU16 = int256(uint256(type(uint16).max)); |
| 113 | + if (nw > maxU16) nw = maxU16; |
| 114 | + if (nl > maxU16) nl = maxU16; |
| 115 | + newWinner = uint16(uint256(nw)); |
| 116 | + newLoser = uint16(uint256(nl)); |
| 117 | + } |
| 118 | + |
| 119 | + // ──────────────────── Internal combat (inlined within this library) ──────────────────── |
| 120 | + |
| 121 | + /// @dev Shared combat loop. If `buf` is empty the trace step is skipped, |
| 122 | + /// otherwise turns are written until `buf` fills. |
| 123 | + function _runCombat( |
| 124 | + AbilityLib.BattleState memory state, |
| 125 | + uint256 attackerAgentId, |
| 126 | + uint256 defenderAgentId, |
| 127 | + uint256 seed, |
| 128 | + Turn[] memory buf |
| 129 | + ) internal pure returns (uint256 winnerAgentId, uint256 turnCount) { |
| 130 | + // ON_START for everyone (left then right) |
| 131 | + state = AbilityLib.triggerAllOnStart(state); |
| 132 | + |
| 133 | + // Alternate hits: pick highest-ATK alive attacker; defender = front line. |
| 134 | + // Tiebreak lowest slot ("attack 大的先动, 左→右对位"). |
| 135 | + uint8 active = AbilityLib.SIDE_LEFT; |
| 136 | + uint256 safety = 0; |
| 137 | + while ( |
| 138 | + AbilityLib.sideHasLiving(state, AbilityLib.SIDE_LEFT) && |
| 139 | + AbilityLib.sideHasLiving(state, AbilityLib.SIDE_RIGHT) && |
| 140 | + safety < 200 |
| 141 | + ) { |
| 142 | + safety++; |
| 143 | + (uint8 atkSlot, bool foundA) = _pickHighestAtk(state, active); |
| 144 | + uint8 enemy = active == AbilityLib.SIDE_LEFT ? AbilityLib.SIDE_RIGHT : AbilityLib.SIDE_LEFT; |
| 145 | + (uint8 defSlot, bool foundD) = _pickFrontline(state, enemy); |
| 146 | + if (!foundA || !foundD) break; |
| 147 | + |
| 148 | + AbilityLib.Unit memory aUnit = AbilityLib._unitAt(state, active, atkSlot); |
| 149 | + uint16 dmg = aUnit.atk; |
| 150 | + bool died = AbilityLib.dealCombatDamage(state, enemy, defSlot, dmg); |
| 151 | + |
| 152 | + if (turnCount < buf.length) { |
| 153 | + buf[turnCount++] = Turn({ |
| 154 | + attackerSide: active, |
| 155 | + attackerSlot: atkSlot, |
| 156 | + defenderSlot: defSlot, |
| 157 | + damage: dmg, |
| 158 | + defenderDied: died |
| 159 | + }); |
| 160 | + } |
| 161 | + |
| 162 | + active = enemy; |
| 163 | + } |
| 164 | + |
| 165 | + bool leftAlive = AbilityLib.sideHasLiving(state, AbilityLib.SIDE_LEFT); |
| 166 | + bool rightAlive = AbilityLib.sideHasLiving(state, AbilityLib.SIDE_RIGHT); |
| 167 | + if (leftAlive && !rightAlive) { |
| 168 | + winnerAgentId = attackerAgentId; |
| 169 | + } else if (!leftAlive && rightAlive) { |
| 170 | + winnerAgentId = defenderAgentId; |
| 171 | + } else { |
| 172 | + // Draw — deterministic coin from keccak(seed,"draw"), no attacker bias. |
| 173 | + uint256 coin = uint256(keccak256(abi.encode(seed, "draw"))) & 1; |
| 174 | + winnerAgentId = coin == 0 ? attackerAgentId : defenderAgentId; |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + function _buildBattleState(Side memory left, Side memory right, uint256 seed) |
| 179 | + internal |
| 180 | + pure |
| 181 | + returns (AbilityLib.BattleState memory state) |
| 182 | + { |
| 183 | + for (uint8 i = 0; i < SLOTS; i++) { |
| 184 | + state.left[i] = _materialize(left.bench[i], left.atkOverride[i], left.hpOverride[i]); |
| 185 | + state.right[i] = _materialize(right.bench[i], right.atkOverride[i], right.hpOverride[i]); |
| 186 | + } |
| 187 | + state.seed = seed; |
| 188 | + } |
| 189 | + |
| 190 | + function _materialize(uint8 unitType, int16 atkOverride, int16 hpOverride) |
| 191 | + internal |
| 192 | + pure |
| 193 | + returns (AbilityLib.Unit memory u) |
| 194 | + { |
| 195 | + if (unitType == 0) return u; // empty |
| 196 | + ( , uint16 atk, uint16 hp, , AbilityLib.Ability memory ab) = UnitCatalog.getUnit(unitType); |
| 197 | + // Apply persistent buy/sell overlay; floor so a negative buff can't underflow. |
| 198 | + int32 finalAtk = int32(uint32(atk)) + int32(atkOverride); |
| 199 | + int32 finalHp = int32(uint32(hp)) + int32(hpOverride); |
| 200 | + if (finalAtk < 0) finalAtk = 0; |
| 201 | + if (finalHp < 1) finalHp = 1; // a live unit must have at least 1 HP |
| 202 | + u.unitType = unitType; |
| 203 | + u.atk = uint16(uint32(finalAtk)); |
| 204 | + u.hp = uint16(uint32(finalHp)); |
| 205 | + u.alive = true; |
| 206 | + u.spawned = false; |
| 207 | + u.ability = ab; |
| 208 | + } |
| 209 | + |
| 210 | + function _pickHighestAtk(AbilityLib.BattleState memory state, uint8 side) |
| 211 | + internal |
| 212 | + pure |
| 213 | + returns (uint8 slot, bool found) |
| 214 | + { |
| 215 | + uint16 bestAtk; |
| 216 | + for (uint8 i = 0; i < SLOTS; i++) { |
| 217 | + if (AbilityLib._aliveAt(state, side, i)) { |
| 218 | + AbilityLib.Unit memory u = AbilityLib._unitAt(state, side, i); |
| 219 | + // strictly greater, so leftmost wins ties ("左→右对位") |
| 220 | + if (!found || u.atk > bestAtk) { |
| 221 | + bestAtk = u.atk; |
| 222 | + slot = i; |
| 223 | + found = true; |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + function _pickFrontline(AbilityLib.BattleState memory state, uint8 side) |
| 230 | + internal |
| 231 | + pure |
| 232 | + returns (uint8 slot, bool found) |
| 233 | + { |
| 234 | + for (uint8 i = 0; i < SLOTS; i++) { |
| 235 | + if (AbilityLib._aliveAt(state, side, i)) { |
| 236 | + return (i, true); |
| 237 | + } |
| 238 | + } |
| 239 | + return (0, false); |
| 240 | + } |
| 241 | +} |
0 commit comments