From 9f476930d157ca5574193dab6210e3eabc061054 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Wed, 28 Jan 2026 00:05:16 -0500 Subject: [PATCH] Enhance AI macro rules and resource management for Dominion faction. Updated AI conditions to ensure mineral costs align with supply cache requirements, preventing economic deadlocks. Added new worker training rule for early game resource management. Improved resource checks in BuildingPlacementSystem for both local and AI players, ensuring accurate deductions and refunds. Updated tests to validate new AI macro rules. --- docs/architecture/OVERVIEW.md | 3 + src/data/ai/factions/dominion.ts | 22 +++++- src/engine/systems/BuildingPlacementSystem.ts | 52 ++++++++++++-- src/engine/systems/ai/AIBuildOrderExecutor.ts | 71 +++++++++++++++---- tests/data/aiConfig.test.ts | 24 +++++++ 5 files changed, 149 insertions(+), 23 deletions(-) diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index b9dd653e..94b05372 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -571,6 +571,9 @@ VOIDSTRIKE uses a **fully data-driven architecture** that separates game-specifi | **Abilities** | `abilities/abilities.ts` | All unit/building abilities with effects | | **Formations** | `formations/formations.ts` | Unit formation patterns and positioning | | **AI** | `ai/buildOrders.ts` | AI difficulty config, build orders, unit compositions | +| **AI** | `ai/factions/*.ts` | Faction macro rules, roles, and economic thresholds | + +AI macro rules are cost-sensitive and should stay aligned with building definitions to avoid economic deadlocks (for example, supply-building rules should require at least the building's mineral cost). ### Example: Creating an Age of Empires Clone diff --git a/src/data/ai/factions/dominion.ts b/src/data/ai/factions/dominion.ts index 6250078c..d0619ed6 100644 --- a/src/data/ai/factions/dominion.ts +++ b/src/data/ai/factions/dominion.ts @@ -31,7 +31,7 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ priority: 100, // Highest priority - never get supply blocked conditions: [ { type: 'supplyRatio', operator: '>=', value: 0.7 }, - { type: 'minerals', operator: '>=', value: 75 }, // Lowered from 100 - supply is critical + { type: 'minerals', operator: '>=', value: 100 }, // Match supply cache cost to prevent deadlocks ], action: { type: 'build', targetId: 'supply_cache' }, cooldownTicks: 40, // ~2 seconds @@ -45,7 +45,7 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ priority: 150, // Even higher priority conditions: [ { type: 'supplyRatio', operator: '>=', value: 0.85 }, - { type: 'minerals', operator: '>=', value: 50 }, // Very low threshold - emergency + { type: 'minerals', operator: '>=', value: 100 }, // Match supply cache cost to prevent deadlocks ], action: { type: 'build', targetId: 'supply_cache' }, cooldownTicks: 15, // Faster cooldown for emergencies @@ -67,6 +67,22 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ cooldownTicks: 10, // Very fast - queue multiple workers }, + // Opening worker production - keep economy growing before supply pressure + { + id: 'workers_opening', + name: 'Train Workers (Opening)', + description: 'Train workers early before supply pressure kicks in', + priority: 95, + conditions: [ + // workerSaturation = workers / (bases * 16), so < 1.0 means under-saturated + { type: 'workerSaturation', operator: '<', value: 1.2 }, // Allow slight over-saturation for smoother production + { type: 'minerals', operator: '>=', value: 50 }, + { type: 'supplyRatio', operator: '<', value: 0.7 }, // Bank for supply once pressure starts + ], + action: { type: 'train', targetId: 'fabricator' }, + cooldownTicks: 25, // Slightly faster worker production + }, + // Standard worker production - caps at 16 per base (optimal saturation) { id: 'workers_basic', @@ -76,7 +92,7 @@ const DOMINION_MACRO_RULES: MacroRule[] = [ conditions: [ // workerSaturation = workers / (bases * 16), so < 1.0 means under-saturated { type: 'workerSaturation', operator: '<', value: 1.2 }, // Allow slight over-saturation for smoother production - { type: 'minerals', operator: '>=', value: 50 }, + { type: 'minerals', operator: '>=', value: 100 }, // Bank for supply cache cost { type: 'supplyRatio', operator: '<', value: 0.9 }, // Stop earlier to leave room for army ], action: { type: 'train', targetId: 'fabricator' }, diff --git a/src/engine/systems/BuildingPlacementSystem.ts b/src/engine/systems/BuildingPlacementSystem.ts index ffc583bb..a80ceb72 100644 --- a/src/engine/systems/BuildingPlacementSystem.ts +++ b/src/engine/systems/BuildingPlacementSystem.ts @@ -8,6 +8,7 @@ import { Selectable } from '../components/Selectable'; import { Unit } from '../components/Unit'; import { Resource } from '../components/Resource'; import { Wall } from '../components/Wall'; +import { EnhancedAISystem } from './EnhancedAISystem'; import { BUILDING_DEFINITIONS } from '@/data/buildings/dominion'; import { WALL_DEFINITIONS } from '@/data/buildings/walls'; import { isLocalPlayer, getLocalPlayerId } from '@/store/gameSetupStore'; @@ -44,6 +45,9 @@ export class BuildingPlacementSystem extends System { // Deterministic RNG for worker wandering (multiplayer sync) private readonly wanderRng = new SeededRandom(1); + // Cache reference to AI system for AI resource checks + private aiSystem: EnhancedAISystem | null = null; + constructor(game: Game) { super(game); this.setupEventListeners(); @@ -66,6 +70,17 @@ export class BuildingPlacementSystem extends System { this.game.eventBus.on('command:resume_construction', this.handleResumeConstruction.bind(this)); } + /** + * Get the AI system, lazily initializing if needed. + * Always re-checks if null since AI system may be registered after init. + */ + private getAISystem(): EnhancedAISystem | null { + if (!this.aiSystem) { + this.aiSystem = this.world.getSystem(EnhancedAISystem) || null; + } + return this.aiSystem; + } + /** * Handle wall line placement - places multiple wall segments at once * Uses smart worker assignment for efficient construction of long walls @@ -346,8 +361,10 @@ export class BuildingPlacementSystem extends System { } const isPlayerLocal = isLocalPlayer(playerId); + const aiPlayer = !isPlayerLocal ? this.getAISystem()?.getAIPlayer(playerId) : undefined; + const isPlayerAI = aiPlayer !== undefined; - // Check resources (only for local player - AI handles its own resources) + // Check resources (local player via game store, AI via AI state) if (isPlayerLocal) { if (this.game.statePort.getMinerals() < definition.mineralCost) { this.game.eventBus.emit('alert:notEnoughMinerals', {}); @@ -359,6 +376,11 @@ export class BuildingPlacementSystem extends System { this.game.eventBus.emit('warning:lowVespene', {}); return; } + } else if (isPlayerAI && aiPlayer) { + if (aiPlayer.minerals < definition.mineralCost || aiPlayer.vespene < definition.vespeneCost) { + debugBuildingPlacement.log(`BuildingPlacementSystem: AI ${playerId} lacks resources for ${buildingType} (need ${definition.mineralCost}M/${definition.vespeneCost}G, have ${Math.floor(aiPlayer.minerals)}M/${Math.floor(aiPlayer.vespene)}G)`); + return; + } } // Check building dependencies (tech requirements) @@ -400,9 +422,12 @@ export class BuildingPlacementSystem extends System { return; } - // Deduct resources (only for local player - AI handles its own resources) + // Deduct resources (local player via store, AI via AI state) if (isPlayerLocal) { this.game.statePort.addResources(-definition.mineralCost, -definition.vespeneCost); + } else if (isPlayerAI && aiPlayer) { + aiPlayer.minerals -= definition.mineralCost; + aiPlayer.vespene -= definition.vespeneCost; } // Create the building entity at the snapped center position @@ -745,8 +770,10 @@ export class BuildingPlacementSystem extends System { return; } - // Check resources (only for local player) + // Check resources (local player via game store, AI via AI state) const isPlayerLocal = isLocalPlayer(playerId); + const aiPlayer = !isPlayerLocal ? this.getAISystem()?.getAIPlayer(playerId) : undefined; + const isPlayerAI = aiPlayer !== undefined; if (isPlayerLocal) { if (this.game.statePort.getMinerals() < addonDef.mineralCost) { this.game.eventBus.emit('alert:notEnoughMinerals', {}); @@ -758,6 +785,11 @@ export class BuildingPlacementSystem extends System { this.game.eventBus.emit('warning:lowVespene', {}); return; } + } else if (isPlayerAI && aiPlayer) { + if (aiPlayer.minerals < addonDef.mineralCost || aiPlayer.vespene < addonDef.vespeneCost) { + debugBuildingPlacement.log(`BuildingPlacementSystem: AI ${playerId} lacks resources for addon ${addonType} (need ${addonDef.mineralCost}M/${addonDef.vespeneCost}G, have ${Math.floor(aiPlayer.minerals)}M/${Math.floor(aiPlayer.vespene)}G)`); + return; + } } // Calculate addon position (to the right of the parent building) @@ -770,9 +802,12 @@ export class BuildingPlacementSystem extends System { return; } - // Deduct resources (only for local player) + // Deduct resources (local player via store, AI via AI state) if (isPlayerLocal) { this.game.statePort.addResources(-addonDef.mineralCost, -addonDef.vespeneCost); + } else if (isPlayerAI && aiPlayer) { + aiPlayer.minerals -= addonDef.mineralCost; + aiPlayer.vespene -= addonDef.vespeneCost; } // Create the addon entity - starts in 'constructing' state (no worker needed) @@ -1707,10 +1742,17 @@ export class BuildingPlacementSystem extends System { // No worker assigned - cancel the blueprint const definition = BUILDING_DEFINITIONS[building.buildingId]; if (definition) { - // Refund resources to the player (only for local player) + // Refund resources to the player (local player via store, AI via AI state) if (isLocalPlayer(selectable.playerId)) { this.game.statePort.addResources(definition.mineralCost, definition.vespeneCost); debugBuildingPlacement.log(`BuildingPlacementSystem: Refunded ${definition.mineralCost} minerals, ${definition.vespeneCost} vespene for cancelled ${building.name}`); + } else { + const aiPlayer = this.getAISystem()?.getAIPlayer(selectable.playerId); + if (aiPlayer) { + aiPlayer.minerals += definition.mineralCost; + aiPlayer.vespene += definition.vespeneCost; + debugBuildingPlacement.log(`BuildingPlacementSystem: Refunded AI ${selectable.playerId} ${definition.mineralCost} minerals, ${definition.vespeneCost} vespene for cancelled ${building.name}`); + } } // PERF: If this is an extractor/refinery, restore the vespene geyser visibility diff --git a/src/engine/systems/ai/AIBuildOrderExecutor.ts b/src/engine/systems/ai/AIBuildOrderExecutor.ts index 5e30d2c9..2d330f93 100644 --- a/src/engine/systems/ai/AIBuildOrderExecutor.ts +++ b/src/engine/systems/ai/AIBuildOrderExecutor.ts @@ -111,6 +111,53 @@ export class AIBuildOrderExecutor { } } + // Check resources and supply before attempting (avoid counting as failure) + if (step.type === 'building') { + const buildingDef = BUILDING_DEFINITIONS[step.id]; + if (buildingDef && + (ai.minerals < buildingDef.mineralCost || ai.vespene < buildingDef.vespeneCost)) { + if (shouldLog) { + debugAI.log(`[AIBuildOrder] ${ai.playerId}: Waiting for resources for ${step.id} (need ${buildingDef.mineralCost}M/${buildingDef.vespeneCost}G, have ${Math.floor(ai.minerals)}M/${Math.floor(ai.vespene)}G)`); + } + return; + } + } + + if (step.type === 'unit') { + const unitDef = UNIT_DEFINITIONS[step.id]; + if (unitDef) { + if (ai.minerals < unitDef.mineralCost || ai.vespene < unitDef.vespeneCost) { + if (shouldLog) { + debugAI.log(`[AIBuildOrder] ${ai.playerId}: Waiting for resources for ${step.id} (need ${unitDef.mineralCost}M/${unitDef.vespeneCost}G, have ${Math.floor(ai.minerals)}M/${Math.floor(ai.vespene)}G)`); + } + return; + } + if (ai.supply + unitDef.supplyCost > ai.maxSupply) { + if (shouldLog) { + debugAI.log(`[AIBuildOrder] ${ai.playerId}: Waiting for supply for ${step.id} (${ai.supply}+${unitDef.supplyCost} > ${ai.maxSupply})`); + } + return; + } + } + } + + if (step.type === 'research') { + const researchDef = RESEARCH_DEFINITIONS[step.id]; + if (ai.researchInProgress.has(step.id)) { + if (shouldLog) { + debugAI.log(`[AIBuildOrder] ${ai.playerId}: Research already in progress: ${step.id}`); + } + return; + } + if (researchDef && + (ai.minerals < researchDef.mineralCost || ai.vespene < researchDef.vespeneCost)) { + if (shouldLog) { + debugAI.log(`[AIBuildOrder] ${ai.playerId}: Waiting for resources for research ${step.id} (need ${researchDef.mineralCost}M/${researchDef.vespeneCost}G, have ${Math.floor(ai.minerals)}M/${Math.floor(ai.vespene)}G)`); + } + return; + } + } + // Execute the step let success = this.executeBuildOrderStep(ai, step); @@ -330,8 +377,8 @@ export class AIBuildOrderExecutor { return false; } - // Check building requirements BEFORE deducting resources - // This prevents resource loss when placement is rejected by BuildingPlacementSystem + // Check building requirements BEFORE attempting placement + // This prevents invalid build attempts and resource churn if (buildingDef.requirements && buildingDef.requirements.length > 0) { for (const reqBuildingId of buildingDef.requirements) { const requiredCount = ai.buildingCounts.get(reqBuildingId) || 0; @@ -354,7 +401,7 @@ export class AIBuildOrderExecutor { } const economyManager = this.getEconomyManager(); - const workerId = economyManager.findAvailableWorker(ai.playerId); + const workerId = economyManager.findAvailableWorkerNotBuilding(ai.playerId); if (workerId === null) { if (shouldLog) { debugAI.log(`[AIBuildOrder] ${ai.playerId}: tryBuildBuilding - no available worker for ${buildingType}`); @@ -383,9 +430,7 @@ export class AIBuildOrderExecutor { } } - ai.minerals -= buildingDef.mineralCost; - ai.vespene -= buildingDef.vespeneCost; - + // Resources are deducted in BuildingPlacementSystem after placement validation this.game.eventBus.emit('building:place', { buildingType, position: buildPos, @@ -439,7 +484,8 @@ export class AIBuildOrderExecutor { // Try each offset until we find a valid spot for (const offset of offsets) { const pos = { x: basePos.x + offset.x, y: basePos.y + offset.y }; - if (this.game.isValidBuildingPlacement(pos.x, pos.y, width, height, excludeEntityId)) { + // Skip unit collision checks to match placement system (units will be pushed away) + if (this.game.isValidBuildingPlacement(pos.x, pos.y, width, height, excludeEntityId, true)) { return pos; } } @@ -476,10 +522,7 @@ export class AIBuildOrderExecutor { if (!building.canHaveAddon) continue; if (building.hasAddon()) continue; - // Found a valid building - deduct resources and emit event - ai.minerals -= addonDef.mineralCost; - ai.vespene -= addonDef.vespeneCost; - + // Found a valid building - emit event (resources deducted on placement) this.game.eventBus.emit('building:build_addon', { buildingId: entity.id, addonType, @@ -766,14 +809,12 @@ export class AIBuildOrderExecutor { } const economyManager = this.getEconomyManager(); - const workerId = economyManager.findAvailableWorker(ai.playerId); + const workerId = economyManager.findAvailableWorkerNotBuilding(ai.playerId); if (workerId === null) { return false; } - ai.minerals -= buildingDef.mineralCost; - ai.vespene -= buildingDef.vespeneCost; - + // Resources are deducted in BuildingPlacementSystem after placement validation this.game.eventBus.emit('building:place', { buildingType: expansionType, position: expansionLocation, diff --git a/tests/data/aiConfig.test.ts b/tests/data/aiConfig.test.ts index 23f3ec1b..323ae60a 100644 --- a/tests/data/aiConfig.test.ts +++ b/tests/data/aiConfig.test.ts @@ -13,6 +13,8 @@ import { UtilityScore, FactionAIConfig, } from '@/data/ai/aiConfig'; +import { BUILDING_DEFINITIONS } from '@/data/buildings/dominion'; +import '@/data/ai/factions/dominion'; // Helper to create minimal test state function createTestState(overrides: Partial = {}): AIStateSnapshot { @@ -510,3 +512,25 @@ describe('faction config registry', () => { expect(factions).toContain('registered-faction'); }); }); + +describe('dominion AI macro rules', () => { + it('requires full supply cache cost for supply rules', () => { + const config = getFactionAIConfig('dominion'); + expect(config).toBeDefined(); + + const supplyCost = BUILDING_DEFINITIONS.supply_cache.mineralCost; + const supplyRules = config!.macroRules.filter( + rule => rule.action.type === 'build' && rule.action.targetId === 'supply_cache' + ); + + expect(supplyRules.length).toBeGreaterThan(0); + + for (const rule of supplyRules) { + const mineralCondition = rule.conditions.find( + condition => condition.type === 'minerals' && condition.operator === '>=' + ); + expect(mineralCondition).toBeDefined(); + expect(mineralCondition!.value).toBeGreaterThanOrEqual(supplyCost); + } + }); +});