From fb147b250bcaa560409201cb69611845192ea976 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Nov 2025 07:50:56 +0000 Subject: [PATCH] STORY-01.3 & 01.4: Domain Use Cases - Resource Collection & Building Upgrades Implements core business logic for resource collection and building upgrades: - CollectResourcesUseCase: Calculate and collect resources based on elapsed time with storage capacity limits - UpgradeBuildingUseCase: Validate and execute building upgrades with gold deduction - Result type for error handling with Success/Failure pattern - 50 comprehensive unit tests (25 + 25) covering all edge cases and validation logic This enables the core gameplay loop of collecting resources from buildings and upgrading them for increased production. --- .../lib/domain/core/result.dart | 75 +++ .../domain/usecases/collect_resources.dart | 117 +++++ .../lib/domain/usecases/upgrade_building.dart | 99 ++++ .../usecases/collect_resources_test.dart | 487 ++++++++++++++++++ .../usecases/upgrade_building_test.dart | 415 +++++++++++++++ 5 files changed, 1193 insertions(+) create mode 100644 trade_factory_masters/lib/domain/core/result.dart create mode 100644 trade_factory_masters/lib/domain/usecases/collect_resources.dart create mode 100644 trade_factory_masters/lib/domain/usecases/upgrade_building.dart create mode 100644 trade_factory_masters/test/domain/usecases/collect_resources_test.dart create mode 100644 trade_factory_masters/test/domain/usecases/upgrade_building_test.dart diff --git a/trade_factory_masters/lib/domain/core/result.dart b/trade_factory_masters/lib/domain/core/result.dart new file mode 100644 index 0000000..db1cb54 --- /dev/null +++ b/trade_factory_masters/lib/domain/core/result.dart @@ -0,0 +1,75 @@ +/// Generic Result type for error handling in use cases +/// Represents either a successful value (Success) or a failure (Failure) +abstract class Result { + const Result(); + + /// Creates a successful result with a value + factory Result.success(T value) = Success; + + /// Creates a failed result with an error + factory Result.failure(E error) = Failure; + + /// Check if this result is a success + bool get isSuccess => this is Success; + + /// Check if this result is a failure + bool get isFailure => this is Failure; + + /// Get the success value, or null if this is a failure + T? get valueOrNull => isSuccess ? (this as Success).value : null; + + /// Get the error, or null if this is a success + E? get errorOrNull => isFailure ? (this as Failure).error : null; + + /// Execute a function based on whether this is a success or failure + R fold({ + required R Function(T value) onSuccess, + required R Function(E error) onFailure, + }) { + if (this is Success) { + return onSuccess((this as Success).value); + } else { + return onFailure((this as Failure).error); + } + } +} + +/// Represents a successful result containing a value +class Success extends Result { + final T value; + + const Success(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Success && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => 'Success($value)'; +} + +/// Represents a failed result containing an error +class Failure extends Result { + final E error; + + const Failure(this.error); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Failure && + runtimeType == other.runtimeType && + error == other.error; + + @override + int get hashCode => error.hashCode; + + @override + String toString() => 'Failure($error)'; +} diff --git a/trade_factory_masters/lib/domain/usecases/collect_resources.dart b/trade_factory_masters/lib/domain/usecases/collect_resources.dart new file mode 100644 index 0000000..c3a1dea --- /dev/null +++ b/trade_factory_masters/lib/domain/usecases/collect_resources.dart @@ -0,0 +1,117 @@ +import 'dart:math'; +import '../entities/player_economy.dart'; +import '../entities/building.dart'; + +/// Use case for collecting produced resources from a building +/// Calculates production based on elapsed time since last collection +/// and updates player's economy with new resources +class CollectResourcesUseCase { + /// Execute the resource collection for a specific building + /// + /// Calculates resources based on: + /// - Time elapsed since lastCollected + /// - Building's production rate (accounts for level multiplier) + /// - Storage capacity limit (prevents over-accumulation) + /// + /// Returns updated PlayerEconomy with: + /// - Added resources to inventory + /// - Updated building with new lastCollected timestamp + CollectResourcesResult execute({ + required PlayerEconomy economy, + required Building building, + DateTime? currentTime, + }) { + final now = currentTime ?? DateTime.now(); + + // Validate building is active + if (!building.isActive) { + return CollectResourcesResult( + economy: economy, + resourcesCollected: 0, + wasCapped: false, + ); + } + + // Calculate time elapsed + final elapsed = now.difference(building.lastCollected); + final hoursElapsed = elapsed.inMinutes / 60.0; + + // Calculate production (productionRate already includes level multiplier) + final rawProduction = hoursElapsed * building.productionRate; + + // Apply storage capacity limit + // Storage capacity = base production rate × 10 (equivalent to 10 hours storage) + final storageCapacity = building.production.baseRate * 10; + final cappedProduction = min(rawProduction, storageCapacity); + final resourceAmount = cappedProduction.toInt(); + + // Check if production was capped + final wasCapped = rawProduction > storageCapacity; + + // Add resources to economy + final updatedEconomy = economy.addResource( + building.production.resourceType, + resourceAmount, + ); + + // Update building's lastCollected timestamp + final updatedBuilding = building.copyWith(lastCollected: now); + + // Replace building in economy's building list + final buildingIndex = economy.buildings.indexWhere((b) => b.id == building.id); + if (buildingIndex == -1) { + // Building not found in economy, return economy with added resources only + return CollectResourcesResult( + economy: updatedEconomy, + resourcesCollected: resourceAmount, + wasCapped: wasCapped, + ); + } + + final updatedBuildings = List.from(economy.buildings); + updatedBuildings[buildingIndex] = updatedBuilding; + + final finalEconomy = updatedEconomy.copyWith(buildings: updatedBuildings); + + return CollectResourcesResult( + economy: finalEconomy, + resourcesCollected: resourceAmount, + wasCapped: wasCapped, + ); + } +} + +/// Result of collecting resources from a building +class CollectResourcesResult { + /// Updated player economy with collected resources + final PlayerEconomy economy; + + /// Amount of resources collected + final int resourcesCollected; + + /// Whether production was capped by storage capacity + final bool wasCapped; + + const CollectResourcesResult({ + required this.economy, + required this.resourcesCollected, + required this.wasCapped, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CollectResourcesResult && + runtimeType == other.runtimeType && + economy == other.economy && + resourcesCollected == other.resourcesCollected && + wasCapped == other.wasCapped; + + @override + int get hashCode => + economy.hashCode ^ resourcesCollected.hashCode ^ wasCapped.hashCode; + + @override + String toString() => + 'CollectResourcesResult(collected: $resourcesCollected, wasCapped: $wasCapped)'; +} diff --git a/trade_factory_masters/lib/domain/usecases/upgrade_building.dart b/trade_factory_masters/lib/domain/usecases/upgrade_building.dart new file mode 100644 index 0000000..b764b25 --- /dev/null +++ b/trade_factory_masters/lib/domain/usecases/upgrade_building.dart @@ -0,0 +1,99 @@ +import '../entities/player_economy.dart'; +import '../entities/building.dart'; +import '../core/result.dart'; + +/// Possible errors when upgrading a building +enum UpgradeError { + /// Building is already at maximum level + maxLevelReached, + + /// Player doesn't have enough gold + insufficientGold, + + /// Building is not active + buildingInactive, + + /// Building not found in economy + buildingNotFound, +} + +/// Use case for upgrading a building to increase production rate +/// Validates upgrade requirements and deducts gold from player's economy +class UpgradeBuildingUseCase { + /// Execute the building upgrade + /// + /// Performs the following checks: + /// 1. Building is active + /// 2. Building is not at max level + /// 3. Player has enough gold + /// + /// If all checks pass: + /// - Deducts upgrade cost from player's gold + /// - Increases building level by 1 + /// - Updates building in economy's building list + /// + /// Returns: + /// - Success(PlayerEconomy) with updated state + /// - Failure(UpgradeError) if any validation fails + Result execute({ + required PlayerEconomy economy, + required Building building, + }) { + // Validate building is active + if (!building.isActive) { + return Result.failure(UpgradeError.buildingInactive); + } + + // Validate building is not at max level + if (building.level >= building.upgradeConfig.maxLevel) { + return Result.failure(UpgradeError.maxLevelReached); + } + + // Calculate upgrade cost + final upgradeCost = building.calculateUpgradeCost(); + + // Validate player can afford upgrade + if (!economy.canAfford(upgradeCost)) { + return Result.failure(UpgradeError.insufficientGold); + } + + // Deduct gold from economy + final economyAfterPayment = economy.deductGold(upgradeCost); + + // Upgrade building (increase level) + final upgradedBuilding = building.copyWith( + level: building.level + 1, + ); + + // Update building in economy's building list + final buildingIndex = economy.buildings.indexWhere((b) => b.id == building.id); + if (buildingIndex == -1) { + return Result.failure(UpgradeError.buildingNotFound); + } + + final updatedBuildings = List.from(economyAfterPayment.buildings); + updatedBuildings[buildingIndex] = upgradedBuilding; + + final finalEconomy = economyAfterPayment.copyWith( + buildings: updatedBuildings, + ); + + return Result.success(finalEconomy); + } +} + +/// Extension to provide human-readable error messages +extension UpgradeErrorMessage on UpgradeError { + String get message { + switch (this) { + case UpgradeError.maxLevelReached: + return 'Building is already at maximum level'; + case UpgradeError.insufficientGold: + return 'Not enough gold to upgrade'; + case UpgradeError.buildingInactive: + return 'Cannot upgrade inactive building'; + case UpgradeError.buildingNotFound: + return 'Building not found in player economy'; + } + } +} diff --git a/trade_factory_masters/test/domain/usecases/collect_resources_test.dart b/trade_factory_masters/test/domain/usecases/collect_resources_test.dart new file mode 100644 index 0000000..a3fa15c --- /dev/null +++ b/trade_factory_masters/test/domain/usecases/collect_resources_test.dart @@ -0,0 +1,487 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:trade_factory_masters/domain/entities/building.dart'; +import 'package:trade_factory_masters/domain/entities/player_economy.dart'; +import 'package:trade_factory_masters/domain/entities/resource.dart'; +import 'package:trade_factory_masters/domain/usecases/collect_resources.dart'; + +void main() { + group('CollectResourcesUseCase', () { + late CollectResourcesUseCase useCase; + late PlayerEconomy testEconomy; + late Building testBuilding; + late DateTime testTime; + + setUp(() { + useCase = CollectResourcesUseCase(); + testTime = DateTime(2024, 1, 1, 12, 0); // Noon + + // Create test economy with wood resource + testEconomy = PlayerEconomy( + gold: 1000, + inventory: { + 'wood': const Resource( + id: 'wood', + displayName: 'Wood', + type: ResourceType.tier1, + amount: 100, + maxCapacity: 1000, + iconPath: 'assets/images/resources/wood.png', + ), + }, + buildings: [], + tier: 1, + lastSeen: testTime, + ); + + // Create test building (Lumbermill, level 1) + testBuilding = Building( + id: 'lumbermill_1', + type: BuildingType.collector, + level: 1, + gridPosition: const GridPosition(x: 5, y: 5), + production: const ProductionConfig( + baseRate: 10.0, // 10 wood per hour + resourceType: 'wood', + ), + upgradeConfig: const UpgradeConfig( + baseCost: 100, + costIncrement: 20, + ), + lastCollected: testTime.subtract(const Duration(hours: 1)), // 1 hour ago + isActive: true, + ); + }); + + group('Basic Collection', () { + test('collects resources based on elapsed time', () { + // 1 hour elapsed, baseRate = 10/hour, level 1 multiplier = 1.0 + // Expected: 10 wood + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + expect(result.resourcesCollected, equals(10)); + expect(result.wasCapped, isFalse); + }); + + test('updates inventory with collected resources', () { + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + expect(result.economy.inventory['wood']!.amount, equals(110)); // 100 + 10 + }); + + test('updates building lastCollected timestamp', () { + final economyWithBuilding = testEconomy.addBuilding(testBuilding); + + final result = useCase.execute( + economy: economyWithBuilding, + building: testBuilding, + currentTime: testTime, + ); + + final updatedBuilding = result.economy.buildings.first; + expect(updatedBuilding.lastCollected, equals(testTime)); + }); + + test('handles multiple hours elapsed', () { + // 3 hours elapsed + final buildingThreeHoursAgo = testBuilding.copyWith( + lastCollected: testTime.subtract(const Duration(hours: 3)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: buildingThreeHoursAgo, + currentTime: testTime, + ); + + // 3 hours × 10/hour = 30 wood + expect(result.resourcesCollected, equals(30)); + }); + + test('handles fractional hours correctly', () { + // 30 minutes elapsed (0.5 hours) + final buildingHalfHourAgo = testBuilding.copyWith( + lastCollected: testTime.subtract(const Duration(minutes: 30)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: buildingHalfHourAgo, + currentTime: testTime, + ); + + // 0.5 hours × 10/hour = 5 wood + expect(result.resourcesCollected, equals(5)); + }); + + test('handles zero elapsed time', () { + final buildingNow = testBuilding.copyWith(lastCollected: testTime); + + final result = useCase.execute( + economy: testEconomy, + building: buildingNow, + currentTime: testTime, + ); + + expect(result.resourcesCollected, equals(0)); + }); + }); + + group('Production Rate Multipliers', () { + test('applies level 2 multiplier (1.2×)', () { + // Level 2: baseRate × 1.2 = 10 × 1.2 = 12/hour + final level2Building = testBuilding.copyWith(level: 2); + + final result = useCase.execute( + economy: testEconomy, + building: level2Building, + currentTime: testTime, + ); + + // 1 hour × 12/hour = 12 wood + expect(result.resourcesCollected, equals(12)); + }); + + test('applies level 5 multiplier (1.8×)', () { + // Level 5: baseRate × 1.8 = 10 × 1.8 = 18/hour + final level5Building = testBuilding.copyWith(level: 5); + + final result = useCase.execute( + economy: testEconomy, + building: level5Building, + currentTime: testTime, + ); + + // 1 hour × 18/hour = 18 wood + expect(result.resourcesCollected, equals(18)); + }); + + test('applies level 10 multiplier (2.8×)', () { + // Level 10: baseRate × 2.8 = 10 × 2.8 = 28/hour + final level10Building = testBuilding.copyWith(level: 10); + + final result = useCase.execute( + economy: testEconomy, + building: level10Building, + currentTime: testTime, + ); + + // 1 hour × 28/hour = 28 wood + expect(result.resourcesCollected, equals(28)); + }); + }); + + group('Storage Capacity', () { + test('caps production at storage capacity', () { + // Storage capacity = baseRate × 10 = 10 × 10 = 100 + // 20 hours elapsed would produce 200, but capped at 100 + final buildingLongAgo = testBuilding.copyWith( + lastCollected: testTime.subtract(const Duration(hours: 20)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: buildingLongAgo, + currentTime: testTime, + ); + + expect(result.resourcesCollected, equals(100)); + expect(result.wasCapped, isTrue); + }); + + test('does not cap production below storage capacity', () { + // 5 hours elapsed = 50 wood (below 100 cap) + final building5HoursAgo = testBuilding.copyWith( + lastCollected: testTime.subtract(const Duration(hours: 5)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: building5HoursAgo, + currentTime: testTime, + ); + + expect(result.resourcesCollected, equals(50)); + expect(result.wasCapped, isFalse); + }); + + test('caps exactly at storage capacity', () { + // 10 hours elapsed = 100 wood (exactly at cap) + final building10HoursAgo = testBuilding.copyWith( + lastCollected: testTime.subtract(const Duration(hours: 10)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: building10HoursAgo, + currentTime: testTime, + ); + + expect(result.resourcesCollected, equals(100)); + expect(result.wasCapped, isFalse); + }); + + test('storage capacity scales with base rate', () { + // Higher base rate building (20/hour) + final fastBuilding = testBuilding.copyWith( + production: const ProductionConfig( + baseRate: 20.0, + resourceType: 'wood', + ), + lastCollected: testTime.subtract(const Duration(hours: 15)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: fastBuilding, + currentTime: testTime, + ); + + // Storage = 20 × 10 = 200 + // 15 hours × 20/hour = 300, capped at 200 + expect(result.resourcesCollected, equals(200)); + expect(result.wasCapped, isTrue); + }); + }); + + group('Inventory Integration', () { + test('respects player inventory maxCapacity', () { + // Player inventory nearly full + final nearlyFullEconomy = testEconomy.copyWith( + inventory: { + 'wood': const Resource( + id: 'wood', + displayName: 'Wood', + type: ResourceType.tier1, + amount: 990, + maxCapacity: 1000, + iconPath: 'assets/images/resources/wood.png', + ), + }, + ); + + final result = useCase.execute( + economy: nearlyFullEconomy, + building: testBuilding, + currentTime: testTime, + ); + + // Would add 10, but inventory caps at 1000 + expect(result.economy.inventory['wood']!.amount, equals(1000)); + }); + + test('handles resource not in inventory', () { + // Economy without wood resource + final economyNoWood = testEconomy.copyWith( + inventory: const {}, + ); + + final result = useCase.execute( + economy: economyNoWood, + building: testBuilding, + currentTime: testTime, + ); + + // Should return economy unchanged (addResource returns same if not found) + expect(result.economy.inventory, isEmpty); + expect(result.resourcesCollected, equals(10)); // Still calculates correctly + }); + }); + + group('Building States', () { + test('does not collect from inactive building', () { + final inactiveBuilding = testBuilding.copyWith(isActive: false); + + final result = useCase.execute( + economy: testEconomy, + building: inactiveBuilding, + currentTime: testTime, + ); + + expect(result.resourcesCollected, equals(0)); + expect(result.wasCapped, isFalse); + }); + + test('handles building not in economy buildings list', () { + // Building exists but not in economy.buildings + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + // Should still add resources to inventory + expect(result.economy.inventory['wood']!.amount, equals(110)); + expect(result.resourcesCollected, equals(10)); + }); + + test('updates correct building when multiple buildings exist', () { + final building2 = Building( + id: 'lumbermill_2', + type: BuildingType.collector, + level: 1, + gridPosition: const GridPosition(x: 10, y: 10), + production: const ProductionConfig( + baseRate: 10.0, + resourceType: 'wood', + ), + upgradeConfig: const UpgradeConfig( + baseCost: 100, + costIncrement: 20, + ), + lastCollected: testTime.subtract(const Duration(hours: 2)), + isActive: true, + ); + + final economyWithBuildings = testEconomy + .addBuilding(testBuilding) + .addBuilding(building2); + + final result = useCase.execute( + economy: economyWithBuildings, + building: testBuilding, + currentTime: testTime, + ); + + // Only testBuilding should be updated + final updatedBuilding1 = result.economy.buildings[0]; + final updatedBuilding2 = result.economy.buildings[1]; + + expect(updatedBuilding1.lastCollected, equals(testTime)); + expect(updatedBuilding2.lastCollected, equals(testTime.subtract(const Duration(hours: 2)))); + }); + }); + + group('Edge Cases', () { + test('handles very long elapsed time (days)', () { + // 100 hours elapsed (4+ days) + final buildingVeryOld = testBuilding.copyWith( + lastCollected: testTime.subtract(const Duration(hours: 100)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: buildingVeryOld, + currentTime: testTime, + ); + + // Should be capped at storage capacity (100) + expect(result.resourcesCollected, equals(100)); + expect(result.wasCapped, isTrue); + }); + + test('handles different resource types', () { + final stoneEconomy = testEconomy.copyWith( + inventory: { + 'stone': const Resource( + id: 'stone', + displayName: 'Stone', + type: ResourceType.tier1, + amount: 50, + maxCapacity: 1000, + iconPath: 'assets/images/resources/stone.png', + ), + }, + ); + + final stoneBuilding = testBuilding.copyWith( + production: const ProductionConfig( + baseRate: 8.0, + resourceType: 'stone', + ), + ); + + final result = useCase.execute( + economy: stoneEconomy, + building: stoneBuilding, + currentTime: testTime, + ); + + expect(result.resourcesCollected, equals(8)); + expect(result.economy.inventory['stone']!.amount, equals(58)); + }); + + test('uses DateTime.now() when currentTime not provided', () { + // This test verifies the default behavior + final recentBuilding = testBuilding.copyWith( + lastCollected: DateTime.now().subtract(const Duration(minutes: 1)), + ); + + final result = useCase.execute( + economy: testEconomy, + building: recentBuilding, + ); + + // Should collect small amount (approximately 0-1 wood) + expect(result.resourcesCollected, greaterThanOrEqualTo(0)); + expect(result.resourcesCollected, lessThanOrEqualTo(1)); + }); + + test('immutability - original economy unchanged', () { + final originalGold = testEconomy.gold; + final originalInventoryAmount = testEconomy.inventory['wood']!.amount; + + useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + expect(testEconomy.gold, equals(originalGold)); + expect(testEconomy.inventory['wood']!.amount, equals(originalInventoryAmount)); + }); + }); + + group('CollectResourcesResult', () { + test('result has correct structure', () { + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + expect(result, isA()); + expect(result.economy, isA()); + expect(result.resourcesCollected, isA()); + expect(result.wasCapped, isA()); + }); + + test('result toString contains useful info', () { + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + final str = result.toString(); + expect(str, contains('CollectResourcesResult')); + expect(str, contains('10')); // resourcesCollected + expect(str, contains('false')); // wasCapped + }); + + test('result equality works correctly', () { + final result1 = useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + final result2 = useCase.execute( + economy: testEconomy, + building: testBuilding, + currentTime: testTime, + ); + + expect(result1, equals(result2)); + expect(result1.hashCode, equals(result2.hashCode)); + }); + }); + }); +} diff --git a/trade_factory_masters/test/domain/usecases/upgrade_building_test.dart b/trade_factory_masters/test/domain/usecases/upgrade_building_test.dart new file mode 100644 index 0000000..d33b025 --- /dev/null +++ b/trade_factory_masters/test/domain/usecases/upgrade_building_test.dart @@ -0,0 +1,415 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:trade_factory_masters/domain/entities/building.dart'; +import 'package:trade_factory_masters/domain/entities/player_economy.dart'; +import 'package:trade_factory_masters/domain/usecases/upgrade_building.dart'; +import 'package:trade_factory_masters/domain/core/result.dart'; + +void main() { + group('UpgradeBuildingUseCase', () { + late UpgradeBuildingUseCase useCase; + late PlayerEconomy testEconomy; + late Building testBuilding; + late DateTime testTime; + + setUp(() { + useCase = UpgradeBuildingUseCase(); + testTime = DateTime(2024, 1, 1, 12, 0); + + // Create test building (level 1, upgrade cost = 100) + testBuilding = Building( + id: 'lumbermill_1', + type: BuildingType.collector, + level: 1, + gridPosition: const GridPosition(x: 5, y: 5), + production: const ProductionConfig( + baseRate: 10.0, + resourceType: 'wood', + ), + upgradeConfig: const UpgradeConfig( + baseCost: 100, + costIncrement: 20, + maxLevel: 10, + ), + lastCollected: testTime, + isActive: true, + ); + + // Create test economy with building and enough gold + testEconomy = PlayerEconomy( + gold: 500, + inventory: const {}, + buildings: [testBuilding], + tier: 1, + lastSeen: testTime, + ); + }); + + group('Successful Upgrades', () { + test('upgrades building from level 1 to level 2', () { + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + ); + + expect(result.isSuccess, isTrue); + final economy = result.valueOrNull!; + final upgradedBuilding = economy.buildings.first; + expect(upgradedBuilding.level, equals(2)); + }); + + test('deducts correct gold amount', () { + // Level 1→2 cost = baseCost = 100 + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + ); + + expect(result.isSuccess, isTrue); + final economy = result.valueOrNull!; + expect(economy.gold, equals(400)); // 500 - 100 + }); + + test('upgrades building from level 2 to level 3', () { + final level2Building = testBuilding.copyWith(level: 2); + final economyWithLevel2 = testEconomy.copyWith( + buildings: [level2Building], + ); + + final result = useCase.execute( + economy: economyWithLevel2, + building: level2Building, + ); + + expect(result.isSuccess, isTrue); + final economy = result.valueOrNull!; + final upgradedBuilding = economy.buildings.first; + expect(upgradedBuilding.level, equals(3)); + }); + + test('calculates cost correctly for level 2→3 upgrade', () { + // Level 2→3 cost = baseCost + (2-1) × costIncrement = 100 + 20 = 120 + final level2Building = testBuilding.copyWith(level: 2); + final economyWithLevel2 = testEconomy.copyWith( + buildings: [level2Building], + ); + + final result = useCase.execute( + economy: economyWithLevel2, + building: level2Building, + ); + + expect(result.isSuccess, isTrue); + final economy = result.valueOrNull!; + expect(economy.gold, equals(380)); // 500 - 120 + }); + + test('calculates cost correctly for level 5→6 upgrade', () { + // Level 5→6 cost = baseCost + (5-1) × costIncrement = 100 + 80 = 180 + final level5Building = testBuilding.copyWith(level: 5); + final economyWithLevel5 = testEconomy.copyWith( + buildings: [level5Building], + ); + + final result = useCase.execute( + economy: economyWithLevel5, + building: level5Building, + ); + + expect(result.isSuccess, isTrue); + final economy = result.valueOrNull!; + expect(economy.gold, equals(320)); // 500 - 180 + }); + + test('preserves all other building properties', () { + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + ); + + expect(result.isSuccess, isTrue); + final upgradedBuilding = result.valueOrNull!.buildings.first; + + expect(upgradedBuilding.id, equals(testBuilding.id)); + expect(upgradedBuilding.type, equals(testBuilding.type)); + expect(upgradedBuilding.gridPosition, equals(testBuilding.gridPosition)); + expect(upgradedBuilding.production, equals(testBuilding.production)); + expect(upgradedBuilding.upgradeConfig, equals(testBuilding.upgradeConfig)); + expect(upgradedBuilding.isActive, equals(testBuilding.isActive)); + }); + + test('updates correct building in multi-building economy', () { + final building2 = Building( + id: 'mine_1', + type: BuildingType.collector, + level: 3, + gridPosition: const GridPosition(x: 10, y: 10), + production: const ProductionConfig( + baseRate: 8.0, + resourceType: 'stone', + ), + upgradeConfig: const UpgradeConfig( + baseCost: 150, + costIncrement: 30, + ), + lastCollected: testTime, + isActive: true, + ); + + final economyWithMultipleBuildings = testEconomy.copyWith( + buildings: [testBuilding, building2], + ); + + final result = useCase.execute( + economy: economyWithMultipleBuildings, + building: testBuilding, + ); + + expect(result.isSuccess, isTrue); + final economy = result.valueOrNull!; + + // First building should be upgraded + expect(economy.buildings[0].level, equals(2)); + // Second building should remain unchanged + expect(economy.buildings[1].level, equals(3)); + }); + }); + + group('Validation Errors', () { + test('fails when building is at max level', () { + final maxLevelBuilding = testBuilding.copyWith(level: 10); + final economyWithMaxLevel = testEconomy.copyWith( + buildings: [maxLevelBuilding], + ); + + final result = useCase.execute( + economy: economyWithMaxLevel, + building: maxLevelBuilding, + ); + + expect(result.isFailure, isTrue); + expect(result.errorOrNull, equals(UpgradeError.maxLevelReached)); + }); + + test('fails when player has insufficient gold', () { + final poorEconomy = testEconomy.copyWith(gold: 50); // Need 100 + + final result = useCase.execute( + economy: poorEconomy, + building: testBuilding, + ); + + expect(result.isFailure, isTrue); + expect(result.errorOrNull, equals(UpgradeError.insufficientGold)); + }); + + test('fails when player has exactly one gold less than needed', () { + final almostEnoughEconomy = testEconomy.copyWith(gold: 99); + + final result = useCase.execute( + economy: almostEnoughEconomy, + building: testBuilding, + ); + + expect(result.isFailure, isTrue); + expect(result.errorOrNull, equals(UpgradeError.insufficientGold)); + }); + + test('succeeds when player has exact gold amount needed', () { + final exactGoldEconomy = testEconomy.copyWith(gold: 100); + + final result = useCase.execute( + economy: exactGoldEconomy, + building: testBuilding, + ); + + expect(result.isSuccess, isTrue); + expect(result.valueOrNull!.gold, equals(0)); + }); + + test('fails when building is inactive', () { + final inactiveBuilding = testBuilding.copyWith(isActive: false); + final economyWithInactive = testEconomy.copyWith( + buildings: [inactiveBuilding], + ); + + final result = useCase.execute( + economy: economyWithInactive, + building: inactiveBuilding, + ); + + expect(result.isFailure, isTrue); + expect(result.errorOrNull, equals(UpgradeError.buildingInactive)); + }); + + test('fails when building not found in economy', () { + final buildingNotInEconomy = Building( + id: 'nonexistent_building', + type: BuildingType.collector, + level: 1, + gridPosition: const GridPosition(x: 99, y: 99), + production: const ProductionConfig( + baseRate: 10.0, + resourceType: 'wood', + ), + upgradeConfig: const UpgradeConfig( + baseCost: 100, + costIncrement: 20, + ), + lastCollected: testTime, + isActive: true, + ); + + final result = useCase.execute( + economy: testEconomy, + building: buildingNotInEconomy, + ); + + expect(result.isFailure, isTrue); + expect(result.errorOrNull, equals(UpgradeError.buildingNotFound)); + }); + }); + + group('Edge Cases', () { + test('handles upgrade to max level', () { + final almostMaxBuilding = testBuilding.copyWith(level: 9); + final economyWithAlmostMax = testEconomy.copyWith( + buildings: [almostMaxBuilding], + gold: 1000, + ); + + final result = useCase.execute( + economy: economyWithAlmostMax, + building: almostMaxBuilding, + ); + + expect(result.isSuccess, isTrue); + final upgradedBuilding = result.valueOrNull!.buildings.first; + expect(upgradedBuilding.level, equals(10)); + }); + + test('cannot upgrade beyond max level', () { + final maxLevelBuilding = testBuilding.copyWith(level: 10); + final economyWithMax = testEconomy.copyWith( + buildings: [maxLevelBuilding], + ); + + final result = useCase.execute( + economy: economyWithMax, + building: maxLevelBuilding, + ); + + expect(result.isFailure, isTrue); + expect(result.errorOrNull, equals(UpgradeError.maxLevelReached)); + }); + + test('immutability - original economy unchanged on success', () { + final originalGold = testEconomy.gold; + final originalBuildingLevel = testEconomy.buildings.first.level; + + useCase.execute( + economy: testEconomy, + building: testBuilding, + ); + + expect(testEconomy.gold, equals(originalGold)); + expect(testEconomy.buildings.first.level, equals(originalBuildingLevel)); + }); + + test('immutability - original economy unchanged on failure', () { + final poorEconomy = testEconomy.copyWith(gold: 50); + final originalGold = poorEconomy.gold; + + useCase.execute( + economy: poorEconomy, + building: testBuilding, + ); + + expect(poorEconomy.gold, equals(originalGold)); + }); + }); + + group('Result Type Behavior', () { + test('Result.isSuccess is true for successful upgrade', () { + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + ); + + expect(result.isSuccess, isTrue); + expect(result.isFailure, isFalse); + }); + + test('Result.isFailure is true for failed upgrade', () { + final poorEconomy = testEconomy.copyWith(gold: 50); + + final result = useCase.execute( + economy: poorEconomy, + building: testBuilding, + ); + + expect(result.isFailure, isTrue); + expect(result.isSuccess, isFalse); + }); + + test('Result.fold handles success case', () { + final result = useCase.execute( + economy: testEconomy, + building: testBuilding, + ); + + final message = result.fold( + onSuccess: (economy) => 'Upgraded to level ${economy.buildings.first.level}', + onFailure: (error) => 'Failed: $error', + ); + + expect(message, equals('Upgraded to level 2')); + }); + + test('Result.fold handles failure case', () { + final poorEconomy = testEconomy.copyWith(gold: 50); + + final result = useCase.execute( + economy: poorEconomy, + building: testBuilding, + ); + + final message = result.fold( + onSuccess: (economy) => 'Success', + onFailure: (error) => 'Failed: ${error.name}', + ); + + expect(message, equals('Failed: insufficientGold')); + }); + }); + + group('UpgradeError Messages', () { + test('maxLevelReached has correct message', () { + expect( + UpgradeError.maxLevelReached.message, + equals('Building is already at maximum level'), + ); + }); + + test('insufficientGold has correct message', () { + expect( + UpgradeError.insufficientGold.message, + equals('Not enough gold to upgrade'), + ); + }); + + test('buildingInactive has correct message', () { + expect( + UpgradeError.buildingInactive.message, + equals('Cannot upgrade inactive building'), + ); + }); + + test('buildingNotFound has correct message', () { + expect( + UpgradeError.buildingNotFound.message, + equals('Building not found in player economy'), + ); + }); + }); + }); +}