Skip to content

Commit 886f683

Browse files
committed
refactor(arena): extract combat into ArenaCombat library (EIP-170 24KB fit)
The wei migration + auto-requeue additions pushed ArenaEngine to 26,494 B, over the 24,576 B contract-size limit (main was already at 24,504, +72 B). optimizer_runs has no effect under via_ir (saves 7 B), so the combat machinery is moved into a separate linked library. - new ArenaCombat library (public simulate / simulateWithTrace / initialStats / eloUpdate): the combat loop + AbilityLib event-queue + UnitCatalog inline here instead of ArenaEngine. Deployed once, reached via DELEGATECALL (one call per settle / view — not per turn), so gas overhead is negligible. - ArenaEngine keeps storage + matchmaking + settlement orchestration; its combat fns become thin wrappers that snapshot the Match into ArenaCombat.Side. - External ABI unchanged (simulateMatch/getInitialStats/settleMatch/ previewEloUpdate same signatures) -> no MCP/frontend changes. Result: ArenaEngine 26,494 -> 21,163 B (+3,413 headroom); ArenaCombat 9,331 B. Behavior identical -- 113 tests pass (determinism/trace tests pin it); the 2 pre-existing incite failures are unrelated. Deploy note: Foundry auto-deploys + links the ArenaCombat library on `new ArenaEngine()`, so Deploy.s.sol / Upgrade.s.sol need no change.
1 parent 3233219 commit 886f683

3 files changed

Lines changed: 273 additions & 232 deletions

File tree

contracts/src/ArenaCombat.sol

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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

Comments
 (0)