gridlords
Version:
Gridlords — a brutal minimalist turn-based strategy war game for the terminal, featuring PvP and AI (Gemini) modes.
1,057 lines (1,035 loc) • 53.9 kB
JavaScript
#!/usr/bin/env node
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const readline_1 = __importDefault(require("readline"));
function displayHelp() {
console.log(`
██████╗ ██████╗ ██╗██████╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗
██╔════╝ ██╔══██╗██║██╔══██╗██║ ██╔═══██╗██╔══██╗██╔══██╗██╔════╝
██║ ███╗██████╔╝██║██║ ██║██║ ██║ ██║██████╔╝██║ ██║███████╗
██║ ██║██╔══██╗██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║╚════██║
╚██████╔╝██║ ██║██║██████╔╝███████╗╚██████╔╝██║ ██║██████╔╝███████║
╚═════╝ ╚═╝ ╚═╝╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
GRIDLORDS - GAME MANUAL
===========================================
Objective:
-----------
Conquer and control ${GameConstants.VICTORY_CONDITION_CELLS} cells on a ${GameConstants.GRID_SIZE}x${GameConstants.GRID_SIZE} grid.
Become the one and only Supreme Grid Lord.
Gameplay Basics:
----------------
- Turn-based battle: Player X vs Player O.
- On each turn, you perform ONE action: Conquer, Fortify, or Attack.
- The board uses Row Letters (A-E) and Column Numbers (1-5), ex: A1, C3, E5.
Actions:
--------
1. **Conquer [CELL]** (Ex: C B3)
- Target an EMPTY cell (' ') adjacent (horizontally or vertically) to your territory.
- If the empty cell contains a Shield (⛨), you conquer the cell AND keep the shield.
- You cannot conquer cells with initial Power Sources (∆) or Magic Wells (✶) directly (these must be captured via attack if owned by enemy, or maybe special actions later).
2. **Fortify [CELL]** (Ex: F A1)
- Fortify a cell you already control.
- Adds a Shield (⛨), granting +${GameConstants.SHIELD_DEFENSE_MODIFIER} defense bonus.
- Removes ∆ and ✶ if present on the fortified cell.
- Cannot fortify if the cell already has a Shield.
3. **Attack [CELL]** (Ex: A D4)
- Attack an enemy-controlled adjacent cell.
- Roll a six-sided die (1-${GameConstants.MAX_DICE_ROLL}) for both attacker and defender.
- Defender adds +${GameConstants.SHIELD_DEFENSE_MODIFIER} if the cell has a Shield (⛨).
- If attacker's roll > defender's roll, the attack succeeds:
- You capture the cell.
- Any Shield (⛨) is destroyed.
- Any ∆ or ✶ is captured (no special effect yet).
- If the attack fails, the cell remains enemy-controlled.
Special Items:
--------------
- **∆ Power Source**: Unimplemented future effects.
- **✶ Magic Well**: Unimplemented future effects.
- **⛨ Shield**: Fortifies a cell with +${GameConstants.SHIELD_DEFENSE_MODIFIER} defense bonus.
Game Modes:
-----------
- **PvP (Player vs Player)**: Human vs Human.
- **PvE (Player vs AI)**: Battle against a Gemini-powered AI (set your GEMINI_API_KEY to enable).
Controls:
---------
- During your turn, input the action and target coordinate, separated by a space:
- C B3 — Conquer B3
- F A1 — Fortify A1
- A E4 — Attack E4
- Input is case-insensitive and automatically capitalized.
Command Line Options:
---------------------
- Run with '--help' or '-h' to display this manual.
(Ex: npx gridlords -h)
---
Good luck, Grid Lord.
Command. Conquer. Survive.
`);
process.exit(0);
}
var GameConstants;
(function (GameConstants) {
GameConstants.GRID_SIZE = 5;
GameConstants.PLAYER_MARKS = ['X', 'O'];
GameConstants.AI_PLAYER_ID = 1;
GameConstants.AI_PLAYER_MARK = GameConstants.PLAYER_MARKS[GameConstants.AI_PLAYER_ID];
GameConstants.SPECIAL_TYPES = ['∆', '✶', '⛨'];
GameConstants.VICTORY_CONDITION_CELLS = 13;
GameConstants.INITIAL_SPECIAL_CELLS = 3;
GameConstants.POWER_SOURCE_ATTACK_BONUS = 1;
GameConstants.MAGIC_WELL_DEFENSE_BONUS = 1;
GameConstants.SHIELD_DEFENSE_MODIFIER = 1;
GameConstants.MIN_DICE_ROLL = 1;
GameConstants.MAX_DICE_ROLL = 6;
GameConstants.ASCII_A_OFFSET = 65;
GameConstants.GEMINI_API_KEY = process.env.GEMINI_API_KEY;
GameConstants.GEMINI_API_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent`;
GameConstants.AI_REQUEST_TIMEOUT_MS = 20000;
GameConstants.AI_MAX_RETRIES = 2;
})(GameConstants || (GameConstants = {}));
var GameUtils;
(function (GameUtils) {
function toCoordinateString(coord) {
return `${coord.row},${coord.col}`;
}
GameUtils.toCoordinateString = toCoordinateString;
function fromCoordinateString(coordStr) {
const [row, col] = coordStr.split(',').map(Number);
return { row, col };
}
GameUtils.fromCoordinateString = fromCoordinateString;
function formatCoordForUser(coord) {
const rowLetter = String.fromCharCode(GameConstants.ASCII_A_OFFSET + coord.row);
const colNumber = coord.col + 1;
return `${rowLetter}${colNumber}`;
}
GameUtils.formatCoordForUser = formatCoordForUser;
function parseCoordinateInput(input) {
const sanitizedInput = input.trim().toUpperCase();
const match = sanitizedInput.match(/^([A-Z])\s?([1-9]\d*)$/);
if (!match) {
return null;
}
const rowChar = match[1];
const colStr = match[2];
const row = rowChar.charCodeAt(0) - GameConstants.ASCII_A_OFFSET;
const col = parseInt(colStr, 10) - 1;
if (row < 0 || row >= GameConstants.GRID_SIZE || col < 0 || col >= GameConstants.GRID_SIZE) {
const maxRowChar = String.fromCharCode(GameConstants.ASCII_A_OFFSET + GameConstants.GRID_SIZE - 1);
const maxCol = GameConstants.GRID_SIZE;
return null;
}
return { row, col };
}
GameUtils.parseCoordinateInput = parseCoordinateInput;
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
GameUtils.randomInt = randomInt;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
GameUtils.sleep = sleep;
function getRandomElement(arr) {
if (arr.length === 0)
return undefined;
return arr[randomInt(0, arr.length - 1)];
}
GameUtils.getRandomElement = getRandomElement;
})(GameUtils || (GameUtils = {}));
var Core;
(function (Core) {
class Grid {
constructor(size) {
if (size <= 0) {
throw new Error("Grid size must be positive.");
}
this.size = size;
this.cells = Array.from({ length: size }, () => Array(size).fill(' '));
}
setCell(coord, value) {
if (this.isValidCoordinate(coord)) {
this.cells[coord.row][coord.col] = value;
}
else {
console.warn(`Attempted to set cell outside grid bounds: ${GameUtils.formatCoordForUser(coord)}`);
}
}
getCell(coord) {
if (this.isValidCoordinate(coord)) {
return this.cells[coord.row][coord.col];
}
console.warn(`Attempted to get cell outside grid bounds: ${GameUtils.formatCoordForUser(coord)}`);
return ' ';
}
isValidCoordinate(coord) {
return (coord.row >= 0 && coord.row < this.size &&
coord.col >= 0 && coord.col < this.size);
}
getSize() {
return this.size;
}
}
Core.Grid = Grid;
class Player {
constructor(id) {
this.id = id;
this.mark = GameConstants.PLAYER_MARKS[id];
this.positions = new Set();
this.ownedPowerSources = new Set();
this.ownedMagicWells = new Set();
}
addPosition(coordStr) {
this.positions.add(coordStr);
}
removePosition(coordStr) {
this.positions.delete(coordStr);
this.removePowerSource(coordStr);
this.removeMagicWell(coordStr);
}
ownsPosition(coordStr) {
return this.positions.has(coordStr);
}
getPositions() {
return this.positions;
}
getPositionCount() {
return this.positions.size;
}
ownsAnyPowerSource() {
return this.ownedPowerSources.size > 0;
}
addPowerSource(coordStr) {
this.ownedPowerSources.add(coordStr);
}
removePowerSource(coordStr) {
this.ownedPowerSources.delete(coordStr);
}
ownsAnyMagicWell() {
return this.ownedMagicWells.size > 0;
}
addMagicWell(coordStr) {
this.ownedMagicWells.add(coordStr);
}
removeMagicWell(coordStr) {
this.ownedMagicWells.delete(coordStr);
}
getMagicWells() {
return this.ownedMagicWells;
}
}
Core.Player = Player;
})(Core || (Core = {}));
var Rules;
(function (Rules) {
class GameRules {
isAdjacent(targetCoord, player) {
for (const posStr of player.getPositions()) {
const ownedCoord = GameUtils.fromCoordinateString(posStr);
const distance = Math.abs(ownedCoord.row - targetCoord.row) + Math.abs(ownedCoord.col - targetCoord.col);
if (distance === 1) {
return true;
}
}
return false;
}
checkWinner(players) {
for (const player of players) {
if (player.getPositionCount() >= GameConstants.VICTORY_CONDITION_CELLS) {
return player.id;
}
}
return null;
}
rollAttackDice(attackerPlayer) {
const baseRoll = GameUtils.randomInt(GameConstants.MIN_DICE_ROLL, GameConstants.MAX_DICE_ROLL);
const powerBonus = attackerPlayer.ownsAnyPowerSource() ? GameConstants.POWER_SOURCE_ATTACK_BONUS : 0;
return baseRoll + powerBonus;
}
rollDefenseDice(defendingPlayer, defendedCoord, isShielded, activeMagicWellBonusTarget) {
const baseRoll = GameUtils.randomInt(GameConstants.MIN_DICE_ROLL, GameConstants.MAX_DICE_ROLL);
const shieldBonus = isShielded ? GameConstants.SHIELD_DEFENSE_MODIFIER : 0;
const defendedCoordStr = GameUtils.toCoordinateString(defendedCoord);
const magicBonus = (activeMagicWellBonusTarget === defendedCoordStr) ? GameConstants.MAGIC_WELL_DEFENSE_BONUS : 0;
return baseRoll + shieldBonus + magicBonus;
}
isValid(r, c, gridSize) {
return r >= 0 && r < gridSize && c >= 0 && c < gridSize;
}
getValidMoves(player, opponent, grid, specials) {
const validMoves = [];
const gridSize = grid.getSize();
const playerPositions = player.getPositions();
for (const posStr of playerPositions) {
const ownedCoord = GameUtils.fromCoordinateString(posStr);
const deltas = [[-1, 0], [1, 0], [0, -1], [0, 1]];
if (specials[posStr] !== '⛨') {
validMoves.push({ action: 'FORTIFY', coord: ownedCoord });
}
for (const [dr, dc] of deltas) {
const targetRow = ownedCoord.row + dr;
const targetCol = ownedCoord.col + dc;
const targetCoord = { row: targetRow, col: targetCol };
if (this.isValid(targetRow, targetCol, gridSize)) {
const targetCoordStr = GameUtils.toCoordinateString(targetCoord);
const cellState = grid.getCell(targetCoord);
if (cellState === ' ') {
validMoves.push({ action: 'CONQUER', coord: targetCoord });
}
else if (opponent.ownsPosition(targetCoordStr)) {
validMoves.push({ action: 'ATTACK', coord: targetCoord });
}
}
}
}
const uniqueMoves = new Map();
for (const move of validMoves) {
const key = `${move.action}:${GameUtils.toCoordinateString(move.coord)}`;
if (!uniqueMoves.has(key)) {
uniqueMoves.set(key, move);
}
}
return Array.from(uniqueMoves.values());
}
getValidMagicWellTargets(player) {
const targets = [];
for (const posStr of player.getPositions()) {
targets.push(GameUtils.fromCoordinateString(posStr));
}
return targets;
}
}
Rules.GameRules = GameRules;
})(Rules || (Rules = {}));
var UI;
(function (UI) {
class TerminalUI {
constructor() {
this.rl = readline_1.default.createInterface({
input: process.stdin,
output: process.stdout,
});
this.rl.on('SIGINT', () => {
this.displayMessage("\nExiting Gridlords. Goodbye!");
this.close();
process.exit(0);
});
}
clearScreen() {
console.log('\n');
}
renderBoard(grid, specials, magicWellBonusTarget) {
const size = grid.getSize();
let header = ' ';
for (let c = 0; c < size; c++) {
header += ` ${c + 1} `;
}
console.log(header);
console.log(' ' + '+---'.repeat(size) + '+');
for (let r = 0; r < size; r++) {
const rowLetter = String.fromCharCode(GameConstants.ASCII_A_OFFSET + r);
let line = `${rowLetter} |`;
for (let c = 0; c < size; c++) {
const coord = { row: r, col: c };
const coordStr = GameUtils.toCoordinateString(coord);
const special = specials[coordStr];
const cellContent = grid.getCell(coord);
let displayChar = cellContent;
if (special) {
displayChar = special;
}
let bonusIndicator = '';
if (magicWellBonusTarget === coordStr) {
bonusIndicator = '+';
}
const cellDisplay = `${displayChar}${bonusIndicator}`;
line += ` ${cellDisplay.padEnd(1)} `.slice(0, 3) + '|';
}
console.log(line);
console.log(' ' + '+---'.repeat(size) + '+');
}
console.log(`Legend: X, O = Players | ∆ = Power | ✶ = Magic Well | ⛨ = Shield | [+] = Active ✶ Bonus`);
console.log('');
}
displayMessage(message) {
console.log(message);
}
askQuestion(prompt) {
return new Promise((resolve) => {
this.rl.question(prompt, (answer) => {
resolve(answer.trim());
});
});
}
async promptGameMode() {
this.clearScreen();
this.displayMessage("Welcome to GRIDLORDS!");
this.displayMessage("----------------------");
while (true) {
this.displayMessage("Choose game mode:");
this.displayMessage(" 1) Player vs Player (PvP)");
this.displayMessage(" 2) Player vs AI (PvE - Gemini)");
const choice = await this.askQuestion('> ');
if (choice === '1')
return 'PvP';
if (choice === '2') {
if (!GameConstants.GEMINI_API_KEY) {
this.displayMessage("\nERROR: Gemini API Key not configured. Set GEMINI_API_KEY env variable. PvE unavailable.\n");
}
else {
return 'PvE';
}
}
else {
this.displayMessage('Invalid choice. Enter 1 or 2.');
}
}
}
async promptPlayerMove(playerName) {
const actionMap = {
'C': 'CONQUER', 'F': 'FORTIFY', 'A': 'ATTACK',
};
while (true) {
const actionHelp = `Actions: C=Conquer, F=Fortify, A=Attack`;
const promptMsg = `${actionHelp}\nPlayer ${playerName}'s turn. Enter ACTION COORDINATE (e.g., C B3, A A1, F C5):`;
const input = await this.askQuestion(`${promptMsg}\n> `);
const parts = input.trim().toUpperCase().split(/\s+/);
if (parts.length !== 2) {
this.displayMessage('Invalid input format. Use: ACTION COORD (e.g., C B3).');
continue;
}
const actionStr = parts[0];
const coordStr = parts[1];
const action = actionMap[actionStr];
if (!action) {
this.displayMessage(`Invalid action "${actionStr}". Use C, F, or A.`);
continue;
}
const coord = GameUtils.parseCoordinateInput(coordStr);
if (!coord) {
if (coordStr.match(/^[A-Z][1-9]\d*$/i)) {
const maxRowChar = String.fromCharCode(GameConstants.ASCII_A_OFFSET + GameConstants.GRID_SIZE - 1);
const maxCol = GameConstants.GRID_SIZE;
this.displayMessage(`Invalid coordinate "${coordStr}". Row must be A-${maxRowChar}, Column must be 1-${maxCol}.`);
}
else {
this.displayMessage(`Invalid coordinate format "${coordStr}". Use LetterNumber (e.g., A1, C5).`);
}
continue;
}
return { action, coord };
}
}
async promptMagicWellTarget(player, validTargets) {
if (validTargets.length === 0) {
this.displayMessage("You own a Magic Well (✶) but have no cells to target for the bonus.");
return null;
}
this.displayMessage(`\n--- Magic Well Activation (Player ${player.mark}) ---`);
this.displayMessage("You own a Magic Well (✶)! Choose one of your cells to receive a +1 defense bonus during the opponent's next turn.");
const targetOptions = validTargets.map(GameUtils.formatCoordForUser).join(', ');
this.displayMessage(`Valid targets: ${targetOptions}`);
while (true) {
const input = await this.askQuestion('Enter coordinate to boost (e.g., B2): ');
const coord = GameUtils.parseCoordinateInput(input);
if (!coord) {
this.displayMessage(`Invalid coordinate format "${input}". Use LetterNumber (e.g., B2).`);
continue;
}
const coordStr = GameUtils.toCoordinateString(coord);
if (!player.ownsPosition(coordStr)) {
this.displayMessage(`Invalid choice. You do not own ${GameUtils.formatCoordForUser(coord)}. Choose from: ${targetOptions}`);
continue;
}
this.displayMessage(`Cell ${GameUtils.formatCoordForUser(coord)} will receive the defense bonus.`);
this.displayMessage('-------------------------------------\n');
return coord;
}
}
displayAttackResult(attackerMark, defenderMark, attackRoll, defenseRoll, success) {
this.displayMessage(`--- Attack ${attackerMark} vs ${defenderMark} ---`);
this.displayMessage(` Rolls: Attacker (${attackerMark}) = ${attackRoll} | Defender (${defenderMark}) = ${defenseRoll}`);
if (success) {
this.displayMessage(` Result: ATTACK SUCCESSFUL!`);
}
else {
this.displayMessage(` Result: Attack Failed. Defense holds!`);
}
this.displayMessage('----------------------------------');
}
displayAIMove(aiMark, move) {
const coordStr = GameUtils.formatCoordForUser(move.coord);
let actionText = '';
switch (move.action) {
case 'CONQUER':
actionText = `conquers ${coordStr}`;
break;
case 'FORTIFY':
actionText = `fortifies ${coordStr} with ⛨`;
break;
case 'ATTACK':
actionText = `attacks ${coordStr}`;
break;
}
this.displayMessage(`*** AI (${aiMark}) decides: ${actionText} ***`);
if (move.magicWellTargetCoord) {
this.displayMessage(`*** AI (${aiMark}) activates Magic Well (✶) on ${GameUtils.formatCoordForUser(move.magicWellTargetCoord)} ***`);
}
}
displayAIThinking() {
this.displayMessage(`--- AI's Turn (${GameConstants.AI_PLAYER_MARK}) ---`);
this.displayMessage("AI is thinking...");
}
displayAIError(errorMsg) {
this.displayMessage(`!!! AI Error: ${errorMsg} !!!`);
this.displayMessage("!!! The AI may have skipped its turn or made a random fallback move. !!!");
}
displayVictory(winnerMark) {
this.displayMessage("\n==============================================");
if (winnerMark) {
this.displayMessage(` GAME OVER! Player ${winnerMark} is the SUPREME GRID LORD!!!`);
}
else {
this.displayMessage(` GAME OVER! Draw or Stalemate!`);
}
this.displayMessage("==============================================\n");
}
close() {
this.rl.close();
}
}
UI.TerminalUI = TerminalUI;
})(UI || (UI = {}));
var AI;
(function (AI) {
class GeminiAI {
constructor(apiKey) {
if (!apiKey) {
throw new Error("Gemini API Key is required for AI Logic.");
}
this.apiKey = apiKey;
}
formatGameStateForPrompt(aiPlayer, humanPlayer, grid, specials, rules) {
const gridSize = grid.getSize();
let boardString = " ";
for (let c = 0; c < gridSize; c++)
boardString += ` ${c + 1} `;
boardString += "\n";
boardString += ' +' + '---+'.repeat(gridSize) + '\n';
for (let r = 0; r < gridSize; r++) {
const rowLetter = String.fromCharCode(GameConstants.ASCII_A_OFFSET + r);
let rowLine = `${rowLetter} |`;
for (let c = 0; c < gridSize; c++) {
const coord = { row: r, col: c };
const coordStr = GameUtils.toCoordinateString(coord);
const special = specials[coordStr];
const cell = grid.getCell(coord);
const displayChar = special ?? cell;
rowLine += ` ${displayChar.padEnd(1)} |`;
}
boardString += rowLine + "\n";
boardString += ' +' + '---+'.repeat(gridSize) + '\n';
}
const aiPositions = Array.from(aiPlayer.getPositions()).map(cs => GameUtils.formatCoordForUser(GameUtils.fromCoordinateString(cs))).join(', ') || 'None';
const humanPositions = Array.from(humanPlayer.getPositions()).map(cs => GameUtils.formatCoordForUser(GameUtils.fromCoordinateString(cs))).join(', ') || 'None';
const aiMagicWells = Array.from(aiPlayer.getMagicWells()).map(cs => GameUtils.formatCoordForUser(GameUtils.fromCoordinateString(cs))).join(', ') || 'None';
const validMagicWellTargets = rules.getValidMagicWellTargets(aiPlayer).map(GameUtils.formatCoordForUser).join(', ') || 'None available';
const specialLocations = Object.entries(specials)
.map(([coordStr, type]) => `${GameUtils.formatCoordForUser(GameUtils.fromCoordinateString(coordStr))}(${type})`)
.join(', ') || 'None';
const prompt = `
You are Gridlords AI Player ${aiPlayer.mark}. Goal: ${GameConstants.VICTORY_CONDITION_CELLS} cells. Grid ${gridSize}x${gridSize}.
Current Board State:
${boardString}
Legend: [ ]=Empty, X, O = Players, ∆=Power, ✶=MagicWell, ⛨=Shield
Your Cells (${aiPlayer.getPositionCount()}): ${aiPositions}
Opponent (${humanPlayer.mark}) Cells (${humanPlayer.getPositionCount()}): ${humanPositions}
Board Specials: ${specialLocations}
You own Magic Wells (✶) at: ${aiMagicWells}
Rules Summary:
- Actions: CONQUER, FORTIFY, ATTACK. Choose ONE per turn.
- Adjacency: CONQUER/ATTACK target MUST be adjacent (up/down/left/right) to one of YOUR cells (${aiPlayer.mark}).
- CONQUER: Target an EMPTY cell (' ') adjacent to YOUR territory. Empty means the cell shows ' ' on the board.
- If the empty cell has a ⛨, you capture the cell AND the ⛨ remains.
- If the empty cell has ∆ or ✶, you capture it.
- FORTIFY: Target YOUR OWN cell (${aiPlayer.mark}) without a ⛨. Adds ⛨. Removes existing ∆ or ✶ from the cell and your control.
- ATTACK: Target an OPPONENT'S cell (${humanPlayer.mark}) adjacent to YOUR territory.
- Roll dice: Attacker (+${GameConstants.POWER_SOURCE_ATTACK_BONUS} if owns any ∆) vs Defender (+${GameConstants.SHIELD_DEFENSE_MODIFIER} if cell has ⛨, +${GameConstants.MAGIC_WELL_DEFENSE_BONUS} if targeted by active ✶ effect).
- Win: Capture cell, DESTROY ⛨ if present, CAPTURE ∆ or ✶ if present.
- Magic Well Bonus (✶): If you own any ✶, after your main action, choose ONE of YOUR cells (${validMagicWellTargets}) to get +${GameConstants.MAGIC_WELL_DEFENSE_BONUS} defense on opponent's next turn.
IMPORTANT: Choose a VALID move based ONLY on the board state and rules above.
- Do NOT CONQUER occupied cells (X or O).
- Do NOT CONQUER non-adjacent cells.
- Do NOT ATTACK empty or own cells.
- Do NOT ATTACK non-adjacent cells.
- Do NOT FORTIFY non-owned cells or cells already with ⛨.
Your turn (${aiPlayer.mark}). Choose your main action (ACTION: COORD).
If you own any Magic Wells (✶), ALSO specify your bonus target on a NEW LINE (WELL_TARGET: COORD). Choose from your owned cells (${validMagicWellTargets}).
Respond ONLY with the action and coordinate, and optionally the well target on a new line.
Valid examples:
CONQUER: B3
ATTACK: D4
WELL_TARGET: C5
FORTIFY: A1
WELL_TARGET: A1
Invalid Examples (Do NOT output these):
CONQUER: C3 (If C3 contains X or O)
ATTACK: B3 (If B3 is empty or owned by you)
FORTIFY: D4 (If you don't own D4)
CONQUER: E1 (If E1 is not adjacent to any of your cells)
What is your move?
`;
return prompt.trim();
}
parseAIResponse(responseText) {
const lines = responseText.trim().toUpperCase().split('\n');
const mainActionLine = lines[0];
const wellTargetLine = lines.length > 1 ? lines[1] : null;
const mainMatch = mainActionLine.match(/^(CONQUER|FORTIFY|ATTACK):\s?([A-Z][1-9]\d*)$/);
if (!mainMatch) {
console.error(`Error parsing AI response: unexpected main action format "${mainActionLine}"`);
return null;
}
const action = mainMatch[1];
const coordInput = mainMatch[2];
const coord = GameUtils.parseCoordinateInput(coordInput);
if (!coord) {
console.error(`Error parsing AI response: invalid main coordinate "${coordInput}"`);
return null;
}
let magicWellTargetCoord = undefined;
if (wellTargetLine) {
const wellMatch = wellTargetLine.match(/^WELL_TARGET:\s?([A-Z][1-9]\d*)$/);
if (wellMatch) {
const wellCoordInput = wellMatch[1];
const parsedWellCoord = GameUtils.parseCoordinateInput(wellCoordInput);
if (parsedWellCoord) {
magicWellTargetCoord = parsedWellCoord;
}
else {
console.warn(`AI provided invalid WELL_TARGET format: "${wellCoordInput}". Ignoring.`);
}
}
else {
console.warn(`AI provided malformed WELL_TARGET line: "${wellTargetLine}". Ignoring.`);
}
}
return { action, coord, magicWellTargetCoord };
}
async callGeminiAPI(prompt) {
const url = `${GameConstants.GEMINI_API_ENDPOINT}?key=${this.apiKey}`;
const requestBody = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.7,
}
};
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(GameConstants.AI_REQUEST_TIMEOUT_MS)
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Gemini API Error: ${response.status} ${response.statusText}`, errorBody);
return null;
}
const data = await response.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (typeof text !== 'string') {
console.error('Gemini API Error: response does not contain valid text.', JSON.stringify(data, null, 2));
return null;
}
return text;
}
catch (error) {
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
console.error('Gemini API Error: Request timed out.');
}
else {
console.error('Error calling Gemini API:', error);
}
return null;
}
}
async decideMove(aiPlayer, humanPlayer, grid, specials, rules) {
const prompt = this.formatGameStateForPrompt(aiPlayer, humanPlayer, grid, specials, rules);
let attempts = 0;
let parsedResponse = null;
while (attempts <= GameConstants.AI_MAX_RETRIES && !parsedResponse) {
if (attempts > 0) {
console.log(`Attempt ${attempts + 1} to get AI move...`);
await GameUtils.sleep(500);
}
const responseText = await this.callGeminiAPI(prompt);
if (responseText) {
parsedResponse = this.parseAIResponse(responseText);
if (parsedResponse) {
const targetCoordStr = GameUtils.toCoordinateString(parsedResponse.coord);
const targetCellState = grid.getCell(parsedResponse.coord);
const targetSpecial = specials[targetCoordStr];
let isValid = true;
let errorMsg = "";
switch (parsedResponse.action) {
case 'CONQUER':
if (targetCellState !== ' ') {
isValid = false;
errorMsg = "CONQUER target not empty";
}
if (!rules.isAdjacent(parsedResponse.coord, aiPlayer)) {
isValid = false;
errorMsg = "CONQUER target not adjacent";
}
break;
case 'FORTIFY':
if (!aiPlayer.ownsPosition(targetCoordStr)) {
isValid = false;
errorMsg = "FORTIFY target not owned";
}
if (targetSpecial === '⛨') {
isValid = false;
errorMsg = "FORTIFY target already shielded";
}
break;
case 'ATTACK':
if (!humanPlayer.ownsPosition(targetCoordStr)) {
isValid = false;
errorMsg = "ATTACK target not enemy";
}
if (!rules.isAdjacent(parsedResponse.coord, aiPlayer)) {
isValid = false;
errorMsg = "ATTACK target not adjacent";
}
break;
}
if (parsedResponse.magicWellTargetCoord) {
if (!aiPlayer.ownsAnyMagicWell()) {
console.warn("AI provided WELL_TARGET but owns no Magic Wells. Ignoring target.");
parsedResponse.magicWellTargetCoord = undefined;
}
else {
const wellTargetStr = GameUtils.toCoordinateString(parsedResponse.magicWellTargetCoord);
if (!aiPlayer.ownsPosition(wellTargetStr)) {
console.warn(`AI provided invalid WELL_TARGET ${GameUtils.formatCoordForUser(parsedResponse.magicWellTargetCoord)} (not owned). Ignoring target.`);
parsedResponse.magicWellTargetCoord = undefined;
}
}
}
if (!isValid) {
console.warn(`AI suggested invalid move: ${parsedResponse.action} ${GameUtils.formatCoordForUser(parsedResponse.coord)} (${errorMsg}). Retrying...`);
parsedResponse = null;
}
}
}
else {
console.error("Communication failure with Gemini API.");
}
attempts++;
}
if (!parsedResponse) {
console.error("AI failed to provide a valid move after multiple attempts.");
const validMoves = rules.getValidMoves(aiPlayer, humanPlayer, grid, specials);
const fallbackMove = GameUtils.getRandomElement(validMoves);
if (fallbackMove) {
console.log("Using random valid move as fallback.");
parsedResponse = fallbackMove;
}
else {
console.error("No valid moves found for AI (fallback failed).");
return null;
}
}
if (aiPlayer.ownsAnyMagicWell() && !parsedResponse.magicWellTargetCoord) {
const validTargets = rules.getValidMagicWellTargets(aiPlayer);
const randomTarget = GameUtils.getRandomElement(validTargets);
if (randomTarget) {
console.log("AI owns Magic Well but didn't specify target. Assigning random valid target.");
parsedResponse.magicWellTargetCoord = randomTarget;
}
}
return parsedResponse;
}
}
AI.GeminiAI = GeminiAI;
})(AI || (AI = {}));
var Game;
(function (Game) {
class GameController {
constructor() {
this.aiLogic = null;
this.gameMode = 'PvP';
this.magicWellBonusTarget = null;
this.magicWellBonusActiveForPlayerId = null;
this.lastAIMoveResult = null;
this.grid = new Core.Grid(GameConstants.GRID_SIZE);
this.players = [new Core.Player(0), new Core.Player(1)];
this.ui = new UI.TerminalUI();
this.rules = new Rules.GameRules();
this.currentPlayerId = 0;
this.specials = {};
this.isGameOver = false;
}
async initializeGame() {
this.gameMode = await this.ui.promptGameMode();
if (this.gameMode === 'PvE') {
if (!GameConstants.GEMINI_API_KEY) {
return false;
}
try {
this.aiLogic = new AI.GeminiAI(GameConstants.GEMINI_API_KEY);
this.ui.displayMessage(`Player vs AI (${GameConstants.AI_PLAYER_MARK}) mode selected.`);
}
catch (error) {
this.ui.displayMessage(`Error initializing AI: ${error.message}`);
return false;
}
}
else {
this.ui.displayMessage("Player vs Player mode selected.");
}
await GameUtils.sleep(1000);
this.setupInitialState();
return true;
}
setupInitialState() {
this.claimCell({ row: 0, col: 0 }, 0);
this.claimCell({ row: GameConstants.GRID_SIZE - 1, col: GameConstants.GRID_SIZE - 1 }, 1);
let specialsPlaced = 0;
const maxPlacementAttempts = GameConstants.GRID_SIZE * GameConstants.GRID_SIZE * 2;
let attempts = 0;
const potentialCoords = [];
for (let r = 0; r < GameConstants.GRID_SIZE; r++) {
for (let c = 0; c < GameConstants.GRID_SIZE; c++) {
potentialCoords.push({ row: r, col: c });
}
}
while (specialsPlaced < GameConstants.INITIAL_SPECIAL_CELLS && attempts < maxPlacementAttempts && potentialCoords.length > 0) {
const randIndex = GameUtils.randomInt(0, potentialCoords.length - 1);
const coord = potentialCoords.splice(randIndex, 1)[0];
const coordStr = GameUtils.toCoordinateString(coord);
if (this.grid.getCell(coord) === ' ' && !this.specials[coordStr]) {
const specialType = GameConstants.SPECIAL_TYPES[specialsPlaced % GameConstants.SPECIAL_TYPES.length];
this.specials[coordStr] = specialType;
specialsPlaced++;
}
attempts++;
}
if (specialsPlaced < GameConstants.INITIAL_SPECIAL_CELLS) {
console.warn("Warning: Could not place all initial specials (board too small or unlucky?).");
}
this.ui.clearScreen();
this.ui.displayMessage("GRIDLORDS - Started!");
this.ui.displayMessage(`Objective: Control ${GameConstants.VICTORY_CONDITION_CELLS} cells.`);
this.ui.displayMessage("Good luck, Lords!\n");
}
claimCell(coord, playerId) {
const player = this.players[playerId];
const coordStr = GameUtils.toCoordinateString(coord);
this.grid.setCell(coord, player.mark);
player.addPosition(coordStr);
}
async startGame() {
const initialized = await this.initializeGame();
if (!initialized) {
this.ui.displayMessage("Failed to initialize the game. Exiting.");
this.ui.close();
return;
}
while (!this.isGameOver) {
await this.executePlayerTurn();
this.checkGameOver();
if (!this.isGameOver) {
this.switchPlayer();
}
else {
this.ui.clearScreen();
this.ui.renderBoard(this.grid, this.specials, this.magicWellBonusTarget);
}
}
const winnerId = this.rules.checkWinner(this.players);
const winnerMark = winnerId !== null ? this.players[winnerId].mark : null;
this.ui.displayVictory(winnerMark);
this.ui.close();
}
async executePlayerTurn() {
const currentPlayer = this.players[this.currentPlayerId];
const opponentPlayer = this.players[this.currentPlayerId === 0 ? 1 : 0];
const isAITurn = this.gameMode === 'PvE' && currentPlayer.id === GameConstants.AI_PLAYER_ID && this.aiLogic;
this.magicWellBonusTarget = null;
this.magicWellBonusActiveForPlayerId = null;
this.ui.clearScreen();
this.ui.renderBoard(this.grid, this.specials, this.magicWellBonusTarget);
let moveSuccessful = false;
if (isAITurn) {
moveSuccessful = await this.executeAITurn(currentPlayer);
}
else {
moveSuccessful = await this.executeHumanTurn(currentPlayer);
}
if (moveSuccessful && currentPlayer.ownsAnyMagicWell()) {
const validTargets = this.rules.getValidMagicWellTargets(currentPlayer);
let chosenTargetCoord = null;
if (isAITurn) {
const aiMove = (this.lastAIMoveResult);
if (aiMove?.magicWellTargetCoord) {
chosenTargetCoord = aiMove.magicWellTargetCoord;
}
else if (validTargets.length > 0) {
console.warn("AI owns Magic Well but no target decided/fallback failed. Skipping bonus.");
}
}
else {
chosenTargetCoord = await this.ui.promptMagicWellTarget(currentPlayer, validTargets);
}
if (chosenTargetCoord) {
this.magicWellBonusTarget = GameUtils.toCoordinateString(chosenTargetCoord);
this.magicWellBonusActiveForPlayerId = opponentPlayer.id;
}
}
await GameUtils.sleep(1500);
}
async executeHumanTurn(player) {
let actionIsValidAndExecuted = false;
while (!actionIsValidAndExecuted) {
this.ui.clearScreen();
this.ui.renderBoard(this.grid, this.specials, this.magicWellBonusTarget);
const moveInput = await this.ui.promptPlayerMove(player.mark);
if (!moveInput) {
this.ui.displayMessage("Unexpected error processing move. Try again.");
await GameUtils.sleep(1500);
continue;
}
actionIsValidAndExecuted = this.processPlayerAction(player, moveInput.action, moveInput.coord);
if (!actionIsValidAndExecuted) {
await this.ui.askQuestion("Action failed or was invalid. Press Enter to try again...");
}
}
return actionIsValidAndExecuted;
}
async executeAITurn(aiPlayer) {
this.ui.displayAIThinking();
const humanPlayer = this.players[aiPlayer.id === 0 ? 1 : 0];
this.lastAIMoveResult = null;
if (!this.aiLogic) {
this.ui.displayAIError("AI logic is not available!");
return false;
}
const aiMove = await this.aiLogic.decideMove(aiPlayer, humanPlayer, this.grid, this.specials, this.rules);
if (!aiMove) {
this.ui.displayAIError("Could not determine a move.");
await GameUtils.sleep(1500);
return false;
}
this.lastAIMoveResult = aiMove;
this.ui.displayAIMove(aiPlayer.mark, aiMove);
await GameUtils.sleep(1500);
const success = this.processPlayerAction(aiPlayer, aiMove.action, aiMove.coord);
if (!success) {
this.ui.displayAIError(`AI's chosen action (${aiMove.action}: ${GameUtils.formatCoordForUser(aiMove.coord)}) failed validation.`);
await GameUtils.sleep(1500);
}
return success;
}
processPlayerAction(player, action, coord) {
switch (action) {
case 'CONQUER':
return this.validateAndExecuteConquer(player, coord);
case 'FORTIFY':
return this.validateAndExecuteFortify(player, coord);
case 'ATTACK':
return this.validateAndExecuteAttack(player, coord);
default:
this.ui.displayMessage("Unknown action received.");
return false;
}
}
validateAndExecuteConquer(player, targetCoord) {
const targetCoordStr = GameUtils.toCoordinateString(targetCoord);
const opponent = this.players[player.id === 0 ? 1 : 0];
if (!this.grid.isValidCoordinate(targetCoord)) {
this.ui.displayMessage('Error: Coordinate is outside the board.');
return false;
}
if (this.grid.getCell(targetCoord) !== ' ') {
this.ui.displayMessage('Error: Target cell is not empty.');
return false;
}
if (!this.rules.isAdjacent(targetCoord, player)) {
this.ui.displayMessage('Error: Target cell is not adjacent to your territory.');
return false;
}
const existingSpecial = this.specials[targetCoordStr];
if (existingSpecial) {
if (existingSpecial === '⛨') {
this.ui.displayMessage(`Player ${player.mark} conquered shielded cell ${GameUtils.formatCoordForUser(targetCoord)}! Shield remains.`);
}
else if (existingSpecial === '∆') {
this.ui.displayMessage(`Player ${player.mark} conquered and captured Power Source ∆ at ${GameUtils.formatCoordForUser(targetCoord)}! (+1 Attack Bonus)`);
delete this.specials[targetCoordStr];
player.addPowerSource(targetCoordStr);
}
else if (existingSpecial === '✶') {
this.ui.displayMessage(`Player ${player.mark} conquered and captured Magic Well ✶ at ${GameUtils.formatCoordForUser(targetCoord)}! (Defense Bonus ability)`);
delete this.specials[targetCoordStr];
player.addMagicWell(targetCoordStr);
}
}
else {
this.ui.displayMessage(`Player ${player.mark} conquered ${GameUtils.formatCoordForUser(targetCoord)}!`);
}
this.claimCell(targetCoord, player.id);
return true;
}
validateAndExecuteFortify(player, targetCoord) {
const targetCoordStr = GameUtils.toCoordinateString(targetCoord);
if (!this.grid.isValidCoordinate(targetCoord)) {
this.ui.displayMessage('Error: Coordinate is outside the board.');
return false;
}
if (!player.ownsPosition(targetCoordStr)) {
this.ui.displayMessage('Error: You do not control this cell.');
return false;
}
if (this.specials[targetCoordStr] === '⛨') {
this.ui.displayMessage('Error: This cell is already fortified with ⛨.');
return false;
}
const existingSpecial = this.specials[targetCoordStr];
if (existingSpecial) {
this.ui.displayMessage(`Warning: Fortifying removes the existing ${existingSpecial} at ${GameUtils.formatCoordForUser(targetCoord)}.`);
if (existingSpecial === '∆')
player.removePowerSource(targetCoordStr);
if (existingSpecial === '✶')
player.removeMagicWell(targetCoordStr);
delete this.specials[targetCoordStr];
}
this.specials[targetCoordStr] = '⛨';
this.ui.displayMessage(`Player ${player.mark} fortified ${GameUtils.formatCoordForUser(targetCoord)} with ⛨!`);
return true;
}
validateAndExecuteAttack(player, targetCoord) {
const targetCoordStr = GameUtils.toCoordinateString(targetCoord);
const opponent = this.players[player.id === 0 ? 1 : 0];
if (!this.grid.isValidCoordinate(targetCoord)) {
this.ui.displayMessage('Error: Coordinate is outside the board.');
return false;
}
if (!opponent.ownsPosition(targetCoordStr)) {
this.ui.displayMessage('Error: Target cell is not controlled by the enemy.');
return false;
}
if (!this.rules.isAdjacent(targetCoord, player)) {
this.ui.displayMessage('Error: Target cell is not adjacent to your territory.');
return false;
}
const defenderSpecial = this.specials[targetCoordStr];
const isDefenderShielded = defenderSpecial === '⛨';
const isMagicWellBoosted = this.magicWellBonusActiveForPlayerId === opponent.id &&
this.magicWellBonusTarget === targetCoordStr;
const attackRoll = this.rules.rollAttackDice(player);
const defenseRoll = this.ru