using KernelPanic.Core; using KernelPanic.Data; using KernelPanic.Run; using KernelPanic.Meta; using KernelPanic.UI; using UnityEngine; using System; using System.Collections; using System.Collections.Generic; using System.Linq; namespace KernelPanic.Combat { /// /// Owns the combat turn phase state machine and coordinates combat lifecycle transitions. /// public sealed class CombatManager : MonoBehaviour { [SerializeField] private TurnPhase currentPhase = TurnPhase.Boot; private readonly DeckController deckController = new(); private readonly InterpreterQueue interpreterQueue = new(); private readonly LazyStack lazyStack = new(); private readonly NativeTrack nativeTrack = new(); private readonly StatusEffectController statusEffects = new(); private readonly DamagePipeline damagePipeline = new(); private readonly List enemies = new(); private readonly HashSet loggedEffectTodos = new(); private readonly List generatedCardPool = new(); private readonly List delayedEffects = new(); private RunManager runManager; private RunConfig runConfig; private HandController handController; private CombatantState playerState; private int selectedEnemyIndex = -1; private CardInstance pendingTargetCard; private bool awaitingWaveContinue; private bool runLost; private bool skipNextAllocateDraw; private bool ubuntuEmptyHandRefillUsed; private int fedoraCardsDiscountedThisTurn; private int fedoraCrashChance = 10; private int cardsPlayedThisTurn; private int cardsPlayedThisWave; private int turnNumberThisWave; private bool packageShieldBonusTriggeredThisTurn; private bool packageTimeshiftTriggeredThisWave; private bool packageInterpreterQueueShieldTriggeredThisWave; private bool packageNativeDamageTriggeredThisWave; private int javaCardsPlayedThisCombat; private int javaCardsDiscountThisTurn; private int rawhideBonusCharges; private int queuedRepeatCharges; private int nextTurnCycleBonus; private Coroutine phaseCoroutine; public TurnPhase CurrentPhase => currentPhase; public RunConfig RunConfig => runConfig; public DeckController DeckController => deckController; public HandController HandController => handController; public CombatantState PlayerState => playerState; public InterpreterQueue InterpreterQueue => interpreterQueue; public LazyStack LazyStack => lazyStack; public NativeTrack NativeTrack => nativeTrack; public DamagePipeline DamagePipeline => damagePipeline; public IReadOnlyList Enemies => enemies; public int SelectedEnemyIndex => selectedEnemyIndex; public CardInstance PendingTargetCard => pendingTargetCard; public bool AwaitingWaveContinue => awaitingWaveContinue; public bool RunLost => runLost; public int CurrentWaveNumber => runManager == null ? 1 : runManager.CurrentWaveNumber; public event Action StateChanged; public event Action CombatLog; private void Awake() { runManager = GetComponent(); } private void OnEnable() { GameEvents.DamageDealt += HandlePackageDamageDealt; } private void OnDisable() { GameEvents.DamageDealt -= HandlePackageDamageDealt; } public void StartCombat() { if (runConfig == null) { Debug.LogWarning("CombatManager.StartCombat called without a RunConfig."); return; } SetPhase(TurnPhase.Boot); } public void StartCombat(RunConfig config) { runConfig = config; StartCombat(); } public void AdvancePhase() { if (IsCombatPaused) { return; } TurnPhase nextPhase = GetNextPhase(currentPhase); SetPhase(nextPhase); } public void EndPlayerTurn() { if (currentPhase == TurnPhase.Execute && !IsCombatPaused) { pendingTargetCard = null; selectedEnemyIndex = -1; SetPhase(TurnPhase.Interpret); } } public void ContinueToNextWave() { if (!awaitingWaveContinue || runLost) { return; } awaitingWaveContinue = false; DecayJavaWarmupForNextWave(); StartCoroutine(ContinueToNextWaveSequenced()); } private IEnumerator ContinueToNextWaveSequenced() { yield return StartWaveSequenced(preservePlayerUptime: true); SetPhase(TurnPhase.Allocate); } public void SelectEnemy(int index) { if (IsCombatPaused) { return; } if (index < 0 || index >= enemies.Count) { selectedEnemyIndex = -1; StateChanged?.Invoke(); return; } selectedEnemyIndex = index; if (pendingTargetCard != null) { CardInstance card = pendingTargetCard; pendingTargetCard = null; PlayCard(card); return; } StateChanged?.Invoke(); } public bool PlayCard(CardInstance card) { if (currentPhase != TurnPhase.Execute || IsCombatPaused || card == null || handController == null || playerState == null) { return false; } if (card.Definition.IsToken) { Log($"{GetCardName(card)} is unplayable"); return false; } bool fedoraBonus = CanApplyFedoraBonus(); int cost = GetEffectiveCardCost(card); if (fedoraBonus) { cost = Mathf.Max(0, cost - 1); } if (playerState.Cycles < cost) { Log($"not enough cycles for {GetCardName(card)}"); return false; } if (RequiresTarget(card) && selectedEnemyIndex < 0) { pendingTargetCard = card; Log($"select target for {GetCardName(card)}"); StateChanged?.Invoke(); return false; } if (!handController.Remove(card)) { return false; } playerState.Cycles -= cost; ResolutionTrack track = card.Definition.ResolutionTrack; card.MarkPlayedThisTurn(cardsPlayedThisTurn == 0); cardsPlayedThisTurn++; cardsPlayedThisWave++; if (card.Definition.Language == Language.Java) { javaCardsPlayedThisCombat++; } GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, track)); LogPlayIntent(card); card.SetTargetSnapshot(CaptureTargets(card)); if (fedoraBonus) { fedoraCardsDiscountedThisTurn++; bool rawhideChargeUsed = rawhideBonusCharges > 0; if (rawhideChargeUsed) { rawhideBonusCharges--; } if (!IsCrashImmune(card) && RandomRoll.RollRange(1, 100, new RollContext(playerState)) <= fedoraCrashChance) { fedoraCrashChance = 10; if (IsDistro("fedora") && runConfig.DistroVersion >= 4) { playerState.Cycles += 1; } deckController.Discard(card); pendingTargetCard = null; selectedEnemyIndex = -1; Log($"{GetCardName(card)} crashed under bleeding edge"); StateChanged?.Invoke(); return true; } fedoraCrashChance = Mathf.Min(90, fedoraCrashChance + 10); card.MarkFedoraNonCrashBonus(); playerState.DamageMultiplierPercent = runConfig.DistroVersion >= 2 ? 175 : 150; ApplyFedoraGrowth(card, rawhideChargeUsed); } switch (track) { case ResolutionTrack.InterpreterQueue: card.MarkQueued(); interpreterQueue.Enqueue(card); break; case ResolutionTrack.LazyStack: lazyStack.Enqueue(card); break; default: ExecuteCardEffects(card, ResolveTargets(card)); ResolveCardRiders(card); deckController.Discard(card); GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.Native)); selectedEnemyIndex = -1; break; } playerState.DamageMultiplierPercent = 100; ApplyPackageCardPlayedEffects(card); pendingTargetCard = null; TryApplyUbuntuEmptyHandRefill(); StateChanged?.Invoke(); return true; } public static int GetCardCost(CardInstance card) { if (card?.Definition == null) { return 0; } return Mathf.Max(0, card.Definition.CycleCost + card.PermanentCostDelta + card.TemporaryCostDelta); } public int GetEffectiveCardCost(CardInstance card) { int cost = GetCardCost(card); if (card?.Definition != null && card.Definition.Language == Language.Java) { cost -= javaCardsPlayedThisCombat + javaCardsDiscountThisTurn; } cost = ApplyPackageCostRules(card, cost); return Mathf.Max(0, cost); } public bool RequiresTarget(CardInstance card) { return card?.Definition != null && CardEffectFactory.RequiresSingleTarget(card.Definition) && LivingEnemyStates().Count > 0; } public void LogEffectTodo(CardInstance card, string message) { string cardId = card?.Definition == null ? string.Empty : card.Definition.Id; string key = $"{cardId}:{message}"; if (loggedEffectTodos.Add(key)) { Log(message); } } public void ReportEffectResult(string message) { if (!string.IsNullOrWhiteSpace(message)) { Log(message); } } public void AddNextTurnCycleBonus(int amount) { nextTurnCycleBonus += Mathf.Max(0, amount); } public void AddQueuedRepeatCharges(int amount) { queuedRepeatCharges += Mathf.Max(0, amount); if (amount > 0) { ReportEffectResult($"next {amount} queued card(s) resolve twice"); } } public void AddJavaCostDiscountThisTurn(int amount) { int safeAmount = Mathf.Max(0, amount); if (safeAmount <= 0) { return; } javaCardsDiscountThisTurn += safeAmount; ReportEffectResult($"Java cards cost -{safeAmount} this turn"); } public int ConsumeQueuedResolutionCount(CardInstance card) { if (queuedRepeatCharges <= 0 || card == null) { return 1; } queuedRepeatCharges--; return 2; } public void GrantRawhideBonus(int charges) { rawhideBonusCharges += Mathf.Max(0, charges); } public void AddIncomingAttackHalfCharges(int charges) { if (playerState == null) { return; } playerState.IncomingAttackHalfCharges += Mathf.Max(0, charges); ReportEffectResult($"next {charges} enemy attack(s) halved"); } public void HandleCardExhausted(CardInstance card) { if (card == null || !HasPackageEffect(PackageEffectKind.ExhaustShield, out PackageEffectData effect)) { return; } GrantPlayerShield(Mathf.Max(0, effect.Amount), "logrotate"); } public void ScheduleUpdateManagerRepeat(int damageAmount) { delayedEffects.Add(DelayedCombatEffect.UpdateManagerRepeat(damageAmount, 2)); ReportEffectResult("update manager scheduled repeat"); } public void ScheduleTimeshiftRestore(int uptime) { delayedEffects.Add(DelayedCombatEffect.TimeshiftRestore(uptime, 2)); ReportEffectResult($"timeshift snapshot recorded {uptime} uptime"); } public bool TryCreateGeneratedCardById(string id, out CardInstance generatedCard) { generatedCard = null; if (string.IsNullOrWhiteSpace(id)) { return false; } IReadOnlyList source = generatedCardPool.Count > 0 ? generatedCardPool : runConfig?.StartingDeck; if (source == null) { return false; } for (int i = 0; i < source.Count; i++) { CardDefinition definition = source[i]; if (definition != null && string.Equals(definition.Id, id, StringComparison.OrdinalIgnoreCase)) { generatedCard = new CardInstance(definition); return true; } } return false; } public void SetGeneratedCardPool(IEnumerable cardPool) { generatedCardPool.Clear(); if (cardPool == null) { return; } foreach (CardDefinition card in cardPool) { if (card != null) { generatedCardPool.Add(card); } } } public bool TryCreateGeneratedCard(Language language, Rarity rarity, out CardInstance generatedCard) { generatedCard = null; List pool = new(); HashSet seenIds = new(); IReadOnlyList source = generatedCardPool.Count > 0 ? generatedCardPool : runConfig?.StartingDeck; if (source == null) { return false; } for (int i = 0; i < source.Count; i++) { CardDefinition definition = source[i]; if (definition == null || definition.IsToken || definition.IsRunOnly || definition.Language != language || definition.Rarity != rarity) { continue; } if (seenIds.Add(definition.Id)) { pool.Add(definition); } } if (pool.Count == 0) { return false; } int index = RandomRoll.RollRange(0, pool.Count - 1, new RollContext(playerState)); CardDefinition selected = pool[index]; generatedCard = new CardInstance(selected) { TemporaryCostDelta = -selected.CycleCost }; return true; } private void SetPhase(TurnPhase nextPhase) { if (IsCombatPaused && nextPhase != TurnPhase.Boot) { return; } TurnPhase previousPhase = currentPhase; currentPhase = nextPhase; GameEvents.RaisePhaseChanged(new PhaseChangedEvent(previousPhase, nextPhase)); if (phaseCoroutine != null) { StopCoroutine(phaseCoroutine); } phaseCoroutine = StartCoroutine(EnterPhase(nextPhase)); } private static TurnPhase GetNextPhase(TurnPhase phase) { return phase switch { TurnPhase.Boot => TurnPhase.Allocate, TurnPhase.Allocate => TurnPhase.Execute, TurnPhase.Execute => TurnPhase.Interpret, TurnPhase.Interpret => TurnPhase.EnemyProcess, TurnPhase.EnemyProcess => TurnPhase.GarbageCollection, _ => TurnPhase.Allocate }; } private IEnumerator EnterPhase(TurnPhase phase) { switch (phase) { case TurnPhase.Boot: yield return BootCombatSequenced(); break; case TurnPhase.Allocate: yield return AllocateTurnSequenced(); break; case TurnPhase.Execute: StateChanged?.Invoke(); yield break; case TurnPhase.Interpret: yield return InterpretQueueSequenced(); break; case TurnPhase.EnemyProcess: yield return ProcessEnemiesSequenced(); break; case TurnPhase.GarbageCollection: GarbageCollect(); break; } if (!IsCombatPaused) { yield return Wait(CombatTuning.PhaseTransitionDelaySeconds); AdvancePhase(); } } private static IEnumerator Wait(float seconds) { if (!UIPreferences.ReducedMotion && seconds > 0f) { yield return new WaitForSeconds(seconds); } } private IEnumerator BootCombatSequenced() { runLost = false; awaitingWaveContinue = false; loggedEffectTodos.Clear(); ubuntuEmptyHandRefillUsed = false; fedoraCrashChance = 10; fedoraCardsDiscountedThisTurn = 0; cardsPlayedThisTurn = 0; cardsPlayedThisWave = 0; turnNumberThisWave = 0; packageShieldBonusTriggeredThisTurn = false; packageTimeshiftTriggeredThisWave = false; packageInterpreterQueueShieldTriggeredThisWave = false; packageNativeDamageTriggeredThisWave = false; javaCardsPlayedThisCombat = 0; javaCardsDiscountThisTurn = 0; rawhideBonusCharges = 0; queuedRepeatCharges = 0; nextTurnCycleBonus = 0; delayedEffects.Clear(); RandomRoll.Seed(runConfig.RunSeed); playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles()); ApplyVersionState(playerState); ApplyPackageState(playerState); yield return StartWaveSequenced(preservePlayerUptime: false); Log($"booted {runConfig.Distro.DisplayName} with {runManager.RunDeck.Count} cards"); StateChanged?.Invoke(); } private IEnumerator StartWaveSequenced(bool preservePlayerUptime) { int carriedUptime = playerState == null ? 0 : playerState.CurrentUptime; if (!preservePlayerUptime || playerState == null) { playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles()); ApplyVersionState(playerState); ApplyPackageState(playerState); } else { playerState.MaxUptime = runManager.EffectiveMaxUptime(); playerState.CurrentUptime = Mathf.Clamp(carriedUptime, 1, playerState.MaxUptime); playerState.Ram = runManager.EffectiveRam(); playerState.MaxCycles = runManager.EffectiveMaxCycles(); playerState.Cycles = 0; playerState.Shield = 0; playerState.IncomingAttackHalfCharges = 0; playerState.IsDefeated = false; playerState.MutableStatuses.Clear(); ApplyVersionState(playerState); ApplyPackageState(playerState); } handController = new HandController(playerState.Ram); selectedEnemyIndex = -1; pendingTargetCard = null; cardsPlayedThisWave = 0; turnNumberThisWave = 0; packageShieldBonusTriggeredThisTurn = false; packageTimeshiftTriggeredThisWave = false; List startingCards = new(); for (int i = 0; i < runManager.RunDeck.Count; i++) { CardInstance card = runManager.RunDeck[i]; if (card != null) { card.ResetCombatState(); startingCards.Add(card); } } deckController.Initialize(startingCards); interpreterQueue.Clear(); enemies.Clear(); SpawnStructuralEnemies(); PickEnemyIntents(); StateChanged?.Invoke(); yield return DrawOpeningHandSequenced(); ApplyPackageWaveStartEffects(); skipNextAllocateDraw = true; } private IEnumerator AllocateTurnSequenced() { if (CheckLoss()) { yield break; } playerState.Cycles = playerState.MaxCycles; fedoraCardsDiscountedThisTurn = 0; cardsPlayedThisTurn = 0; turnNumberThisWave++; packageShieldBonusTriggeredThisTurn = false; javaCardsDiscountThisTurn = 0; ApplyPackageTurnStartEffects(); if (nextTurnCycleBonus > 0) { playerState.Cycles += nextTurnCycleBonus; Log($"deferred cycle grant: +{nextTurnCycleBonus}"); nextTurnCycleBonus = 0; } statusEffects.Tick(playerState, StatusTickTiming.StartOfTurn, playerState, damagePipeline); StateChanged?.Invoke(); if (CheckLoss()) { yield break; } TryApplyUbuntuAptUpdate(); if (skipNextAllocateDraw) { skipNextAllocateDraw = false; } else { yield return DrawForTurnSequenced(); } StateChanged?.Invoke(); } private IEnumerator InterpretQueueSequenced() { while (interpreterQueue.TryDequeue(out CardInstance card)) { LogPlayIntent(card); int resolutionCount = ConsumeQueuedResolutionCount(card); for (int repeatIndex = 0; repeatIndex < resolutionCount; repeatIndex++) { ExecuteCardEffects(card, ResolveQueuedTargets(card)); if (CheckWinOrLoss()) { yield break; } } ResolveCardRiders(card); deckController.Discard(card); GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue)); StateChanged?.Invoke(); if (CheckWinOrLoss()) { yield break; } yield return Wait(CombatTuning.QueueCardResolveDelaySeconds); } CheckWinOrLoss(); StateChanged?.Invoke(); } private IEnumerator ProcessEnemiesSequenced() { for (int i = 0; i < enemies.Count; i++) { EnemyInstance enemy = enemies[i]; if (enemy.State.IsDefeated) { continue; } statusEffects.Tick(enemy.State, StatusTickTiming.StartOfTurn, playerState, damagePipeline); RemoveDefeatedEnemies(); if (CheckWinOrLoss()) { yield break; } if (enemy.State.IsDefeated) { continue; } Log($"{enemy.Name} executes {enemy.CurrentIntent.DisplayText}"); GameEvents.RaiseEnemyWouldAct(new EnemyWouldActEvent(enemy)); StateChanged?.Invoke(); yield return Wait(CombatTuning.EnemyTelegraphDelaySeconds); ExecuteEnemyIntent(enemy); GameEvents.RaiseEnemyActed(new EnemyActedEvent(enemy, enemy.CurrentIntent)); StateChanged?.Invoke(); if (CheckLoss()) { yield break; } yield return Wait(CombatTuning.EnemyActionDelaySeconds); } PickEnemyIntents(); StateChanged?.Invoke(); } private void GarbageCollect() { selectedEnemyIndex = -1; pendingTargetCard = null; statusEffects.Tick(playerState, StatusTickTiming.EndOfTurn, playerState, damagePipeline); if (CheckLoss()) { return; } ResolveDelayedEndOfTurnEffects(); if (CheckWinOrLoss()) { return; } RemoveDefeatedEnemies(); CheckWinOrLoss(); StateChanged?.Invoke(); } private IEnumerator DrawOpeningHandSequenced() { int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, playerState.Ram); yield return DrawCardsToHandSequenced(openingDraw, "opening hand"); } private IEnumerator DrawForTurnSequenced() { int requested = CombatTuning.DrawPerTurn; if (CombatTuning.MinimumHandFloor > 0 && handController.Cards.Count < CombatTuning.MinimumHandFloor && deckController.AvailableToDrawCount > 0) { requested = Mathf.Max(requested, CombatTuning.MinimumHandFloor - handController.Cards.Count); } yield return DrawCardsToHandSequenced(requested, "turn draw"); } private IEnumerator DrawCardsToHandSequenced(int requestedCount, string label) { if (handController == null || playerState == null || requestedCount <= 0) { yield break; } int room = handController.RemainingRam; if (room <= 0) { Log($"{label}: hand full"); yield break; } IReadOnlyList drawn = deckController.Draw(requestedCount); int added = 0; for (int i = 0; i < drawn.Count; i++) { if (handController.Add(drawn[i])) { added++; StateChanged?.Invoke(); yield return Wait(CombatTuning.CardDrawDelaySeconds); } else { deckController.AddToDrawPile(drawn[i], shuffle: false); } } if (added == 0 && deckController.AvailableToDrawCount == 0) { Log($"{label}: no cards to draw"); } else if (added < requestedCount) { Log($"{label}: drew {added} (hand full)"); } else { Log($"{label}: drew {added}"); } } private void TryApplyUbuntuAptUpdate() { if (!IsDistro("ubuntu") || handController == null || playerState == null || handController.RemainingRam <= 0) { return; } int lookCount = runConfig.DistroVersion >= 2 ? 3 : 2; if (!deckController.TryDrawCheapestFromTop(lookCount, out CardInstance card)) { return; } if (runConfig.DistroVersion >= 4) { card.TemporaryCostDelta -= 1; } if (handController.Add(card)) { Log($"apt update: staged {GetCardName(card)} from top {lookCount}"); } else { deckController.AddToDrawPile(card, shuffle: false); } } private void TryApplyUbuntuEmptyHandRefill() { if (!IsDistro("ubuntu") || runConfig.DistroVersion < 5 || ubuntuEmptyHandRefillUsed || handController == null || handController.Cards.Count > 0) { return; } ubuntuEmptyHandRefillUsed = true; StartCoroutine(DrawCardsToHandSequenced(playerState.Ram, "ubuntu 24.04 refill")); } private bool CanApplyFedoraBonus() { return CanApplyFedoraPassiveBonus() || rawhideBonusCharges > 0; } private void DecayJavaWarmupForNextWave() { if (javaCardsPlayedThisCombat <= 0) { return; } javaCardsPlayedThisCombat = Mathf.Max(0, javaCardsPlayedThisCombat - 1); Log($"Java JIT cooled: discount now -{javaCardsPlayedThisCombat}"); } private bool CanApplyFedoraPassiveBonus() { if (!IsDistro("fedora") || playerState == null) { return false; } int limit = runConfig.DistroVersion >= 5 || HasOnDistroPackageEffect(PackageEffectKind.DnfFedoraPassive, out PackageEffectData dnfEffect) && dnfEffect.EnableFedoraSecondCardPassive ? 2 : 1; return fedoraCardsDiscountedThisTurn < limit; } private void ApplyFedoraGrowth(CardInstance card, bool rawhideChargeUsed) { if (!IsDistro("fedora") || runConfig.DistroVersion < 3 || card == null || !card.WasFirstCardThisTurn) { return; } int growth = rawhideChargeUsed ? 2 : 1; if (card.ApplyCombatMagnitudeBonus(growth)) { Log($"rawhide growth: {GetCardName(card)} +{growth} effect"); } } private int ApplyPackageCostRules(CardInstance card, int cost) { if (card == null || cost <= 0) { return cost; } IReadOnlyList packages = runConfig?.EquippedPackages; if (packages == null) { return cost; } int nextTurnCard = cardsPlayedThisTurn + 1; int nextWaveCard = cardsPlayedThisWave + 1; for (int i = 0; i < packages.Count; i++) { PackageInstance package = packages[i]; if (package?.Definition == null) { continue; } PackageEffectData effect = package.EffectFor(runConfig?.Distro?.Id); switch (effect.Kind) { case PackageEffectKind.FirstCardEachWaveCostReduction: if (nextWaveCard == 1) { cost -= Mathf.Max(0, effect.Amount); } break; case PackageEffectKind.EveryNthCardEachWaveFree: if (effect.Threshold > 0 && nextWaveCard % effect.Threshold == 0) { cost = 0; } break; case PackageEffectKind.FirstCardsEachTurnCostReduction: if (nextTurnCard <= Mathf.Max(0, effect.Threshold)) { cost -= Mathf.Max(0, effect.Amount); } break; } } return cost; } private void ApplyPackageState(CombatantState state) { if (state == null) { return; } state.JavaScriptFlatDamageBonus = 0; IReadOnlyList packages = runConfig?.EquippedPackages; if (packages == null) { return; } for (int i = 0; i < packages.Count; i++) { PackageEffectData effect = packages[i] == null ? default : packages[i].EffectFor(runConfig?.Distro?.Id); if (effect.Kind == PackageEffectKind.JavaScriptFlatDamage) { state.JavaScriptFlatDamageBonus += Mathf.Max(0, effect.Amount); } } } private void ApplyPackageWaveStartEffects() { IReadOnlyList packages = runConfig?.EquippedPackages; if (packages == null) { return; } for (int i = 0; i < packages.Count; i++) { PackageInstance package = packages[i]; if (package?.Definition == null) { continue; } PackageEffectData effect = package.EffectFor(runConfig?.Distro?.Id); switch (effect.Kind) { case PackageEffectKind.WaveDraw: StartCoroutine(DrawCardsToHandSequenced(Mathf.Max(0, effect.Amount), package.Definition.DisplayName)); break; case PackageEffectKind.WaveStartShield: GrantPlayerShield(Mathf.Max(0, effect.Amount), package.Definition.DisplayName); break; case PackageEffectKind.FirstTurnEachWaveShield: if (turnNumberThisWave == 0) { GrantPlayerShield(Mathf.Max(0, effect.Amount), package.Definition.DisplayName); } break; case PackageEffectKind.FirstTurnFirstWaveDraw: if (CurrentWaveNumber == 1 && turnNumberThisWave == 0) { StartCoroutine(DrawCardsToHandSequenced(Mathf.Max(0, effect.Amount), package.Definition.DisplayName)); } break; case PackageEffectKind.WaveGenerateBasicCard: GeneratePackageLanguageCard(package.Definition.DisplayName); break; } } } private void ApplyPackageTurnStartEffects() { if (HasPackageEffect(PackageEffectKind.EveryNthTurnShield, out PackageEffectData effect) && effect.Threshold > 0 && turnNumberThisWave % effect.Threshold == 0) { GrantPlayerShield(Mathf.Max(0, effect.Amount), "cron"); } if (HasPackageEffect(PackageEffectKind.EveryNthTurnDraw, out effect) && effect.Threshold > 0 && turnNumberThisWave % effect.Threshold == 0) { StartCoroutine(DrawCardsToHandSequenced(Mathf.Max(0, effect.Amount), "udev")); } if (turnNumberThisWave == 1 && HasPackageEffect(PackageEffectKind.FirstTurnEachWaveCycle, out effect) && effect.Amount > 0) { playerState.Cycles += effect.Amount; Log($"apt-fast: gained {effect.Amount} Cycle"); } if (HasPackageEffect(PackageEffectKind.StartTurnNoDebuffShield, out effect) && !HasHarmfulStatus(playerState)) { GrantPlayerShield(Mathf.Max(0, effect.Amount), "auditd"); } } private void ApplyPackageCardPlayedEffects(CardInstance card) { if (card == null || playerState == null) { return; } if (HasPackageEffect(PackageEffectKind.ThirdCardEachTurnGenerate, out PackageEffectData aptEffect) && cardsPlayedThisTurn == Mathf.Max(1, aptEffect.Threshold)) { GeneratePackageLanguageCard("apt"); if (aptEffect.RefundCycle) { playerState.Cycles += 1; Log("apt refunded 1 Cycle"); } } if (HasPackageEffect(PackageEffectKind.DnfFedoraPassive, out PackageEffectData dnfEffect) && cardsPlayedThisTurn == 1 && dnfEffect.Amount > 0) { playerState.FlatEffectBonus += dnfEffect.Amount; Log($"DNF wave buff +{dnfEffect.Amount} damage"); } if (!packageInterpreterQueueShieldTriggeredThisWave && card.Definition.ResolutionTrack == ResolutionTrack.InterpreterQueue && HasPackageEffect(PackageEffectKind.FirstInterpreterQueueCardEachWaveShield, out PackageEffectData queueEffect)) { packageInterpreterQueueShieldTriggeredThisWave = true; GrantPlayerShield(Mathf.Max(0, queueEffect.Amount), "dbus"); } if (!packageNativeDamageTriggeredThisWave && card.Definition.ResolutionTrack == ResolutionTrack.Native && HasPackageEffect(PackageEffectKind.FirstNativeCardEachWaveFlatDamage, out PackageEffectData nativeEffect) && nativeEffect.Amount > 0) { packageNativeDamageTriggeredThisWave = true; playerState.FlatEffectBonus += nativeEffect.Amount; Log($"make: first Native card +{nativeEffect.Amount} damage"); } if (HasPackageEffect(PackageEffectKind.EveryNthCardEachWaveCycle, out PackageEffectData cycleEffect) && cycleEffect.Threshold > 0 && cardsPlayedThisWave % cycleEffect.Threshold == 0 && cycleEffect.Amount > 0) { playerState.Cycles += cycleEffect.Amount; Log($"systemd-timer: gained {cycleEffect.Amount} Cycle"); } } public void GrantPlayerShield(int amount, string label) { if (playerState == null || amount <= 0) { return; } int total = amount; if (!packageShieldBonusTriggeredThisTurn && HasPackageEffect(PackageEffectKind.FirstShieldEachTurnBonus, out PackageEffectData effect)) { int bonus = Mathf.Max(0, effect.Amount); if (bonus > 0) { total += bonus; packageShieldBonusTriggeredThisTurn = true; Log($"SELinux shield +{bonus}"); } } playerState.Shield += total; if (!string.IsNullOrWhiteSpace(label)) { Log($"{label}: gained {total} Shield"); } } private void GeneratePackageLanguageCard(string label) { if (handController == null || handController.RemainingRam <= 0) { return; } Language language = RandomRoll.RollRange(0, 1, new RollContext(playerState)) == 0 ? runConfig.PrimaryLanguage : runConfig.SecondaryLanguage; if (!TryCreateGeneratedCard(language, Rarity.Common, out CardInstance generated)) { Log($"{label}: TODO card-generation pool unavailable"); return; } if (handController.Add(generated)) { Log($"{label}: generated {GetCardName(generated)} at 0c"); StateChanged?.Invoke(); } } private void HandlePackageDamageDealt(DamageDealtEvent payload) { if (payload.Target != playerState || payload.UptimeDamage <= 0 || packageTimeshiftTriggeredThisWave || !HasPackageEffect(PackageEffectKind.WaveThresholdRestore, out PackageEffectData effect)) { return; } int thresholdPercent = Mathf.Clamp(effect.Threshold, 1, 100); int restoreTarget = Mathf.CeilToInt(playerState.MaxUptime * (thresholdPercent / 100f)); if (playerState.CurrentUptime >= restoreTarget) { return; } packageTimeshiftTriggeredThisWave = true; playerState.CurrentUptime = restoreTarget; if (effect.CleanseDebuffs) { statusEffects.CleanseHarmful(playerState); } Log($"Timeshift restored uptime to {thresholdPercent}%"); StateChanged?.Invoke(); } private bool HasOnDistroPackageEffect(PackageEffectKind kind, out PackageEffectData effect) { effect = default; IReadOnlyList packages = runConfig?.EquippedPackages; if (packages == null) { return false; } for (int i = 0; i < packages.Count; i++) { PackageInstance package = packages[i]; if (package?.Definition == null || !package.Definition.IsIntendedFor(runConfig?.Distro?.Id)) { continue; } effect = package.EffectFor(runConfig?.Distro?.Id); if (effect.Kind == kind) { return true; } } return false; } private bool HasPackageEffect(PackageEffectKind kind, out PackageEffectData effect) { effect = default; IReadOnlyList packages = runConfig?.EquippedPackages; if (packages == null) { return false; } for (int i = 0; i < packages.Count; i++) { PackageInstance package = packages[i]; if (package?.Definition == null) { continue; } effect = package.EffectFor(runConfig?.Distro?.Id); if (effect.Kind == kind) { return true; } } return false; } private static bool HasHarmfulStatus(CombatantState state) { if (state == null) { return false; } for (int i = 0; i < state.Statuses.Count; i++) { if (!StatusEffectController.GetDescriptor(state.Statuses[i].Type).IsBeneficial) { return true; } } return false; } private static bool IsCrashImmune(CardInstance card) { string id = card?.Definition?.Id; return string.Equals(id, "fedora_selinux", StringComparison.OrdinalIgnoreCase); } private void ApplyVersionState(CombatantState state) { if (state == null) { return; } state.forceMaxRolls = IsDistro("mint"); state.IgnoreDamageMultipliers = IsDistro("mint"); state.AllowFlatDamageBuffs = IsDistro("mint") && runConfig.DistroVersion >= 4; state.FlatEffectBonus = IsDistro("mint") && runConfig.DistroVersion >= 2 ? 2 : 0; state.DamageMultiplierPercent = 100; } private bool IsDistro(string id) { return string.Equals(runConfig?.Distro?.Id, id, StringComparison.OrdinalIgnoreCase); } public bool CanPlayerReceiveCardShield(CardInstance card) { return CanPlayerReceiveCardBuff(card); } public bool CanPlayerReceiveCardHeal(CardInstance card) { return CanPlayerReceiveCardBuff(card); } public bool CanPlayerReceiveCardBuff(CardInstance card) { return !IsDistro("mint") || IsCurrentDistroExclusiveCard(card); } private bool IsCurrentDistroExclusiveCard(CardInstance card) { CardDefinition definition = card?.Definition; IReadOnlyList exclusiveCards = runConfig?.Distro?.ExclusiveCards; if (definition == null || exclusiveCards == null) { return false; } for (int i = 0; i < exclusiveCards.Count; i++) { CardDefinition exclusive = exclusiveCards[i]; if (exclusive == null) { continue; } if (ReferenceEquals(exclusive, definition) || string.Equals(exclusive.Id, definition.Id, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private CombatContext BuildContext(CardInstance card, IReadOnlyList targets) { return new CombatContext(card, playerState, targets, this, damagePipeline, statusEffects, deckController, handController, enemies); } private void ExecuteCardEffects(CardInstance card, IReadOnlyList targets) { IReadOnlyList effects = CardEffectFactory.CreateEffects(card?.Definition); CombatContext context = BuildContext(card, targets); for (int i = 0; i < effects.Count; i++) { effects[i].Execute(context); RemoveDefeatedEnemies(); if (CheckWinOrLoss()) { return; } } } private void ResolveCardRiders(CardInstance card) { if (card == null || card.DrawRider <= 0 || handController == null || playerState == null) { return; } StartCoroutine(DrawCardsToHandSequenced(card.DrawRider, "upgrade rider")); } private IReadOnlyList CaptureTargets(CardInstance card) { if (card?.Definition == null) { return Array.Empty(); } if (CardEffectFactory.TargetsAllEnemies(card.Definition)) { return LivingEnemyStates(); } if (selectedEnemyIndex >= 0 && selectedEnemyIndex < enemies.Count && !enemies[selectedEnemyIndex].State.IsDefeated) { return new[] { enemies[selectedEnemyIndex].State }; } if (RequiresEnemyTarget(card)) { CombatantState firstLiving = FirstLivingEnemyState(); return firstLiving == null ? Array.Empty() : new[] { firstLiving }; } return Array.Empty(); } private IReadOnlyList ResolveTargets(CardInstance card) { List targets = card.TargetSnapshot.Where(target => target != null && !target.IsDefeated).ToList(); if (targets.Count == 0 && RequiresEnemyTarget(card)) { CombatantState firstLiving = FirstLivingEnemyState(); if (firstLiving != null) { targets.Add(firstLiving); } } return targets; } private IReadOnlyList ResolveQueuedTargets(CardInstance card) { List targets = ResolveTargets(card).ToList(); if (targets.Count == 0 && CardEffectFactory.TargetsAllEnemies(card?.Definition)) { targets.AddRange(LivingEnemyStates()); } // Queued cards snapshot targets when played. If those targets die before Interpret, retarget to living enemies. return targets; } private bool RequiresEnemyTarget(CardInstance card) { if (card?.Definition == null) { return false; } return CardEffectFactory.RequiresSingleTarget(card.Definition) || CardEffectFactory.TargetsAllEnemies(card.Definition) || card.Definition.Id == "lang_py_print"; } private List LivingEnemyStates() { return enemies .Where(enemy => enemy.State != null && !enemy.State.IsDefeated) .Select(enemy => enemy.State) .ToList(); } private CombatantState FirstLivingEnemyState() { for (int i = 0; i < enemies.Count; i++) { if (enemies[i].State != null && !enemies[i].State.IsDefeated) { return enemies[i].State; } } return null; } private void RemoveDefeatedEnemies() { for (int i = enemies.Count - 1; i >= 0; i--) { if (enemies[i].State.IsDefeated) { Log($"{enemies[i].Name} exited"); runManager?.AddBits(CombatTuning.BitsPerKill); enemies.RemoveAt(i); } } if (selectedEnemyIndex >= enemies.Count) { selectedEnemyIndex = -1; } } private void SpawnStructuralEnemies() { int wave = CurrentWaveNumber; int count = Mathf.Clamp(CombatTuning.BaseEnemiesPerWave + ((wave - 1) * CombatTuning.AdditionalEnemiesPerWave), CombatTuning.BaseEnemiesPerWave, CombatTuning.MaxEnemiesPerWave); int waveUptimeBonus = Mathf.Max(0, wave - 1) * CombatTuning.EnemyUptimeGrowthPerWave; for (int i = 0; i < count; i++) { // TODO: Replace this low-HP additive wave stub with the real encounter/difficulty system. int baseUptime = RandomRoll.RollRange(CombatTuning.BaseEnemyUptimeMin, CombatTuning.BaseEnemyUptimeMax, RollContext.None); int uptime = baseUptime + waveUptimeBonus; enemies.Add(new EnemyInstance("zombie_process", uptime, CreateZombieProcessIntentPool(wave, i))); } } private static IReadOnlyList CreateZombieProcessIntentPool(int wave, int slotIndex) { // TODO: Replace this code-shaped pool with EnemyDefinition-provided intent data. int attack = GetZombieProcessAttack(wave, slotIndex); return new[] { new EnemyIntent(EnemyIntentKind.Attack, attack, attack, Language.C, "attack", ">"), new EnemyIntent(EnemyIntentKind.Defend, 3 + wave, 3 + wave, Language.C, "defend", "#"), new EnemyIntent(EnemyIntentKind.StatusAttack, CombatTuning.EnemyStatusAttackDamage, CombatTuning.EnemyStatusAttackDamage, Language.C, "leak", "!", false, StatusType.MemoryLeak, 1, CombatTuning.EnemyStatusDuration) }; } private static int GetZombieProcessAttack(int wave, int slotIndex) { int safeWave = Mathf.Max(1, wave); int waveGrowth = (safeWave - 1) / Mathf.Max(1, CombatTuning.EnemyAttackGrowthEveryWaves); int slotVariance = CombatTuning.EnemySlotAttackVariance <= 0 ? 0 : slotIndex % (CombatTuning.EnemySlotAttackVariance + 1); return CombatTuning.BaseEnemyAttack + waveGrowth + slotVariance; } private void PickEnemyIntents() { for (int i = 0; i < enemies.Count; i++) { if (!enemies[i].State.IsDefeated) { enemies[i].PickNextIntent(); } } } private void ExecuteEnemyIntent(EnemyInstance enemy) { EnemyIntent intent = enemy.CurrentIntent; int value = intent.MinValue == intent.MaxValue ? intent.MinValue : RandomRoll.RollRange(intent.MinValue, intent.MaxValue, new RollContext(enemy.State)); switch (intent.Kind) { case EnemyIntentKind.Attack: value = ApplyIncomingAttackMitigation(value); DamageResult attackResult = damagePipeline.DealDamage(new DamageRequest(enemy.State, playerState, value, intent.DamageType, intent.TrueDamage, false)); GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, attackResult.FinalAmount)); break; case EnemyIntentKind.Defend: enemy.State.Shield += value; break; case EnemyIntentKind.StatusAttack: DamageResult statusResult = damagePipeline.DealDamage(new DamageRequest(enemy.State, playerState, value, intent.DamageType, intent.TrueDamage, false)); GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, statusResult.FinalAmount)); if (intent.StatusStacks > 0) { statusEffects.Apply(playerState, intent.StatusType, intent.StatusStacks, intent.StatusDuration, enemy.State, skipNextTick: true); } break; case EnemyIntentKind.Buff: Log("TODO: buff intent needs enemy buff/status support."); break; case EnemyIntentKind.Special: Log("TODO: special intent needs enemy-specific behavior hooks."); break; } } private int ApplyIncomingAttackMitigation(int value) { if (playerState == null || playerState.IncomingAttackHalfCharges <= 0) { return value; } playerState.IncomingAttackHalfCharges--; int mitigated = Mathf.CeilToInt(Mathf.Max(0, value) * 0.5f); Log($"SELinux enforcing halved attack {value}->{mitigated}"); return mitigated; } private void ResolveDelayedEndOfTurnEffects() { for (int i = delayedEffects.Count - 1; i >= 0; i--) { DelayedCombatEffect effect = delayedEffects[i]; effect.GarbageCollectionsRemaining--; if (effect.GarbageCollectionsRemaining > 0) { delayedEffects[i] = effect; continue; } delayedEffects.RemoveAt(i); switch (effect.Kind) { case DelayedCombatEffectKind.UpdateManagerRepeat: DealToAllEnemies(effect.Amount, Language.Python, "update manager repeat"); break; case DelayedCombatEffectKind.TimeshiftRestore: RestoreTimeshift(effect.Amount); break; } } } private void DealToAllEnemies(int amount, Language language, string label) { List targets = LivingEnemyStates(); for (int i = 0; i < targets.Count; i++) { damagePipeline.DealDamage(new DamageRequest(playerState, targets[i], amount, language, false, true)); } Log($"{label}: dealt {amount} to all enemies"); RemoveDefeatedEnemies(); } private void RestoreTimeshift(int snapshot) { if (playerState == null || playerState.IsDefeated || playerState.CurrentUptime >= snapshot) { return; } int target = snapshot; if (IsDistro("mint") && runConfig.DistroVersion >= 3) { target = Mathf.Min(playerState.MaxUptime, snapshot + 2); } if (IsDistro("mint") && runConfig.DistroVersion >= 5) { target = playerState.MaxUptime; } int restored = target - playerState.CurrentUptime; playerState.CurrentUptime = target; Log($"timeshift restored {restored} uptime"); } private bool CheckWinOrLoss() { return CheckLoss() || CheckWin(); } private bool CheckLoss() { if (runLost || playerState == null || !playerState.IsDefeated) { return runLost; } runLost = true; awaitingWaveContinue = false; selectedEnemyIndex = -1; pendingTargetCard = null; GameEvents.RaiseEncounterLost(new EncounterLostEvent(CurrentWaveNumber)); GameEvents.RaiseRunEnded(new RunEndedEvent(playerDied: true, entropyEarned: 0)); Log("kernel panic: player uptime reached 0"); StateChanged?.Invoke(); return true; } private bool CheckWin() { if (awaitingWaveContinue || runLost || enemies.Count > 0) { return awaitingWaveContinue; } awaitingWaveContinue = true; selectedEnemyIndex = -1; pendingTargetCard = null; int clearedWave = CurrentWaveNumber; GameEvents.RaiseEncounterWon(new EncounterWonEvent(clearedWave)); GameEvents.RaiseWaveCleared(new WaveClearedEvent(clearedWave)); Log("wave cleared -> repository available"); StateChanged?.Invoke(); return true; } private void LogPlayIntent(CardInstance card) { string notes = card?.Definition == null || string.IsNullOrWhiteSpace(card.Definition.DesignNotes) ? "no-op scaffold" : card.Definition.DesignNotes; Log($"{GetCardName(card)} would resolve: {notes}"); } private void Log(string message) { CombatLog?.Invoke(message); } private static string GetCardName(CardInstance card) { if (card?.Definition == null) { return "--"; } return string.IsNullOrWhiteSpace(card.Definition.DisplayName) ? card.Definition.Id : card.Definition.DisplayName; } private bool IsCombatPaused => awaitingWaveContinue || runLost; private enum DelayedCombatEffectKind { UpdateManagerRepeat, TimeshiftRestore } private struct DelayedCombatEffect { private DelayedCombatEffect(DelayedCombatEffectKind kind, int amount, int garbageCollectionsRemaining) { Kind = kind; Amount = amount; GarbageCollectionsRemaining = garbageCollectionsRemaining; } public DelayedCombatEffectKind Kind { get; } public int Amount { get; } public int GarbageCollectionsRemaining { get; set; } public static DelayedCombatEffect UpdateManagerRepeat(int amount, int garbageCollectionsRemaining) { return new DelayedCombatEffect(DelayedCombatEffectKind.UpdateManagerRepeat, amount, garbageCollectionsRemaining); } public static DelayedCombatEffect TimeshiftRestore(int uptime, int garbageCollectionsRemaining) { return new DelayedCombatEffect(DelayedCombatEffectKind.TimeshiftRestore, uptime, garbageCollectionsRemaining); } } } }