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); + } + }); +});