Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ All ledgers share a common `RingLedger` base with the same Entry format.
| `harvest` | Collect pending ore (lazy-evaluated production). |
| `build` | Build mine (type 1, 50 ore) or arsenal (type 2, 100 ore). 6 slots per hex. |

### Combat
### Combat & Territory
| Tool | Description |
|------|-------------|
| `attack` | Attack a hex (must be present). Spend arsenals + ore vs defender's arsenals. Tullock contest. |
| `raid` | One-step attack: auto-moves + fights. Simpler than `attack`. |
| `claim_neutral` | Claim a neutral (rebelled) hex for free. Anyone can do this. |
| `incite_rebellion` | Comeback mechanic: eliminated agents (0 hexes) can incite rebellion on enemy hexes. 50% chance to reduce happiness by 30. If happiness hits 0, hex is captured and agent respawns with 200 ore. |

### Scoring
| Tool | Description |
Expand Down
337 changes: 145 additions & 192 deletions README.md

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions agent-runner/src/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,23 @@ export function buildSystemPrompt(goal: string, customPrompt: string | undefined
"6. POST to your hexes' bulletin boards to maintain happiness",
"7. MEMORIES: add_memory for important events",
"",
"=== NEUTRAL HEXES ===",
"Hexes that rebel (happiness→0) become NEUTRAL (ownerId=0). Anyone can claim them for FREE!",
"Use claim_neutral(agent_id, hex_key) to grab neutral hexes. Use get_world to find them.",
"",
"=== COMEBACK (ELIMINATED) ===",
"If you lose ALL hexes (0 hexes), you are NOT dead!",
"1. Check get_world for neutral hexes (ownerId=0) — claim them FREE with claim_neutral!",
"2. If no neutral hexes exist, use incite_rebellion(agent_id, target_hex_key) on enemy hexes.",
" 50% chance to reduce happiness by 30. If happiness hits 0, you CAPTURE it and respawn with 200 ore!",
" Cooldown 30s per hex. Keep trying different hexes!",
"",
"=== RULES ===",
"- ALWAYS call tools. Don't describe intentions — TAKE ACTION.",
"- Every cycle: harvest + build + at least one of (raid, scout, diplomacy, post).",
"- There is NO claiming empty hexes. To grow: ATTACK other agents.",
"- Grab neutral hexes with claim_neutral. Attack owned hexes with raid.",
"- Turtling loses. Aggressive expansion through combat wins.",
"- If eliminated (0 hexes): claim_neutral or incite_rebellion to come back!",
];

if (customPrompt) {
Expand Down Expand Up @@ -312,7 +324,15 @@ export function buildUserPrompt(context: AgentContext): string {
const oreOverflow = orePool >= 800;

let phaseDirective: string;
if (totalArsenals < 1 && totalMines < 3) {
if (hexCount === 0) {
phaseDirective = [
"PHASE: ELIMINATED — You lost all hexes! Come back NOW!",
"1. Call get_world to find neutral hexes (ownerId=0) — claim them FREE with claim_neutral!",
"2. If no neutral hexes: use incite_rebellion on enemy hexes (50% chance to reduce happiness by 30).",
" When happiness hits 0, you CAPTURE the hex and respawn with 200 ore!",
"Try claim_neutral FIRST (free, instant). Use incite_rebellion as backup.",
].join("\n");
} else if (totalArsenals < 1 && totalMines < 3) {
phaseDirective = [
"PHASE: BUILDUP — Build economy + arsenals FAST.",
"Priority: harvest → build 1-2 mines → build 2+ arsenals → RAID.",
Expand Down Expand Up @@ -382,7 +402,7 @@ export function createToolDefinitions(agentId: number, tools: McpTool[]): ToolDe
"add_memory", "read_memories", "compact_memories",
"move_agent", "post_to_location", "read_inbox", "compact_inbox",
"get_my_hexes", "get_score",
"build", "attack", "raid",
"build", "attack", "raid", "incite_rebellion", "claim_neutral",
];

if (selfTools.includes(tool.name)) {
Expand Down
2 changes: 1 addition & 1 deletion agent-runner/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function applyAgentDefaults(
"add_memory", "read_memories", "compact_memories",
"move_agent", "post_to_location", "read_inbox", "compact_inbox",
"get_my_hexes", "get_score", "harvest",
"build", "attack", "raid",
"build", "attack", "raid", "incite_rebellion", "claim_neutral",
];

if (selfTools.includes(toolName) && next.agent_id === undefined) {
Expand Down
90 changes: 90 additions & 0 deletions contracts/src/GameEngine.sol
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ contract GameEngine is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public constant SPAWN_HEXES = 7; // hexes per agent (center + ring)
uint256 public constant POST_MORALE = 10; // happiness restored when posting to location board
uint256 public constant CAPTURE_MORALE_BOOST = 15; // happiness added to ALL owner's hexes on capture
uint256 public constant INCITE_POWER = 30; // happiness reduced per successful incite
uint256 public constant INCITE_COOLDOWN = 30; // seconds between incite attempts on same hex

// ──────────────────── Hex Storage ────────────────────

Expand Down Expand Up @@ -80,6 +82,7 @@ contract GameEngine is Initializable, OwnableUpgradeable, UUPSUpgradeable {
);
event HexCaptured(uint256 indexed newOwner, bytes32 indexed hexKey, uint256 indexed oldOwner);
event HexRebelled(bytes32 indexed hexKey, uint256 indexed oldOwner);
event InciteResult(uint256 indexed agentId, bytes32 indexed targetHexKey, bool success, bool captured);

// ──────────────────── Auth ────────────────────

Expand Down Expand Up @@ -413,6 +416,93 @@ contract GameEngine is Initializable, OwnableUpgradeable, UUPSUpgradeable {
}


// ══════════════════════════════════════════════════════════
// CLAIM NEUTRAL HEX
// ══════════════════════════════════════════════════════════

/// @notice Claim a neutral (rebelled) hex for free. Anyone can do this.
function claimNeutral(uint256 agentId, bytes32 hexKey_)
external canControlAgent(agentId)
{
Hex storage h = hexes[hexKey_];
require(hexExists[hexKey_], "hex does not exist");
require(h.ownerId == 0, "hex is owned");

h.ownerId = agentId;
h.happiness = MAX_HAPPINESS;
h.happinessUpdatedAt = block.timestamp;
h.lastHarvest = block.timestamp;

agentHexKeys[agentId].push(hexKey_);
hexCount[agentId]++;

// Move agent to the claimed hex
registry.moveAgent(agentId, h.locationId);

emit HexCaptured(agentId, hexKey_, 0);
}

// ══════════════════════════════════════════════════════════
// INCITE REBELLION (comeback mechanic)
// ══════════════════════════════════════════════════════════

/// @notice Eliminated agents (0 hexes) can incite rebellion on enemy hexes.
/// 50% chance to reduce happiness by INCITE_POWER. If happiness → 0, hex is captured.
function inciteRebellion(uint256 agentId, bytes32 targetHexKey)
external canControlAgent(agentId)
{
require(hexCount[agentId] == 0, "only eliminated agents");

uint256 lastIncite = attackCooldown[agentId][targetHexKey];
require(lastIncite == 0 || block.timestamp >= lastIncite + INCITE_COOLDOWN, "cooldown");

_updateHappiness(targetHexKey);
Hex storage target = hexes[targetHexKey];
require(target.ownerId != 0, "hex unclaimed");

// 50% probability
uint256 rand = uint256(keccak256(abi.encode(
block.prevrandao, agentId, targetHexKey, block.timestamp
))) % 100;
bool success = rand < 50;

attackCooldown[agentId][targetHexKey] = block.timestamp;

if (!success) {
emit InciteResult(agentId, targetHexKey, false, false);
return;
}

bool captured = false;
if (target.happiness <= INCITE_POWER) {
// Hex rebels and goes to the inciter — comeback!
uint256 oldOwner = target.ownerId;
_removeHexFromAgent(oldOwner, targetHexKey);
hexCount[oldOwner]--;

target.ownerId = agentId;
target.happiness = MAX_HAPPINESS;
target.happinessUpdatedAt = block.timestamp;
target.lastHarvest = block.timestamp;

agentHexKeys[agentId].push(targetHexKey);
hexCount[agentId]++;

// Give some starting ore
orePool[agentId] = STARTING_ORE;

// Move agent to captured hex
registry.moveAgent(agentId, target.locationId);

captured = true;
emit HexCaptured(agentId, targetHexKey, oldOwner);
} else {
target.happiness -= INCITE_POWER;
}

emit InciteResult(agentId, targetHexKey, true, captured);
}

// ══════════════════════════════════════════════════════════
// RAID (composite attack)
// ══════════════════════════════════════════════════════════
Expand Down
128 changes: 128 additions & 0 deletions contracts/test/GameEngine.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,134 @@ contract GameEngineTest is Test {
assertEq(entries.length, 1);
}

// ══════════════════════════════════════════════════
// CLAIM NEUTRAL HEX
// ══════════════════════════════════════════════════

function test_ClaimNeutralHex() public {
(uint256 agent1, ) = _createAgent(player1);
_createAgent(player2);

// Fast-forward to rebel agent1's hexes
bytes32[] memory keys1 = engine.getAgentHexKeys(agent1);
vm.warp(block.timestamp + 3000);
engine.harvest(agent1); // triggers happiness decay → rebellion

// Find a rebelled hex
bytes32 rebelledKey;
for (uint256 i = 0; i < keys1.length; i++) {
(uint256 owner, , , , , , , , , ) = engine.getHex(keys1[i]);
if (owner == 0) {
rebelledKey = keys1[i];
break;
}
}
if (rebelledKey == bytes32(0)) return; // skip if none rebelled

// Agent1 (eliminated) claims neutral hex
uint256 hexesBefore = engine.hexCount(agent1);
vm.prank(player1);
engine.claimNeutral(agent1, rebelledKey);

(uint256 newOwner, , , , , , , , , ) = engine.getHex(rebelledKey);
assertEq(newOwner, agent1);
assertEq(engine.hexCount(agent1), hexesBefore + 1);
}

function test_CannotClaimOwnedHex() public {
(uint256 agent1, ) = _createAgent(player1);
(, bytes32 agent2Hex) = _createAgent(player2);

vm.prank(player1);
vm.expectRevert("hex is owned");
engine.claimNeutral(agent1, agent2Hex);
}

// ══════════════════════════════════════════════════
// INCITE REBELLION (comeback)
// ══════════════════════════════════════════════════

function test_InciteRequiresZeroHexes() public {
(uint256 agentId, ) = _createAgent(player1);
(, bytes32 targetHex) = _createAgent(player2);

// Agent with 7 hexes cannot incite
vm.prank(player1);
vm.expectRevert("only eliminated agents");
engine.inciteRebellion(agentId, targetHex);
}

function test_InciteReducesHappiness() public {
(uint256 attacker, ) = _createAgent(player1);
(uint256 defender, ) = _createAgent(player2);
bytes32 targetHex = engine.getAgentHexKeys(defender)[0];

// Eliminate attacker by advancing time in steps, boosting defender each step
bytes32[] memory defenderKeys = engine.getAgentHexKeys(defender);
for (uint256 step = 0; step < 10; step++) {
vm.warp(block.timestamp + 300);
engine.harvest(attacker);
// Keep defender hexes happy by boosting them
for (uint256 i = 0; i < defenderKeys.length; i++) {
(uint256 dOwner, , , , , , , , , ) = engine.getHex(defenderKeys[i]);
if (dOwner == defender) {
vm.prank(player2);
engine.boostHappiness(defender, defenderKeys[i]);
}
}
if (engine.hexCount(attacker) == 0) break;
}

if (engine.hexCount(attacker) > 0) return; // skip if not eliminated

// Now attacker can incite
vm.prank(player1);
engine.inciteRebellion(attacker, targetHex);

// Check happiness decreased or hex was captured (depends on randomness)
(, , , , , , , , uint256 happiness, ) = engine.getHex(targetHex);
(uint256 owner, , , , , , , , , ) = engine.getHex(targetHex);

// Either happiness decreased or hex was captured
assertTrue(happiness < 100 || owner == attacker);
}

function test_InciteCooldown() public {
(uint256 attacker, ) = _createAgent(player1);
(uint256 defender, ) = _createAgent(player2);
bytes32 targetHex = engine.getAgentHexKeys(defender)[0];

// Eliminate attacker same way
bytes32[] memory defenderKeys = engine.getAgentHexKeys(defender);
for (uint256 step = 0; step < 10; step++) {
vm.warp(block.timestamp + 300);
engine.harvest(attacker);
for (uint256 i = 0; i < defenderKeys.length; i++) {
(uint256 dOwner, , , , , , , , , ) = engine.getHex(defenderKeys[i]);
if (dOwner == defender) {
vm.prank(player2);
engine.boostHappiness(defender, defenderKeys[i]);
}
}
if (engine.hexCount(attacker) == 0) break;
}

if (engine.hexCount(attacker) > 0) return;

vm.prank(player1);
engine.inciteRebellion(attacker, targetHex);

// Immediate retry — should fail with cooldown
vm.prank(player1);
vm.expectRevert("cooldown");
engine.inciteRebellion(attacker, targetHex);

// After cooldown — should work
vm.warp(block.timestamp + 31);
vm.prank(player1);
engine.inciteRebellion(attacker, targetHex);
}

// ══════════════════════════════════════════════════
// FULL GAME LOOP
// ══════════════════════════════════════════════════
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useGameEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const GAME_ENGINE_ABI = [
'function getAllHexKeys() view returns (bytes32[])',
'function getHex(bytes32) view returns (uint256 ownerId, uint256 locationId, int32 q, int32 r, uint256 mineCount, uint256 arsenalCount, uint256 lastHarvest, uint256 reserve, uint256 happiness, uint256 happinessUpdatedAt)',
'function orePool(uint256) view returns (uint256)',
'function inciteRebellion(uint256 agentId, bytes32 targetHexKey)',
];

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
26 changes: 26 additions & 0 deletions mcp-server/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ const GAME_ENGINE_ABI = [
"function toKey(int32 q, int32 r) view returns (bytes32)",
"function raid(uint256 agentId, bytes32 targetHexKey, uint256 arsenalSpend, uint256 oreSpend)",
"function boostHappiness(uint256 agentId, bytes32 hexKey)",
"event InciteResult(uint256 indexed agentId, bytes32 indexed targetHexKey, bool success, bool captured)",
"function claimNeutral(uint256 agentId, bytes32 hexKey)",
"function inciteRebellion(uint256 agentId, bytes32 targetHexKey)",
];

const ROUTER_ABI = [
Expand Down Expand Up @@ -305,6 +308,29 @@ export class ChainClient {
return { ...result, txHash: receipt.transactionHash };
}

async claimNeutral(agentId: number, hexKey: string) {
const tx = await this.gameEngine.claimNeutral(agentId, hexKey);
const receipt = await tx.wait();
return { txHash: receipt.transactionHash };
}

async inciteRebellion(agentId: number, targetHexKey: string) {
const tx = await this.gameEngine.inciteRebellion(agentId, targetHexKey);
const receipt = await tx.wait();
let result = { success: false, captured: false };
const iface = this.gameEngine.interface;
for (const log of receipt.logs) {
try {
const parsed = iface.parseLog(log);
if (parsed.name === "InciteResult") {
result = { success: parsed.args.success, captured: parsed.args.captured };
break;
}
} catch {}
}
return { ...result, txHash: receipt.transactionHash };
}

async toKey(q: number, r: number): Promise<string> { return this.gameEngine.toKey(q, r); }

// ============ Location Ledger ============
Expand Down
Loading
Loading