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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/architecture/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 19 additions & 3 deletions src/data/ai/factions/dominion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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',
Expand All @@ -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' },
Expand Down
52 changes: 47 additions & 5 deletions src/engine/systems/BuildingPlacementSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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', {});
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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', {});
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
71 changes: 56 additions & 15 deletions src/engine/systems/ai/AIBuildOrderExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand All @@ -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}`);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions tests/data/aiConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): AIStateSnapshot {
Expand Down Expand Up @@ -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);
}
});
});