// game_service.dart - Core game logic with empire import 'dart:math'; import '../models/game_state.dart'; import '../models/player.dart'; import '../models/gang_territory.dart'; import '../models/product.dart'; import '../models/loot.dart'; import '../models/city.dart'; import '../models/loan.dart'; import '../models/crew_member.dart'; import '../models/reputation.dart'; import '../models/combat.dart'; import '../utils/constants.dart'; import 'market_service.dart'; import '../models/weapon.dart'; import '../models/armor.dart'; import '../models/political_asset.dart'; import '../models/laundry_front.dart'; import 'event_service.dart'; class ActionResult { final bool success; final String message; final GameState? newState; const ActionResult({required this.success, required this.message, this.newState}); } class GameService { final MarketService _marketService = MarketService(); final EventService _eventService = EventService(); final Random _random = Random(); GameState newGame() { final player = Player.newGame(); final prices = _marketService.generateAllPrices(); return GameState( player: player, marketPrices: prices, previousPrices: prices, status: GameStatus.playing, statusMessage: '🎮 Welcome to Dope Wars 2026. Start in Miami with \$10,000.', eventLog: ['Game started. You\'re in Miami with \$10,000.'], ); } ActionResult buyProduct(GameState state, String productId, int quantity) { final player = state.player; var price = state.priceFor(productId); // Gang tax: outsiders pay more final terr = player.gangTerritories[player.currentCityId]; final gang = kGangs[player.currentCityId]; if (terr != null && gang != null && !terr.isControlled) { final penalty = terr.pricePenalty(gang); price *= (1.0 + penalty); // e.g. 0.25 tax = 25% higher prices } final totalCost = price * quantity; if (player.money < totalCost) { return ActionResult(success: false, message: 'Not enough cash! Need ${_fmt(totalCost)}, have ${_fmt(player.money)}'); } final newTotal = player.totalInventoryCount + quantity; if (newTotal > player.stashLimit) { final space = player.stashLimit - player.totalInventoryCount; return ActionResult(success: false, message: 'Stash full! Only $space slots left. Upgrade your stash.'); } final newInventory = Map.from(player.inventory); newInventory[productId] = (newInventory[productId] ?? 0) + quantity; final product = kProducts.firstWhere((p) => p.id == productId); double heatIncrease = kHeatPerSale; if (player.hasCrew(CrewRole.lookout)) heatIncrease *= 0.7; // Scanner upgrade reduces heat if (player.upgradeLevel('scanner') > 0) heatIncrease *= 0.6; var newPlayer = player.copyWith( money: player.money - totalCost, inventory: newInventory, heat: (player.heat + heatIncrease * 0.5).clamp(0.0, 100.0), ); final event = _eventService.rollTradeEvent(player.heat, player.warActive); final newLog = List.from(state.eventLog) ..insert(0, 'Bought $quantity x ${product.name} for ${_fmt(totalCost)}'); return ActionResult( success: true, message: 'Bought $quantity ${product.name} for ${_fmt(totalCost)}', newState: state.copyWith( player: newPlayer, eventLog: newLog, statusMessage: null, pendingEvent: event, ), ); } ActionResult sellProduct(GameState state, String productId, int quantity) { final player = state.player; final currentQty = player.quantityOf(productId); if (currentQty < quantity) { return ActionResult(success: false, message: 'You only have $currentQty units.'); } final product = kProducts.firstWhere((p) => p.id == productId); double price = state.priceFor(productId); // Rep bonus price *= player.repLevel.priceBonus; // Dealer crew bonus if (player.hasCrew(CrewRole.dealer)) price *= 1.10; // Heat penalty if (player.heat > 60) price *= 0.90; if (player.heat > 80) price *= 0.80; // Territory bonus: +15% in cities you own if (player.territories.contains(player.currentCityId)) price *= 1.15; // Gang tax: outsiders sell for less (they take a cut) final terrSell = player.gangTerritories[player.currentCityId]; final gangSell = kGangs[player.currentCityId]; if (terrSell != null && gangSell != null && !terrSell.isControlled) { final penalty = terrSell.pricePenalty(gangSell); price *= (1.0 - penalty * 0.5); // sell penalty is half the buy penalty } // Combo multiplier final comboBonus = 1.0 + (player.comboCount * 0.02).clamp(0, 0.20); // up to +20% price *= comboBonus; final totalEarned = price * quantity; // Drug money is dirty cash final newInventory = Map.from(player.inventory); newInventory[productId] = currentQty - quantity; if (newInventory[productId] == 0) newInventory.remove(productId); double heatIncrease = kHeatPerSale + (quantity * 0.1).clamp(0, 15); if (player.hasCrew(CrewRole.lookout)) heatIncrease *= 0.7; if (player.upgradeLevel('scanner') > 0) heatIncrease *= 0.6; final riskMult = (product.maxPrice / 1000).clamp(1.0, 10.0); final repGain = (totalEarned / 100).round().clamp(1, 5000); // Combo: increment if heat < 60, reset if heat >= 60 final newCombo = player.heat < 60 ? player.comboCount + 1 : 0; final newLifetimeCash = player.lifetimeCash + totalEarned; var newPlayer = player.copyWith( money: player.money + totalEarned, dirtyCash: player.dirtyCash + totalEarned, // drug money is dirty inventory: newInventory, heat: (player.heat + heatIncrease).clamp(0.0, 100.0), repPoints: player.repPoints + repGain, totalProfit: player.totalProfit + totalEarned, dealsMade: player.dealsMade + 1, comboCount: newCombo, lifetimeCash: newLifetimeCash, ); // Territory: selling consistently in a city builds control // After 5 deals in a city, claim territory (tracked via repPoints proxy) // Simple rule: 15% chance to claim territory if not already owned Set newTerritories = Set.from(newPlayer.territories); if (!newTerritories.contains(player.currentCityId) && _random.nextDouble() < 0.08) { newTerritories.add(player.currentCityId); } newPlayer = newPlayer.copyWith(territories: newTerritories); // Check & unlock achievements newPlayer = _checkAchievements(newPlayer); final event = _eventService.rollTradeEvent(player.heat, player.warActive); // Roll for special deal after a sale final specialDeal = _eventService.rollSpecialDeal( newPlayer.heat, newPlayer.repPoints, state.currentCityPrices); // Roll for combat ambush final combatEvent = _eventService.rollCombatAmbush(newPlayer.heat, newPlayer.repPoints); // Roll for boss challenge final bossEvent = combatEvent == null ? _eventService.rollBossChallenge(newPlayer.repPoints) : null; final newLog = List.from(state.eventLog) ..insert(0, _buildSaleLog(quantity, product.name, totalEarned, newCombo, comboBonus)); // Combo message String? statusMsg; if (newCombo >= 5) statusMsg = '🔥 ${newCombo}x COMBO! +${((comboBonus - 1) * 100).round()}% bonus!'; return ActionResult( success: true, message: 'Sold $quantity ${product.name} for ${_fmt(totalEarned)}!${newCombo >= 3 ? " 🔥 ${newCombo}x combo!" : ""}', newState: state.copyWith( player: newPlayer, eventLog: newLog, statusMessage: statusMsg, pendingEvent: event, pendingCombat: combatEvent ?? bossEvent, pendingDeal: specialDeal, ), ); } String _buildSaleLog(int qty, String name, double earned, int combo, double comboBonus) { var log = 'Sold $qty x $name for ${_fmt(earned)} 💰'; if (combo >= 3) log += ' 🔥 ${combo}x combo!'; return log; } ActionResult startTravel(GameState state, String destinationCityId) { final player = state.player; if (player.currentCityId == destinationCityId) { return ActionResult(success: false, message: 'You\'re already here!'); } if (player.heat >= 90) { return ActionResult(success: false, message: '🚔 ACTIVE MANHUNT! Cops everywhere. Lay low or bribe your way out.'); } double travelCost = kTravelCost; final destCity = kCities.firstWhere((c) => c.id == destinationCityId); if (destCity.isWarZone) travelCost *= 3; if (player.hasCrew(CrewRole.driver)) travelCost *= 0.75; // Fast car upgrade if (player.upgradeLevel('fast_car') > 0) travelCost *= 0.5; if (player.money < travelCost) { return ActionResult(success: false, message: 'Need ${_fmt(travelCost)} to travel. Hustle more!'); } final newPlayer = player.copyWith( money: player.money - travelCost, comboCount: 0, // travel breaks combo ); return ActionResult( success: true, message: 'Traveling to ${destCity.name}...', newState: state.copyWith( player: newPlayer, status: GameStatus.traveling, statusMessage: '✈️ Heading to ${destCity.name}...', ), ); } GameState completeTraveling(GameState state, String destinationCityId) { final city = kCities.firstWhere((c) => c.id == destinationCityId); Map? warMods; if (state.player.warActive) { warMods = {'opium': 2.0, 'heroin': 1.6}; } final oldPrices = Map>.from(state.marketPrices); final newPrices = Map>.from(state.marketPrices); newPrices[destinationCityId] = _marketService.refreshCityPrices(destinationCityId, warModifiers: warMods); final event = _eventService.rollTravelEvent(state.player.heat, state.player.warActive); double heatDecay = kHeatDecayOnTravel; if (state.player.repLevel.index >= 2) heatDecay += 3; // AC unit upgrade: extra heat decay if (state.player.upgradeLevel('ac_unit') > 0) heatDecay += 8; double crewCostDeducted = state.player.dailyCrewCost.toDouble(); List updatedLoans = state.player.activeLoans .map((l) => l.withInterest()) .toList(); final newVisited = Set.from(state.player.visitedCities)..add(destinationCityId); final isNewCity = !state.player.visitedCities.contains(destinationCityId); // Territory passive income final territoryIncome = state.player.territoryIncome; // Empire: laundry fronts auto-clean dirty cash final dailyLaundry = state.player.dailyLaundryClean; final launderedAmount = dailyLaundry.clamp(0, state.player.dirtyCash); final cleanedAmount = launderedAmount * 0.85; // 15% cut // Empire: daily political bribe cost final dailyPolitical = state.player.dailyPoliticalCost; // Political heat modifier double newHeat = state.player.heat - heatDecay; if (state.player.hasPoliticalAbility('full_immunity')) { newHeat = (newHeat - 10).clamp(0, 0); // immunity = heat always 0 } else if (state.player.hasPoliticalAbility('block_federal')) { newHeat -= 5; // extra decay } // Exoskeleton armor regen on travel Map regenDurability = Map.from(state.player.armorDurability); if (state.player.equippedArmorId != null) { final armor = state.player.equippedArmor; if (armor != null && armor.regenerates) { final cur = regenDurability[armor.id] ?? armor.maxDurability; regenDurability[armor.id] = (cur + 15).clamp(0, armor.maxDurability); } } var newPlayer = state.player.copyWith( currentCityId: destinationCityId, heat: newHeat.clamp(0.0, 100.0), daysPassed: state.player.daysPassed + 1, money: (state.player.money - crewCostDeducted + territoryIncome + cleanedAmount - dailyPolitical).clamp(0.0, double.infinity), dirtyCash: (state.player.dirtyCash - launderedAmount).clamp(0, double.infinity), activeLoans: updatedLoans, citiesVisited: newVisited.length, visitedCities: newVisited, repPoints: isNewCity ? state.player.repPoints + 10 : state.player.repPoints, armorDurability: regenDurability, ); // Rivals attack territory while you're away var rivalAttackMsg = ''; final rivals = state.activeRivals; if (newPlayer.territories.isNotEmpty && _random.nextDouble() < 0.15) { final terrList = newPlayer.territories.toList(); if (terrList.isNotEmpty && rivals.isNotEmpty) { final lost = terrList[_random.nextInt(terrList.length)]; final attacker = rivals[_random.nextInt(rivals.length)]; final newTerr = Set.from(newPlayer.territories)..remove(lost); newPlayer = newPlayer.copyWith(territories: newTerr); rivalAttackMsg = '${attacker.emoji} ${attacker.name} seized $lost while you were away!'; } } // Rival shipment attack — steals some inventory if (newPlayer.inventory.isNotEmpty && rivals.isNotEmpty && _random.nextDouble() < 0.1) { final inv = Map.from(newPlayer.inventory); final items = inv.keys.where((k) => inv[k]! > 0).toList(); if (items.isNotEmpty) { final hit = items[_random.nextInt(items.length)]; final stolen = (inv[hit]! * 0.2).ceil().clamp(1, inv[hit]!); inv[hit] = inv[hit]! - stolen; newPlayer = newPlayer.copyWith(inventory: inv); final attacker = rivals[_random.nextInt(rivals.length)]; rivalAttackMsg += (rivalAttackMsg.isNotEmpty ? '\n' : '') + '${attacker.emoji} ${attacker.name} intercepted your shipment! Lost $stolen units of $hit.'; } } String arrivalMessage = '${city.emoji} Arrived in ${city.name}!'; if (city.isWarZone) { arrivalMessage += ' ⚠️ WAR ZONE — prices volatile, heat rises fast.'; } if (territoryIncome > 0) { arrivalMessage += ' 💰 Territory income: ${_fmt(territoryIncome)}'; } final newLog = List.from(state.eventLog); newLog.insert(0, arrivalMessage); if (rivalAttackMsg.isNotEmpty) { newLog.insert(0, '⚔️ $rivalAttackMsg'); } if (crewCostDeducted > 0) { newLog.insert(0, '💸 Paid crew: ${_fmt(crewCostDeducted)}'); } if (territoryIncome > 0) { newLog.insert(0, '🏙️ Territory passive income: +${_fmt(territoryIncome)}'); } GameEvent? pendingEvent = event; for (final loan in updatedLoans) { if (loan.isOverdue(newPlayer.daysPassed)) { final inventoryPunishment = Map.from(newPlayer.inventory); for (final key in inventoryPunishment.keys.toList()) { final qty = inventoryPunishment[key]!; inventoryPunishment[key] = (qty * 0.5).floor(); if (inventoryPunishment[key]! == 0) inventoryPunishment.remove(key); } newPlayer = newPlayer.copyWith( money: (newPlayer.money * 0.7).clamp(0, double.infinity), inventory: inventoryPunishment, heat: (newPlayer.heat + 20).clamp(0, 100), ); pendingEvent = const GameEvent( type: EventType.loanSharkVisit, title: '🦈 LOAN SHARK GOONS', description: 'You\'re late on your debt. His boys found you and took 30% of your cash and half your stash.', effects: {}, actions: ['Understood'], ); newLog.insert(0, '🦈 Loan shark goons hit you for being late!'); break; } } if (event != null && pendingEvent == event) { switch (event.type) { case EventType.freeProduct: final productId = event.effects['productId'] as String; final qty = event.effects['quantity'] as int; final inv = Map.from(newPlayer.inventory); inv[productId] = (inv[productId] ?? 0) + qty; newPlayer = newPlayer.copyWith(inventory: inv, eventsSurvived: newPlayer.eventsSurvived + 1); break; default: break; } } bool warActive = state.player.warActive; if (!warActive && _random.nextDouble() < 0.05) { warActive = true; newLog.insert(0, '🌍 BREAKING: Iran-Israel war escalating. Global drug supply disrupted!'); } else if (warActive && _random.nextDouble() < 0.10) { warActive = false; newLog.insert(0, '☮️ Ceasefire declared. Markets stabilizing.'); } if (warActive != state.player.warActive) { newPlayer = newPlayer.copyWith(warActive: warActive); } // Roll for combat ambush on arrival final combatEvent = _eventService.rollCombatAmbush(newPlayer.heat, newPlayer.repPoints); newPlayer = _checkAchievements(newPlayer); return state.copyWith( player: newPlayer, marketPrices: newPrices, previousPrices: oldPrices, status: GameStatus.playing, statusMessage: arrivalMessage, eventLog: newLog, pendingEvent: pendingEvent, pendingCombat: combatEvent, ); } /// Resolve combat - fight action