Skip to content

Commit cf71923

Browse files
authored
feat(arena): SAP-style autobattler — contracts + MCP + Tournament Hall UI (#29)
1 parent c99a241 commit cf71923

28 files changed

Lines changed: 3989 additions & 6 deletions

agent-runner/src/llm.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,28 @@ export function buildSystemPrompt(
432432
" 50% chance to reduce happiness by 30. If happiness hits 0, you CAPTURE it and respawn with 200 ore!",
433433
" Cooldown 30s per hex. Keep trying different hexes!",
434434
"",
435+
"=== ARENA (side-system: async autobattler) ===",
436+
"The Arena is an OPTIONAL side-game layered on the main world. It does NOT touch your hexes — only your ore.",
437+
"You build a 5-slot 'bench' (your 'ghost') from a roster of 12 unit types, then SUBMIT it to the matchmaking pool.",
438+
"Other agents' ghosts will fight yours asynchronously. Wins boost your ELO; losses drop it. The whole battle is deterministic — once you submit, the bench you snapshotted at submit time is what fights.",
439+
"",
440+
"Units span 4 tiers (cost 3 / 4 / 5 / 6 ore each), with abilities triggering on ON_BUY / ON_START / ON_HURT / ON_SELL / ON_DEATH / ON_FRIEND_DEATH.",
441+
"Battles use a simple loop: each turn the side with the highest-ATK living unit hits the opponent's frontmost-living unit. Synergies matter — e.g. Battlemage's ON_BUY (+2 ATK right neighbor) means slot placement is meaningful; Wraith's ON_DEATH summon enables resurrection chains with Spiritbinder; Stoneguard tanks while glass-cannon attackers (Skirmisher, Shadowstalker) deal the damage.",
442+
"",
443+
"Tools:",
444+
" arena_list_units() — see all 12 units (stats + abilities). Look at this BEFORE buying.",
445+
" arena_get_state(agent_id) — see your current bench, ELO, bucket, ore.",
446+
" arena_buy(agent_id, unit_type, slot) — buy unit_type (1-12) into bench slot (0-4). Costs ore from your main pool.",
447+
" arena_submit(agent_id) — push your ghost into matchmaking. Idempotent.",
448+
" arena_get_recent_matches(agent_id) — read 'arena defeat' entries on your evaluation ledger to LEARN from losses.",
449+
"",
450+
"Strategy hints (you discover the rest yourself):",
451+
" - Spend leftover ore in Arena rather than wasting it at the 1000 cap.",
452+
" - Front line tanks shield damage dealers; place Stoneguard / Crystalwarden up front, glass cannons behind.",
453+
" - Battlemage's right-neighbor buff and Crystalwarden's both-neighbor buff reward THINKING about slot order.",
454+
" - Submitting an empty bench reverts — buy at least one unit first.",
455+
" - The Arena is OPTIONAL. Hex play, not Arena, decides the main scoreboard. Use Arena for ore burn, side bragging rights, and reputation via wins.",
456+
"",
435457
"=== RULES ===",
436458
"- ALWAYS call tools. Don't describe intentions — TAKE ACTION.",
437459
"- Every cycle: harvest + build + at least one of (raid, debate, chronicle, scout, diplomacy).",
@@ -535,6 +557,35 @@ export function buildUserPrompt(context: AgentContext): string {
535557
inboxNudge += `\nPREDICTION ALERT: You have ${predictionNotices.length} prediction-related message(s)! Check predictions and bet ore with vote_debate.`;
536558
}
537559

560+
// Arena state surfacing — show the agent its current bench + ELO so it can
561+
// decide whether to refine, submit, or ignore the Arena this cycle.
562+
const arena = context.arenaState as
563+
| { bench?: any[]; elo?: number; bucketId?: number; exists?: boolean; ore?: number }
564+
| null;
565+
let arenaPrompt = "";
566+
if (arena) {
567+
const filledSlots = Array.isArray(arena.bench)
568+
? arena.bench.filter((s: any) => !s.empty).length
569+
: 0;
570+
const benchSummary = Array.isArray(arena.bench)
571+
? arena.bench
572+
.map((s: any) =>
573+
s.empty
574+
? `slot${s.slot}: empty`
575+
: `slot${s.slot}: ${s.name}(${s.atk}/${s.hp})`
576+
)
577+
.join(", ")
578+
: "(unknown)";
579+
arenaPrompt = [
580+
"=== ARENA STATE (side-system, optional) ===",
581+
`Your ghost — ELO ${arena.elo ?? 0}, bucket ${arena.bucketId ?? 0}, ${filledSlots}/5 slots filled.`,
582+
`Bench: ${benchSummary}`,
583+
filledSlots === 0
584+
? "You have NO units yet. If you have spare ore (>= 3), consider arena_list_units → arena_buy → arena_submit to enter the Arena. (Optional.)"
585+
: "Submit with arena_submit to get matched against another ghost. Or refine your bench with more arena_buy.",
586+
].join("\n");
587+
}
588+
538589
// Active Oracle prophecy — surfaced every cycle so betting never depends on the
539590
// perishable inbox notice (which the debate-notice flood evicts quickly).
540591
const aod = context.activeOracleDebate as
@@ -558,6 +609,7 @@ export function buildUserPrompt(context: AgentContext): string {
558609
happinessWarning,
559610
inboxNudge,
560611
oraclePrompt,
612+
arenaPrompt,
561613
"",
562614
"IMPORTANT: Call tools — don't describe intentions. TAKE ACTION NOW.",
563615
"IMPORTANT: Vary your actions each cycle. Harvest + build + (scout or raid or diplomacy or post).",
@@ -588,6 +640,8 @@ export function createToolDefinitions(agentId: number, tools: McpTool[]): ToolDe
588640
"get_my_hexes", "get_score", "harvest",
589641
"build", "attack", "raid", "incite_rebellion", "claim_neutral",
590642
"start_debate", "vote_debate", "write_chronicle", "get_chronicle",
643+
// Arena side-system
644+
"arena_buy", "arena_submit", "arena_get_state", "arena_get_recent_matches",
591645
];
592646

593647
if (selfTools.includes(tool.name)) {

agent-runner/src/mcp.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export async function collectContext(
8383
? Number((self as AgentSnapshot).location)
8484
: undefined;
8585

86-
const [world, nearbyAgents, memories, locationBoard, inbox, myHexes, activeOracleDebate] = await Promise.all([
86+
const [world, nearbyAgents, memories, locationBoard, inbox, myHexes, activeOracleDebate, arenaState] = await Promise.all([
8787
callMcpTool(client, "get_world").then(parseToolJson),
8888
callMcpTool(client, "get_nearby_agents", { agent_id: agentId }).then(parseToolJson),
8989
callMcpTool(client, "read_memories", { agent_id: agentId, count: 10 }).then(parseToolJson),
@@ -93,9 +93,12 @@ export async function collectContext(
9393
callMcpTool(client, "read_inbox", { agent_id: agentId, count: 16 }).then(parseToolJson),
9494
callMcpTool(client, "get_my_hexes", { agent_id: agentId }).then(parseToolJson).catch(() => null),
9595
callMcpTool(client, "get_active_oracle_debate").then(parseToolJson).catch(() => null),
96+
// Arena side-system — pull current ghost state if tool exists. Best-effort: if
97+
// the chain has no ArenaEngine deployed the tool errors and we degrade to null.
98+
callMcpTool(client, "arena_get_state", { agent_id: agentId }).then(parseToolJson).catch(() => null),
9699
]);
97100

98-
return { self, world, nearbyAgents, memories, locationBoard, inbox, myHexes, activeOracleDebate };
101+
return { self, world, nearbyAgents, memories, locationBoard, inbox, myHexes, activeOracleDebate, arenaState };
99102
}
100103

101104
export function parseArguments(toolCall: ToolCall): Record<string, unknown> {
@@ -118,6 +121,8 @@ export function applyAgentDefaults(
118121
"get_my_hexes", "get_score", "harvest",
119122
"build", "attack", "raid", "incite_rebellion", "claim_neutral",
120123
"start_debate", "vote_debate", "write_chronicle", "get_chronicle",
124+
// Arena
125+
"arena_buy", "arena_submit", "arena_get_state", "arena_get_recent_matches",
121126
];
122127

123128
if (selfTools.includes(toolName) && next.agent_id === undefined) {

agent-runner/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface AgentContext {
5353
inbox: unknown;
5454
myHexes: unknown; // agent's owned hex territories
5555
activeOracleDebate?: unknown; // current open Oracle prediction (for betting), or null
56+
arenaState?: unknown; // current Arena ghost (bench, ELO, bucket), or null if Arena unavailable
5657
}
5758

5859
export interface AgentSnapshot {

contracts/foundry.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"lib/forge-std": {
3+
"rev": "4540e4aadda88eeb19a54d2b5ad2117c2c7632ec"
4+
},
5+
"lib/openzeppelin-contracts": {
6+
"rev": "9cfdccd35350f7bcc585cf2ede08cd04e7f0ec10"
7+
},
8+
"lib/openzeppelin-contracts-upgradeable": {
9+
"rev": "25780dbcea4d5124fd517f002f0f8984881c5198"
10+
}
11+
}

contracts/script/Deploy.s.sol

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import "../src/LocationLedger.sol";
99
import "../src/InboxLedger.sol";
1010
import "../src/EvaluationLedger.sol";
1111
import "../src/GameEngine.sol";
12+
import "../src/ArenaEngine.sol";
1213
import "../src/Router.sol";
1314

1415
contract DeployScript is Script {
@@ -82,6 +83,21 @@ contract DeployScript is Script {
8283
engine.setAgentLedger(address(agentLedgerProxy));
8384
engine.setEvaluationLedger(address(evalLedgerProxy));
8485

86+
// ──── Deploy ArenaEngine (side system on top of main world) ────
87+
ArenaEngine arenaImpl = new ArenaEngine();
88+
ERC1967Proxy arenaProxy = new ERC1967Proxy(
89+
address(arenaImpl),
90+
abi.encodeCall(ArenaEngine.initialize, (
91+
address(registry),
92+
address(engine),
93+
address(evalLedgerProxy)
94+
))
95+
);
96+
// Arena needs to be operator so it can call GameEngine.spendOre and
97+
// EvaluationLedger.write.
98+
registry.addOperator(address(arenaProxy));
99+
Router(address(routerProxy)).setArenaEngine(address(arenaProxy));
100+
85101
// ──── Initialize World Bible ────
86102
engine.initWorldBible();
87103

@@ -94,6 +110,7 @@ contract DeployScript is Script {
94110
console.log("InboxLedger (proxy):", address(inboxLedgerProxy));
95111
console.log("EvaluationLedger (proxy):", address(evalLedgerProxy));
96112
console.log("GameEngine (proxy):", address(engineProxy));
113+
console.log("ArenaEngine (proxy):", address(arenaProxy));
97114

98115
string memory json = string.concat(
99116
'{\n',

contracts/script/Upgrade.s.sol

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import "../src/LocationLedger.sol";
1010
import "../src/InboxLedger.sol";
1111
import "../src/EvaluationLedger.sol";
1212
import "../src/GameEngine.sol";
13+
import "../src/ArenaEngine.sol";
1314

1415
/// @notice Upgrade all implementations behind existing proxies.
1516
/// Only requires ROUTER_ADDRESS - resolves everything else on-chain.
@@ -101,6 +102,31 @@ contract UpgradeScript is Script {
101102
}
102103
}
103104

105+
// 5. ArenaEngine: deploy fresh proxy if router slot is unset, else upgrade.
106+
// Same shape as the EvaluationLedger backfill above.
107+
address arenaProxy;
108+
(bool okArena, bytes memory arenaData) = routerProxy.staticcall(abi.encodeWithSignature("arenaEngine()"));
109+
if (okArena && arenaData.length >= 32) {
110+
arenaProxy = abi.decode(arenaData, (address));
111+
}
112+
113+
if (arenaProxy == address(0)) {
114+
console.log("ArenaEngine not found - deploying new proxy...");
115+
ArenaEngine arenaImpl = new ArenaEngine();
116+
ERC1967Proxy newArena = new ERC1967Proxy(
117+
address(arenaImpl),
118+
abi.encodeCall(ArenaEngine.initialize, (registryProxy, engineProxy, evalLedgerProxy))
119+
);
120+
arenaProxy = address(newArena);
121+
Router(routerProxy).setArenaEngine(arenaProxy);
122+
// Arena needs to call GameEngine.spendOre and EvaluationLedger.write.
123+
AgentRegistry(registryProxy).addOperator(arenaProxy);
124+
console.log("ArenaEngine (new):", arenaProxy);
125+
} else {
126+
ArenaEngine(arenaProxy).upgradeToAndCall(address(new ArenaEngine()), "");
127+
console.log("ArenaEngine upgraded:", arenaProxy);
128+
}
129+
104130
vm.stopBroadcast();
105131

106132
console.log("All contracts upgraded successfully");

0 commit comments

Comments
 (0)