From 8a9f431ce3e3ab02398264a7ecade0109854f4d0 Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:28:25 +0800 Subject: [PATCH 1/2] feat: add safe AI opponent provider layer --- README.md | 6 + demo/human-vs-bots/ai-opponent.js | 265 ++++++++++++++++++++++++ demo/human-vs-bots/ai-opponent.test.mjs | 211 +++++++++++++++++++ demo/human-vs-bots/game.js | 242 +++++++++++++++++++--- demo/human-vs-bots/index.html | 14 ++ demo/human-vs-bots/styles.css | 20 +- docs/ai-opponent.md | 104 ++++++++++ scripts/ai-opponent-proxy.mjs | 131 ++++++++++++ scripts/ai-opponent-proxy.test.mjs | 52 +++++ 9 files changed, 1014 insertions(+), 31 deletions(-) create mode 100644 demo/human-vs-bots/ai-opponent.js create mode 100644 demo/human-vs-bots/ai-opponent.test.mjs create mode 100644 docs/ai-opponent.md create mode 100644 scripts/ai-opponent-proxy.mjs create mode 100644 scripts/ai-opponent-proxy.test.mjs diff --git a/README.md b/README.md index ff4aab4..0b53222 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ Alternative prototype route: - OpenAI GPT-4.1 mini (Medium) - OpenAI o1-mini (Very Hard) +Real AI turn decisions can be connected through a safe provider layer for a +server-side cloud proxy, local OpenAI-compatible endpoint, or MCP tool. See +[`docs/ai-opponent.md`](docs/ai-opponent.md) for the provider contract, +configuration, and anti-farm metadata. + ## Web3 / ZK flow in the demo UI includes: @@ -73,6 +78,7 @@ The flow is aligned with Stellar hackathon architecture and is prepared for deep - `scripts/serve-human-vs-bots.sh` - `scripts/serve-civ-lite.sh` - `scripts/game-studio.sh` +- `scripts/ai-opponent-proxy.mjs` ## Godot project diff --git a/demo/human-vs-bots/ai-opponent.js b/demo/human-vs-bots/ai-opponent.js new file mode 100644 index 0000000..03d88cf --- /dev/null +++ b/demo/human-vs-bots/ai-opponent.js @@ -0,0 +1,265 @@ +const HEX_DIRS = [ + [1, 0], [1, -1], [0, -1], + [-1, 0], [-1, 1], [0, 1], +]; + +function key(q, r) { + return `${q},${r}`; +} + +function getMapCell(gameState, q, r) { + return gameState.mapByKey?.[key(q, r)] + || gameState.mapCells?.find(cell => cell.q === q && cell.r === r) + || null; +} + +function getUnitById(gameState, id) { + return [...(gameState.humans || []), ...(gameState.bots || [])] + .find(unit => unit.id === id) || null; +} + +function getCellUnit(gameState, q, r, kind) { + const pos = key(q, r); + const id = kind === 'human' ? gameState.humansByPos?.[pos] : gameState.botsByPos?.[pos]; + return id ? getUnitById(gameState, id) : null; +} + +function isPassable(gameState, q, r) { + const cell = getMapCell(gameState, q, r); + return !!cell && cell.terrain !== 'water'; +} + +function isOccupied(gameState, q, r) { + return !!getCellUnit(gameState, q, r, 'human') || !!getCellUnit(gameState, q, r, 'bot'); +} + +function getNeighbors(gameState, q, r) { + return HEX_DIRS + .map(([dq, dr]) => ({ q: q + dq, r: r + dr })) + .filter(pos => getMapCell(gameState, pos.q, pos.r)); +} + +function compactUnit(unit) { + return { + id: unit.id, + kind: unit.kind, + unitType: unit.unitType, + q: unit.q, + r: unit.r, + hp: unit.hp, + hpMax: unit.hpMax, + }; +} + +function actionsEqual(a, b) { + if (!a || !b || a.type !== b.type || a.unitId !== b.unitId) return false; + if (a.type === 'move' || a.type === 'conquer') return a.q === b.q && a.r === b.r; + if (a.type === 'attack') return a.targetId === b.targetId; + return a.type === 'wait'; +} + +function normalizeAction(rawAction) { + if (typeof rawAction === 'string') { + const parsed = JSON.parse(rawAction); + return normalizeAction(parsed); + } + if (rawAction && typeof rawAction === 'object' && rawAction.action) { + return normalizeAction(rawAction.action); + } + if (!rawAction || typeof rawAction !== 'object') return null; + + const type = String(rawAction.type || '').toLowerCase(); + const unitId = Number(rawAction.unitId); + if (!type || !Number.isFinite(unitId)) return null; + + if (type === 'move' || type === 'conquer') { + return { type, unitId, q: Number(rawAction.q), r: Number(rawAction.r) }; + } + if (type === 'attack') { + return { type, unitId, targetId: Number(rawAction.targetId) }; + } + if (type === 'wait') { + return { type, unitId }; + } + return null; +} + +function getFetch(fetchImpl) { + if (fetchImpl) return fetchImpl; + if (typeof fetch === 'function') return fetch; + throw new Error('No fetch implementation is available for this AI provider.'); +} + +function providerPrompt() { + return [ + 'You are the opponent player in Human-vs-bots.', + 'Choose exactly one legal action from the supplied legalActions array.', + 'Return JSON only, with no markdown or prose.', + ].join(' '); +} + +function extractTextFromMcpResult(result) { + if (typeof result === 'string') return result; + if (result?.text) return result.text; + const content = result?.content; + if (Array.isArray(content)) { + const textItem = content.find(item => typeof item?.text === 'string'); + if (textItem) return textItem.text; + } + return result; +} + +async function readJsonResponse(response, label) { + if (!response.ok) { + const text = typeof response.text === 'function' ? await response.text() : ''; + throw new Error(`${label} failed with ${response.status || 'unknown'} ${text}`.trim()); + } + return response.json(); +} + +export function createProxyProvider({ endpoint = '/api/ai-opponent/decide', model = 'default', fetchImpl } = {}) { + return { + id: 'proxy', + async decideTurn({ gameState, legalActions }) { + const doFetch = getFetch(fetchImpl); + const response = await doFetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, gameState, legalActions }), + }); + const payload = await readJsonResponse(response, 'AI proxy request'); + return normalizeAction(payload); + }, + }; +} + +export function createLocalOpenAIProvider({ baseUrl = 'http://localhost:11434/v1', model = 'llama3.1', apiKey = '', fetchImpl } = {}) { + return { + id: 'local-openai', + async decideTurn({ gameState, legalActions }) { + const doFetch = getFetch(fetchImpl); + const endpoint = `${baseUrl.replace(/\/+$/, '')}/chat/completions`; + const headers = { 'Content-Type': 'application/json' }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + const response = await doFetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + model, + temperature: 0, + messages: [ + { role: 'system', content: providerPrompt() }, + { role: 'user', content: JSON.stringify({ gameState, legalActions }) }, + ], + }), + }); + const payload = await readJsonResponse(response, 'Local AI request'); + return normalizeAction(payload?.choices?.[0]?.message?.content ?? payload); + }, + }; +} + +export function createMcpProvider({ mcpClient, toolName = 'decideTurn' } = {}) { + return { + id: 'mcp', + async decideTurn({ gameState, legalActions }) { + if (!mcpClient || typeof mcpClient.callTool !== 'function') { + throw new Error('MCP client with callTool(name, payload) is not configured.'); + } + const result = await mcpClient.callTool(toolName, { gameState, legalActions }); + return normalizeAction(extractTextFromMcpResult(result)); + }, + }; +} + +export function getAntiFarmMetadata({ matchMode, aiControlsHumanSide, opponentProvider }) { + const aiVsAi = matchMode === 'llm-vs-llm' || !!aiControlsHumanSide; + return { + aiControlsHumanSide: !!aiControlsHumanSide, + aiVsAi, + opponentProvider: opponentProvider || 'heuristic', + rewardEligible: !aiVsAi, + }; +} + +export function getLegalUnitActions({ gameState, unit, teamKind }) { + if (!unit || !unit.alive || unit.acted) return []; + const enemyKind = teamKind === 'human' ? 'bot' : 'human'; + const actions = []; + + for (const pos of getNeighbors(gameState, unit.q, unit.r)) { + const enemy = getCellUnit(gameState, pos.q, pos.r, enemyKind); + if (enemy?.alive) { + actions.push({ type: 'attack', unitId: unit.id, targetId: enemy.id }); + continue; + } + + if (!isPassable(gameState, pos.q, pos.r) || isOccupied(gameState, pos.q, pos.r)) continue; + actions.push({ type: 'move', unitId: unit.id, q: pos.q, r: pos.r }); + + const cell = getMapCell(gameState, pos.q, pos.r); + if (cell && cell.owner !== teamKind) { + actions.push({ type: 'conquer', unitId: unit.id, q: pos.q, r: pos.r }); + } + } + + actions.push({ type: 'wait', unitId: unit.id }); + return actions; +} + +export function serializeGameStateForAI({ gameState, unit, teamKind, legalActions }) { + return { + turn: gameState.turn, + mode: gameState.matchMode, + team: teamKind, + activeUnit: compactUnit(unit), + units: [...(gameState.humans || []), ...(gameState.bots || [])] + .filter(candidate => candidate.alive) + .map(compactUnit), + cells: (gameState.mapCells || []).map(cell => ({ + q: cell.q, + r: cell.r, + terrain: cell.terrain, + owner: cell.owner, + })), + legalActions, + }; +} + +export function isLegalAction(action, legalActions) { + return legalActions.some(legal => actionsEqual(action, legal)); +} + +export function createAIOpponentController({ provider, fallbackDecider }) { + const safeProvider = provider || { id: 'heuristic', decideTurn: async () => null }; + const safeFallback = fallbackDecider || (({ legalActions }) => legalActions.at(-1) || null); + + return { + async decideUnitAction({ gameState, unit, teamKind }) { + const legalActions = getLegalUnitActions({ gameState, unit, teamKind }); + const fallback = (reason) => { + const fallbackAction = normalizeAction(safeFallback({ gameState, unit, teamKind, legalActions })); + const action = isLegalAction(fallbackAction, legalActions) + ? fallbackAction + : legalActions.find(candidate => candidate.type === 'wait') || null; + return { source: 'fallback', reason, action, legalActions }; + }; + + if (!legalActions.length) { + return { source: 'none', reason: 'No legal actions available.', action: null, legalActions }; + } + + try { + const aiState = serializeGameStateForAI({ gameState, unit, teamKind, legalActions }); + const rawAction = await safeProvider.decideTurn({ gameState: aiState, legalActions }); + const action = normalizeAction(rawAction); + if (!isLegalAction(action, legalActions)) { + return fallback('Provider action is not legal for the current turn.'); + } + return { source: safeProvider.id || 'provider', action, legalActions }; + } catch (error) { + return fallback(error instanceof Error ? error.message : String(error)); + } + }, + }; +} diff --git a/demo/human-vs-bots/ai-opponent.test.mjs b/demo/human-vs-bots/ai-opponent.test.mjs new file mode 100644 index 0000000..087b22a --- /dev/null +++ b/demo/human-vs-bots/ai-opponent.test.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAIOpponentController, + createLocalOpenAIProvider, + createMcpProvider, + createProxyProvider, + getAntiFarmMetadata, + getLegalUnitActions, + serializeGameStateForAI, +} from './ai-opponent.js'; + +function makeFixture() { + const cells = [ + { q: 0, r: 0, terrain: 'plains', owner: 'bot' }, + { q: 1, r: 0, terrain: 'plains', owner: 'neutral' }, + { q: 0, r: 1, terrain: 'forest', owner: 'human' }, + { q: 1, r: -1, terrain: 'water', owner: 'neutral' }, + ]; + return { + turn: 4, + selectedAI: 'gpt-4o', + selectedDifficulty: 'normal', + matchMode: 'human-vs-llm', + mapCells: cells, + humans: [{ id: 7, kind: 'human', unitType: 'warrior', q: 0, r: 1, hp: 80, hpMax: 95, alive: true }], + bots: [{ id: 3, kind: 'bot', unitType: 'robot', q: 0, r: 0, hp: 90, hpMax: 120, alive: true }], + mapByKey: Object.fromEntries(cells.map(cell => [`${cell.q},${cell.r}`, cell])), + humansByPos: { '0,1': 7 }, + botsByPos: { '0,0': 3 }, + }; +} + +test('getLegalUnitActions lists only adjacent legal bot actions', () => { + const gameState = makeFixture(); + const unit = gameState.bots[0]; + + const actions = getLegalUnitActions({ gameState, unit, teamKind: 'bot' }); + + assert.deepEqual(actions, [ + { type: 'move', unitId: 3, q: 1, r: 0 }, + { type: 'conquer', unitId: 3, q: 1, r: 0 }, + { type: 'attack', unitId: 3, targetId: 7 }, + { type: 'wait', unitId: 3 }, + ]); +}); + +test('AI controller accepts valid structured JSON from provider', async () => { + const gameState = makeFixture(); + const unit = gameState.bots[0]; + const controller = createAIOpponentController({ + provider: { + id: 'mock-provider', + decideTurn: async () => ({ type: 'move', unitId: 3, q: 1, r: 0 }), + }, + fallbackDecider: () => ({ type: 'wait', unitId: 3 }), + }); + + const decision = await controller.decideUnitAction({ gameState, unit, teamKind: 'bot' }); + + assert.equal(decision.source, 'mock-provider'); + assert.deepEqual(decision.action, { type: 'move', unitId: 3, q: 1, r: 0 }); +}); + +test('AI controller falls back when provider returns an illegal action', async () => { + const gameState = makeFixture(); + const unit = gameState.bots[0]; + const controller = createAIOpponentController({ + provider: { + id: 'bad-provider', + decideTurn: async () => ({ type: 'move', unitId: 3, q: 9, r: 9 }), + }, + fallbackDecider: () => ({ type: 'attack', unitId: 3, targetId: 7 }), + }); + + const decision = await controller.decideUnitAction({ gameState, unit, teamKind: 'bot' }); + + assert.equal(decision.source, 'fallback'); + assert.match(decision.reason, /not legal/); + assert.deepEqual(decision.action, { type: 'attack', unitId: 3, targetId: 7 }); +}); + +test('serializeGameStateForAI exposes compact public state without secrets', () => { + const gameState = makeFixture(); + + const serialized = serializeGameStateForAI({ + gameState, + unit: gameState.bots[0], + teamKind: 'bot', + legalActions: [{ type: 'wait', unitId: 3 }], + }); + + assert.deepEqual(serialized, { + turn: 4, + mode: 'human-vs-llm', + team: 'bot', + activeUnit: { id: 3, kind: 'bot', unitType: 'robot', q: 0, r: 0, hp: 90, hpMax: 120 }, + units: [ + { id: 7, kind: 'human', unitType: 'warrior', q: 0, r: 1, hp: 80, hpMax: 95 }, + { id: 3, kind: 'bot', unitType: 'robot', q: 0, r: 0, hp: 90, hpMax: 120 }, + ], + cells: [ + { q: 0, r: 0, terrain: 'plains', owner: 'bot' }, + { q: 1, r: 0, terrain: 'plains', owner: 'neutral' }, + { q: 0, r: 1, terrain: 'forest', owner: 'human' }, + { q: 1, r: -1, terrain: 'water', owner: 'neutral' }, + ], + legalActions: [{ type: 'wait', unitId: 3 }], + }); + assert.equal(JSON.stringify(serialized).includes('apiKey'), false); +}); + +test('proxy provider posts compact state to backend without browser authorization headers', async () => { + const calls = []; + const provider = createProxyProvider({ + endpoint: '/api/ai-opponent/decide', + model: 'claude-3-5-sonnet', + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + json: async () => ({ action: { type: 'wait', unitId: 3 } }), + }; + }, + }); + + const action = await provider.decideTurn({ + gameState: { turn: 1 }, + legalActions: [{ type: 'wait', unitId: 3 }], + }); + + assert.deepEqual(action, { type: 'wait', unitId: 3 }); + assert.equal(calls[0].url, '/api/ai-opponent/decide'); + assert.equal(calls[0].options.method, 'POST'); + assert.equal(calls[0].options.headers.Authorization, undefined); + assert.deepEqual(JSON.parse(calls[0].options.body), { + model: 'claude-3-5-sonnet', + gameState: { turn: 1 }, + legalActions: [{ type: 'wait', unitId: 3 }], + }); +}); + +test('local OpenAI-compatible provider extracts structured JSON action from chat response', async () => { + const provider = createLocalOpenAIProvider({ + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.1', + fetchImpl: async (url, options) => { + assert.equal(url, 'http://localhost:11434/v1/chat/completions'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers.Authorization, undefined); + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: '{"type":"attack","unitId":3,"targetId":7}' } }], + }), + }; + }, + }); + + const action = await provider.decideTurn({ + gameState: { turn: 1 }, + legalActions: [{ type: 'attack', unitId: 3, targetId: 7 }], + }); + + assert.deepEqual(action, { type: 'attack', unitId: 3, targetId: 7 }); +}); + +test('MCP provider calls configured decideTurn tool', async () => { + const provider = createMcpProvider({ + toolName: 'decideTurn', + mcpClient: { + callTool: async (name, payload) => { + assert.equal(name, 'decideTurn'); + assert.deepEqual(payload.legalActions, [{ type: 'wait', unitId: 3 }]); + return { content: [{ text: '{"type":"wait","unitId":3}' }] }; + }, + }, + }); + + const action = await provider.decideTurn({ + gameState: { turn: 1 }, + legalActions: [{ type: 'wait', unitId: 3 }], + }); + + assert.deepEqual(action, { type: 'wait', unitId: 3 }); +}); + +test('anti-farm metadata marks AI-controlled human side as not reward eligible', () => { + assert.deepEqual(getAntiFarmMetadata({ + matchMode: 'human-vs-llm', + aiControlsHumanSide: false, + opponentProvider: 'local-openai', + }), { + aiControlsHumanSide: false, + aiVsAi: false, + opponentProvider: 'local-openai', + rewardEligible: true, + }); + + assert.deepEqual(getAntiFarmMetadata({ + matchMode: 'llm-vs-llm', + aiControlsHumanSide: true, + opponentProvider: 'mcp', + }), { + aiControlsHumanSide: true, + aiVsAi: true, + opponentProvider: 'mcp', + rewardEligible: false, + }); +}); diff --git a/demo/human-vs-bots/game.js b/demo/human-vs-bots/game.js index 0a8799f..bdeac23 100644 --- a/demo/human-vs-bots/game.js +++ b/demo/human-vs-bots/game.js @@ -1,3 +1,11 @@ +import { + createAIOpponentController, + createLocalOpenAIProvider, + createMcpProvider, + createProxyProvider, + getAntiFarmMetadata, +} from './ai-opponent.js'; + const canvas = document.getElementById('arena'); const ctx = canvas.getContext('2d'); const HEX_SIZE = 34; @@ -47,12 +55,18 @@ const ui = { proofCount: document.getElementById('proofCount'), matchState: document.getElementById('matchState'), selectedAI: document.getElementById('selectedAI'), + selectedAIProvider: document.getElementById('selectedAIProvider'), selectedMode: document.getElementById('selectedMode'), selectedHumanModel: document.getElementById('selectedHumanModel'), selectedDifficulty: document.getElementById('selectedDifficulty'), humanTerritory: document.getElementById('humanTerritory'), botTerritory: document.getElementById('botTerritory'), aiSelect: document.getElementById('aiSelect'), + aiProviderSelect: document.getElementById('aiProviderSelect'), + aiEndpointInput: document.getElementById('aiEndpointInput'), + aiModelInput: document.getElementById('aiModelInput'), + aiConnectionState: document.getElementById('aiConnectionState'), + btnTestAiProvider: document.getElementById('btnTestAiProvider'), modeSelect: document.getElementById('modeSelect'), llmHumanSelect: document.getElementById('llmHumanSelect'), difficultySelect: document.getElementById('difficultySelect'), @@ -80,6 +94,12 @@ const state = { selectedAI: 'claude-3-5-sonnet', selectedHumanModel: 'claude-3-5-sonnet', selectedDifficulty: 'normal', + aiOpponent: { + provider: 'heuristic', + endpoint: '/api/ai-opponent/decide', + model: 'claude-3-5-sonnet', + connectionState: 'Heuristic fallback ready', + }, mapCells: [], mapByKey: {}, humansByPos: {}, @@ -151,6 +171,13 @@ function formatModelLabel(modelId) { return `${p.name} (${p.difficulty})`; } +function aiProviderLabel(providerId = state.aiOpponent.provider) { + if (providerId === 'proxy') return 'Cloud Proxy'; + if (providerId === 'local-openai') return 'Local OpenAI-compatible'; + if (providerId === 'mcp') return 'MCP Tool'; + return 'Heuristic'; +} + const HEX_DIRS = [ [1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1], @@ -535,50 +562,119 @@ function nearestEnemy(unit, enemies) { return { target: best, dist: bestDist }; } -function chooseAiAction(unit, teamKind) { +function chooseHeuristicAction(unit, teamKind, legalActions) { const enemyKind = teamKind === 'human' ? 'bot' : 'human'; - const ownOwner = teamKind; const enemies = (enemyKind === 'human' ? state.humans : state.bots).filter(u => u.alive); - if (!enemies.length) return; + if (!enemies.length) return legalActions.find(action => action.type === 'wait') || null; const profile = teamKind === 'bot' ? getOpponentProfile() : (getHumanSideProfile() || getModelProfile('clawbot-v2')); const style = profile.style; - const adjacentEnemy = getNeighbors(unit.q, unit.r) - .map(c => getCellUnit(c.q, c.r, enemyKind)) - .find(Boolean); - - if (adjacentEnemy) { - attack(unit, adjacentEnemy); - return; - } - - const conquerOption = getNeighbors(unit.q, unit.r) - .map(c => state.mapByKey[key(c.q, c.r)]) - .find(c => c && c.terrain !== 'water' && c.owner !== ownOwner && !isOccupied(c.q, c.r)); + const attackAction = legalActions.find(action => action.type === 'attack'); + if (attackAction) return attackAction; const shouldConquer = style === 'defensive' ? Math.random() < 0.7 : style === 'swarm' ? Math.random() < 0.55 : Math.random() < 0.4; - if (conquerOption && shouldConquer) { - conquerOption.owner = ownOwner; - unit.acted = true; - return; - } + const conquerAction = legalActions.find(action => action.type === 'conquer'); + if (conquerAction && shouldConquer) return conquerAction; const { target } = nearestEnemy(unit, enemies); - if (!target) return; + if (!target) return legalActions.find(action => action.type === 'wait') || null; - const options = getNeighbors(unit.q, unit.r) - .filter(n => isPassable(n.q, n.r) && !isOccupied(n.q, n.r)); - if (!options.length) return; + const moveActions = legalActions.filter(action => action.type === 'move'); + if (!moveActions.length) return legalActions.find(action => action.type === 'wait') || null; - options.sort((a, b) => { + moveActions.sort((a, b) => { const da = hexDistance(a.q, a.r, target.q, target.r); const db = hexDistance(b.q, b.r, target.q, target.r); if (style === 'defensive') return db - da; return da - db; }); - moveUnit(unit, options[0].q, options[0].r); + return moveActions[0]; +} + +function getMcpClient(toolName) { + const bridge = window.HumanVsBotsMcp; + if (bridge && typeof bridge.callTool === 'function') return bridge; + if (bridge && typeof bridge[toolName] === 'function') { + return { callTool: (name, payload) => bridge[name](payload) }; + } + return null; +} + +function createConfiguredAiProvider(unit, teamKind) { + const provider = state.aiOpponent.provider; + if (provider === 'proxy') { + return createProxyProvider({ + endpoint: state.aiOpponent.endpoint || '/api/ai-opponent/decide', + model: state.aiOpponent.model || state.selectedAI, + }); + } + if (provider === 'local-openai') { + return createLocalOpenAIProvider({ + baseUrl: state.aiOpponent.endpoint || 'http://localhost:11434/v1', + model: state.aiOpponent.model || 'llama3.1', + }); + } + if (provider === 'mcp') { + return createMcpProvider({ + toolName: state.aiOpponent.model || 'decideTurn', + mcpClient: getMcpClient(state.aiOpponent.model || 'decideTurn'), + }); + } + return { + id: 'heuristic', + decideTurn: async ({ legalActions }) => chooseHeuristicAction(unit, teamKind, legalActions), + }; +} + +function applyAiAction(action, teamKind) { + if (!action) return false; + const unit = getUnitById(action.unitId); + if (!unit || !unit.alive || unit.acted || unit.kind !== teamKind) return false; + + if (action.type === 'attack') { + const target = getUnitById(action.targetId); + if (!target || !target.alive) return false; + attack(unit, target); + return true; + } + + if (action.type === 'conquer') { + const cell = state.mapByKey[key(action.q, action.r)]; + if (!cell || cell.terrain === 'water' || isOccupied(action.q, action.r)) return false; + cell.owner = teamKind; + unit.acted = true; + log(`${teamKind.toUpperCase()} ${unit.unitType} conquered ${action.q},${action.r}`, teamKind === 'bot' ? 'warn' : 'ok'); + return true; + } + + if (action.type === 'move') { + if (!isPassable(action.q, action.r) || isOccupied(action.q, action.r)) return false; + moveUnit(unit, action.q, action.r); + log(`${teamKind.toUpperCase()} ${unit.unitType} moved`); + return true; + } + + if (action.type === 'wait') { + unit.acted = true; + return true; + } + return false; +} + +async function chooseAiAction(unit, teamKind) { + const provider = createConfiguredAiProvider(unit, teamKind); + const controller = createAIOpponentController({ + provider, + fallbackDecider: ({ legalActions }) => chooseHeuristicAction(unit, teamKind, legalActions), + }); + + const decision = await controller.decideUnitAction({ gameState: state, unit, teamKind }); + if (decision.source === 'fallback' && provider.id !== 'heuristic') { + log(`${aiProviderLabel(provider.id)} returned no legal action; heuristic fallback used`, 'warn'); + } + applyAiAction(decision.action, teamKind); } function countTerritory(owner) { @@ -773,11 +869,18 @@ function handlePlayerCellClick(cell) { } function buildProofSnapshot(tag = 'turn') { + const antiFarm = getAntiFarmMetadata({ + matchMode: state.matchMode, + aiControlsHumanSide: state.matchMode === 'llm-vs-llm', + opponentProvider: state.aiOpponent.provider, + }); const payload = { tag, turn: state.turn, ai: state.selectedAI, + aiOpponentProvider: state.aiOpponent.provider, difficulty: state.selectedDifficulty, + antiFarm, humansAlive: state.humans.filter(u => u.alive).length, botsAlive: state.bots.filter(u => u.alive).length, humanTerritory: countTerritory('human'), @@ -789,7 +892,7 @@ function buildProofSnapshot(tag = 'turn') { return payload; } -function endPlayerTurn() { +async function endPlayerTurn() { if (!state.inMatch) return; if (state.matchMode === 'llm-vs-llm') { @@ -799,13 +902,13 @@ function endPlayerTurn() { produceHumanAiUnit(); for (const unit of state.humans.filter(u => u.alive)) { - chooseAiAction(unit, 'human'); + await chooseAiAction(unit, 'human'); if (checkVictory()) return; } produceBotRobot(); for (const bot of state.bots.filter(u => u.alive)) { - chooseAiAction(bot, 'bot'); + await chooseAiAction(bot, 'bot'); if (checkVictory()) return; } @@ -834,7 +937,7 @@ function endPlayerTurn() { produceBotRobot(); for (const bot of state.bots.filter(u => u.alive)) { - chooseAiAction(bot, 'bot'); + await chooseAiAction(bot, 'bot'); if (checkVictory()) return; } @@ -1033,7 +1136,9 @@ function syncUi() { ui.selectedMode.textContent = state.matchMode === 'llm-vs-llm' ? 'LLM vs LLM' : 'Human vs LLM'; ui.selectedHumanModel.textContent = formatModelLabel(state.selectedHumanModel); ui.selectedAI.textContent = formatModelLabel(state.selectedAI); + ui.selectedAIProvider.textContent = aiProviderLabel(); ui.selectedDifficulty.textContent = state.selectedDifficulty; + ui.aiConnectionState.textContent = state.aiOpponent.connectionState; ui.matchState.textContent = state.inMatch ? 'Running' : 'Idle'; ui.walletState.textContent = state.connected ? 'Wallet: Connected' : 'Wallet: Disconnected'; ui.humanTerritory.textContent = String(countTerritory('human')); @@ -1054,11 +1159,53 @@ function syncUi() { ui.llmHumanSelect.disabled = state.matchMode !== 'llm-vs-llm' || state.inMatch; ui.modeSelect.disabled = state.inMatch; ui.aiSelect.disabled = state.inMatch; + ui.aiProviderSelect.disabled = state.inMatch; + ui.aiEndpointInput.disabled = state.inMatch || state.aiOpponent.provider === 'heuristic' || state.aiOpponent.provider === 'mcp'; + ui.aiModelInput.disabled = state.inMatch || state.aiOpponent.provider === 'heuristic'; + ui.btnTestAiProvider.disabled = state.inMatch; ui.btnEndTurn.classList.toggle('active-turn', canAdvance); ui.btnConquer.classList.toggle('active-turn', state.captureMode); } +function syncAiProviderConfigFromUi() { + state.aiOpponent.provider = ui.aiProviderSelect.value; + state.aiOpponent.endpoint = ui.aiEndpointInput.value.trim(); + state.aiOpponent.model = ui.aiModelInput.value.trim(); + if (state.aiOpponent.provider === 'heuristic') { + state.aiOpponent.connectionState = 'Heuristic fallback ready'; + } else { + state.aiOpponent.connectionState = `${aiProviderLabel()} configured`; + } + syncUi(); +} + +async function testAiProviderConnection() { + syncAiProviderConfigFromUi(); + const sampleUnit = state.bots.find(unit => unit.alive) || state.humans.find(unit => unit.alive); + if (!sampleUnit) { + state.aiOpponent.connectionState = 'No unit available for test'; + syncUi(); + return; + } + + const provider = createConfiguredAiProvider(sampleUnit, sampleUnit.kind); + try { + const action = await provider.decideTurn({ + gameState: { + turn: state.turn, + mode: state.matchMode, + activeUnit: { id: sampleUnit.id, kind: sampleUnit.kind, unitType: sampleUnit.unitType }, + }, + legalActions: [{ type: 'wait', unitId: sampleUnit.id }], + }); + state.aiOpponent.connectionState = action ? `${aiProviderLabel(provider.id)} returned a test action` : `${aiProviderLabel(provider.id)} reachable`; + } catch (error) { + state.aiOpponent.connectionState = `Connection failed: ${error instanceof Error ? error.message : String(error)}`; + } + syncUi(); +} + function resetArena() { const ok = initializeArenaWithRetries(); @@ -1072,6 +1219,12 @@ function resetArena() { state.selectedAI = ui.aiSelect.value; state.selectedHumanModel = ui.llmHumanSelect.value; state.selectedDifficulty = ui.difficultySelect.value; + state.aiOpponent.provider = ui.aiProviderSelect.value; + state.aiOpponent.endpoint = ui.aiEndpointInput.value.trim(); + state.aiOpponent.model = ui.aiModelInput.value.trim(); + state.aiOpponent.connectionState = state.aiOpponent.provider === 'heuristic' + ? 'Heuristic fallback ready' + : `${aiProviderLabel()} configured`; ui.resultBanner.classList.remove('show'); ui.resultBanner.textContent = ''; @@ -1105,10 +1258,32 @@ canvas.addEventListener('wheel', event => { ui.aiSelect.addEventListener('change', () => { state.selectedAI = ui.aiSelect.value; + if (state.aiOpponent.provider === 'heuristic') { + ui.aiModelInput.value = state.selectedAI; + state.aiOpponent.model = state.selectedAI; + } syncUi(); log(`Opponent selected: ${formatModelLabel(state.selectedAI)}`, 'warn'); }); +ui.aiProviderSelect.addEventListener('change', () => { + if (ui.aiProviderSelect.value === 'proxy') { + ui.aiEndpointInput.value = '/api/ai-opponent/decide'; + ui.aiModelInput.value = state.selectedAI; + } else if (ui.aiProviderSelect.value === 'local-openai') { + ui.aiEndpointInput.value = 'http://localhost:11434/v1'; + ui.aiModelInput.value = 'llama3.1'; + } else if (ui.aiProviderSelect.value === 'mcp') { + ui.aiModelInput.value = 'decideTurn'; + } + syncAiProviderConfigFromUi(); + log(`AI decision provider: ${aiProviderLabel()}`, 'warn'); +}); + +ui.aiEndpointInput.addEventListener('change', syncAiProviderConfigFromUi); +ui.aiModelInput.addEventListener('change', syncAiProviderConfigFromUi); +ui.btnTestAiProvider.addEventListener('click', testAiProviderConnection); + ui.llmHumanSelect.addEventListener('change', () => { state.selectedHumanModel = ui.llmHumanSelect.value; syncUi(); @@ -1149,6 +1324,12 @@ ui.btnStart.addEventListener('click', async () => { mode: state.matchMode, llmA: state.selectedHumanModel, ai: state.selectedAI, + aiOpponentProvider: state.aiOpponent.provider, + antiFarm: getAntiFarmMetadata({ + matchMode: state.matchMode, + aiControlsHumanSide: state.matchMode === 'llm-vs-llm', + opponentProvider: state.aiOpponent.provider, + }), difficulty: state.selectedDifficulty, contract: 'CB4VZAT2U3UC6XFK3N23SKRF2NDCMP3QHJYMCHHFMZO7MRQO6DQ2EMYG', }); @@ -1207,6 +1388,7 @@ ui.btnExportProofs.addEventListener('click', () => { game: 'human-vs-bots', mode: 'turn-based-buildings', ai: state.selectedAI, + aiOpponentProvider: state.aiOpponent.provider, difficulty: state.selectedDifficulty, proofs: state.proofs, }, null, 2)], { type: 'application/json' }); diff --git a/demo/human-vs-bots/index.html b/demo/human-vs-bots/index.html index 2a2d517..8e15b7a 100644 --- a/demo/human-vs-bots/index.html +++ b/demo/human-vs-bots/index.html @@ -52,6 +52,19 @@