diff --git a/Assets/Data/Cards/Shop/AsyncDef.asset b/Assets/Data/Cards/Shop/AsyncDef.asset index fd4c3a8..71fd4bd 100644 --- a/Assets/Data/Cards/Shop/AsyncDef.asset +++ b/Assets/Data/Cards/Shop/AsyncDef.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: shop_py_async_def displayName: async def - description: 'queue: draw 2. TODO: gain 1 Cycle next turn.' + description: 'queue: draw 2. gain 1 Cycle next turn.' flavorText: returns eventually. emotionally, at least. language: 3 rarity: 1 @@ -23,7 +23,7 @@ MonoBehaviour: distroExclusive: 0 isToken: 0 isRunOnly: 1 - designNotes: Run-only Repository card. Draw 2 works now; next-turn cycle grant waits for deferred-cycle-grant. + designNotes: Run-only Repository card. Draw 2 and grants +1 Cycle on the next turn. requiresSystem: - deferred-cycle-grant upgradeTarget: {fileID: 0} diff --git a/Assets/Data/Cards/Shop/ListAppend.asset b/Assets/Data/Cards/Shop/ListAppend.asset index 756a515..f8ee7c7 100644 --- a/Assets/Data/Cards/Shop/ListAppend.asset +++ b/Assets/Data/Cards/Shop/ListAppend.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: shop_py_list_append displayName: list.append() - description: 'queue: deal 4. TODO: grows when re-queued.' + description: 'queue: deal 4. gains +1 damage each time it is re-queued.' flavorText: mutable state, but make it cute. language: 3 rarity: 1 @@ -23,7 +23,7 @@ MonoBehaviour: distroExclusive: 0 isToken: 0 isRunOnly: 1 - designNotes: Run-only Repository card. Base deal 4 works now; permanent per-combat requeue growth waits for requeue-tracking. + designNotes: Run-only Repository card. Base deal 4 with per-combat requeue growth. requiresSystem: - requeue-tracking upgradeTarget: {fileID: 0} diff --git a/Assets/Data/Cards/Shop/Spread.asset b/Assets/Data/Cards/Shop/Spread.asset index 8d272f2..4ef3baf 100644 --- a/Assets/Data/Cards/Shop/Spread.asset +++ b/Assets/Data/Cards/Shop/Spread.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: shop_js_spread displayName: spread ... - description: 'TODO: copy the lowest-cost card in hand.' + description: 'copy the lowest-cost card in hand.' flavorText: cloning by punctuation, naturally. language: 4 rarity: 1 @@ -23,7 +23,7 @@ MonoBehaviour: distroExclusive: 0 isToken: 0 isRunOnly: 1 - designNotes: Run-only Repository card. Requires card-copy. + designNotes: Run-only Repository card. Copies the lowest-cost card currently in hand. requiresSystem: - card-copy upgradeTarget: {fileID: 0} diff --git a/Assets/Data/Cards/Shop/Zip.asset b/Assets/Data/Cards/Shop/Zip.asset index 6f1075f..6f31d95 100644 --- a/Assets/Data/Cards/Shop/Zip.asset +++ b/Assets/Data/Cards/Shop/Zip.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: shop_py_zip displayName: zip() - description: 'TODO: next 2 queued cards resolve twice.' + description: 'next 2 queued cards resolve twice.' flavorText: pairs nicely until lengths disagree. language: 3 rarity: 1 @@ -23,7 +23,7 @@ MonoBehaviour: distroExclusive: 0 isToken: 0 isRunOnly: 1 - designNotes: Run-only Repository card. Requires queue-effect-multiply. + designNotes: Run-only Repository card. Adds two charges that make queued cards resolve twice. requiresSystem: - queue-effect-multiply upgradeTarget: {fileID: 0} diff --git a/Assets/Scripts/Combat/CardEffects.cs b/Assets/Scripts/Combat/CardEffects.cs index 8679831..c2d2de7 100644 --- a/Assets/Scripts/Combat/CardEffects.cs +++ b/Assets/Scripts/Combat/CardEffects.cs @@ -83,6 +83,26 @@ namespace KernelPanic.Combat } } + public sealed class FirstCardShieldEffect : ICardEffect + { + private readonly int _baseAmount; + private readonly int _firstCardBonus; + + public FirstCardShieldEffect(int baseAmount, int firstCardBonus) + { + _baseAmount = baseAmount; + _firstCardBonus = firstCardBonus; + } + + public void Execute(CombatContext context) + { + int amount = _baseAmount + (context.Card != null && context.Card.WasFirstCardThisTurn ? _firstCardBonus : 0); + amount = UpgradeMath.ScaleShield(UpgradeMath.ApplySourceFlatBonus(amount, context), context.Card); + context.Source.Shield += amount; + context.CombatManager.ReportEffectResult($"gained {amount} shield"); + } + } + public sealed class ChanceDamageEffect : ICardEffect { private readonly int _damageAmount; @@ -115,6 +135,26 @@ namespace KernelPanic.Combat } } + public sealed class QueuedGrowthDamageEffect : ICardEffect + { + private readonly int _baseAmount; + private readonly Language _language; + + public QueuedGrowthDamageEffect(int baseAmount, Language language) + { + _baseAmount = baseAmount; + _language = language; + } + + public void Execute(CombatContext context) + { + int queueGrowth = UnityEngine.Mathf.Max(0, (context.Card?.QueuePlayCount ?? 1) - 1); + int amount = _baseAmount + queueGrowth; + new DealDamageEffect(amount, amount, _language).Execute(context); + } + } + + public sealed class ApplyStatusEffect : ICardEffect { private readonly StatusType _statusType; @@ -209,6 +249,81 @@ namespace KernelPanic.Combat } } + public sealed class NextTurnCycleEffect : ICardEffect + { + private readonly int _amount; + + public NextTurnCycleEffect(int amount) + { + _amount = amount; + } + + public void Execute(CombatContext context) + { + int amount = UpgradeMath.ScaleAmount(_amount, context.Card); + context.CombatManager.AddNextTurnCycleBonus(amount); + context.CombatManager.ReportEffectResult($"next turn +{amount} cycle"); + } + } + + public sealed class QueueRepeatEffect : ICardEffect + { + private readonly int _count; + + public QueueRepeatEffect(int count) + { + _count = count; + } + + public void Execute(CombatContext context) + { + context.CombatManager.AddQueuedRepeatCharges(UpgradeMath.ScaleAmount(_count, context.Card)); + } + } + + public sealed class CopyLowestCostHandCardEffect : ICardEffect + { + public void Execute(CombatContext context) + { + if (context.HandController.Cards.Count >= context.HandController.RamCapacity) + { + context.CombatManager.ReportEffectResult("hand full: copied 0"); + return; + } + + CardInstance selected = null; + int selectedCost = int.MaxValue; + for (int i = 0; i < context.HandController.Cards.Count; i++) + { + CardInstance candidate = context.HandController.Cards[i]; + if (candidate == null || candidate.Definition == null) + { + continue; + } + + int cost = context.CombatManager.GetEffectiveCardCost(candidate); + if (cost < selectedCost) + { + selected = candidate; + selectedCost = cost; + } + } + + if (selected == null) + { + context.CombatManager.ReportEffectResult("no card to copy"); + return; + } + + CardInstance copy = selected.CopyForCombat(); + if (context.HandController.Add(copy)) + { + string name = string.IsNullOrWhiteSpace(copy.Definition.DisplayName) ? copy.Definition.Id : copy.Definition.DisplayName; + context.CombatManager.ReportEffectResult($"copied {name}"); + } + } + } + public sealed class UpgradeHandCardsEffect : ICardEffect { private readonly int _minCount; @@ -247,6 +362,34 @@ namespace KernelPanic.Combat } } + public sealed class ShuffleTokenIntoDrawPileEffect : ICardEffect + { + private readonly string _tokenId; + + public ShuffleTokenIntoDrawPileEffect(string tokenId) + { + _tokenId = tokenId; + } + + public void Execute(CombatContext context) + { + if (context.CombatManager.TryCreateGeneratedCardById(_tokenId, out CardInstance token)) + { + context.DeckController.AddToDrawPile(token, shuffle: true); + context.CombatManager.ReportEffectResult($"shuffled {GetCardName(token)} into draw pile"); + } + else + { + context.CombatManager.ReportEffectResult($"token {_tokenId} unavailable"); + } + } + + private static string GetCardName(CardInstance card) + { + return string.IsNullOrWhiteSpace(card?.Definition?.DisplayName) ? card?.Definition?.Id ?? "--" : card.Definition.DisplayName; + } + } + public sealed class GenerateCardEffect : ICardEffect { private readonly Language _language; @@ -293,18 +436,123 @@ namespace KernelPanic.Combat } } - public sealed class NoOpCardEffect : ICardEffect + public sealed class OverkillToShieldDamageEffect : ICardEffect { - private readonly string _todo; + private readonly int _amount; + private readonly Language _language; - public NoOpCardEffect(string todo) + public OverkillToShieldDamageEffect(int amount, Language language) { - _todo = todo; + _amount = amount; + _language = language; } public void Execute(CombatContext context) { - context.CombatManager.LogEffectTodo(context.Card, _todo); + for (int i = 0; i < context.Targets.Count; i++) + { + CombatantState target = context.Targets[i]; + if (target == null || target.IsDefeated) + { + continue; + } + + int amount = UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_amount, context), context.Card); + int survivable = target.CurrentUptime + target.Shield; + context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, false)); + int overkill = UnityEngine.Mathf.Max(0, amount - survivable); + if (overkill > 0) + { + context.Source.Shield += overkill; + context.CombatManager.ReportEffectResult($"overkill -> {overkill} shield"); + } + } + } + } + + public sealed class ConditionalShieldRepeatDamageEffect : ICardEffect + { + private readonly int _amount; + private readonly Language _language; + + public ConditionalShieldRepeatDamageEffect(int amount, Language language) + { + _amount = amount; + _language = language; + } + + public void Execute(CombatContext context) + { + int repeats = context.Source.Shield > 0 ? 2 : 1; + for (int repeat = 0; repeat < repeats; repeat++) + { + new DealDamageEffect(_amount, _amount, _language).Execute(context); + } + } + } + + public sealed class UpdateManagerEffect : ICardEffect + { + private readonly int _amount; + + public UpdateManagerEffect(int amount) + { + _amount = amount; + } + + public void Execute(CombatContext context) + { + int amount = UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_amount, context), context.Card); + new DealDamageEffect(amount, amount, Language.Python, allEnemies: true).Execute(context); + context.CombatManager.ScheduleUpdateManagerRepeat(amount); + } + } + + public sealed class TimeshiftSnapshotEffect : ICardEffect + { + public void Execute(CombatContext context) + { + context.CombatManager.ScheduleTimeshiftRestore(context.Source.CurrentUptime); + } + } + + public sealed class RawhideEffect : ICardEffect + { + public void Execute(CombatContext context) + { + new DrawEffect(2).Execute(context); + context.CombatManager.GrantRawhideBonus(1); + context.CombatManager.ReportEffectResult("rawhide armed next card"); + } + } + + public sealed class IncomingAttackHalveEffect : ICardEffect + { + private readonly int _charges; + + public IncomingAttackHalveEffect(int charges) + { + _charges = charges; + } + + public void Execute(CombatContext context) + { + context.CombatManager.AddIncomingAttackHalfCharges(UpgradeMath.ScaleAmount(_charges, context.Card)); + } + } + + public sealed class NoOpCardEffect : ICardEffect + { + private readonly string _message; + + public NoOpCardEffect(string message) + { + _message = message; + } + + public void Execute(CombatContext context) + { + context.CombatManager.LogEffectTodo(context.Card, _message); } } @@ -337,9 +585,13 @@ namespace KernelPanic.Combat new CleanseEffect(harmfulOnly: true) }, "lang_js_typeof" => One(new ChanceDamageEffect(8, 75, Language.JavaScript)), - "shop_py_list_append" => One(new DealDamageEffect(4, 4, Language.Python)), - "shop_py_async_def" => One(new DrawEffect(2)), - "shop_py_zip" => Todo("TODO: zip() needs queue-effect-multiply support."), + "shop_py_list_append" => One(new QueuedGrowthDamageEffect(4, Language.Python)), + "shop_py_async_def" => new ICardEffect[] + { + new DrawEffect(2), + new NextTurnCycleEffect(1) + }, + "shop_py_zip" => One(new QueueRepeatEffect(2)), "shop_js_math_random" => One(new DealDamageEffect(2, 20, Language.JavaScript)), "shop_js_promise_all" => new ICardEffect[] { @@ -347,28 +599,37 @@ namespace KernelPanic.Combat new DealDamageEffect(4, 10, Language.JavaScript), new DealDamageEffect(4, 10, Language.JavaScript) }, - "shop_js_spread" => Todo("TODO: spread ... needs card-copy support."), - "ubuntu_snap_install" => Todo("TODO: snap install needs token/junk insertion system."), + "shop_js_spread" => One(new CopyLowestCostHandCardEffect()), + "ubuntu_snap_install" => new ICardEffect[] + { + new DealDamageEffect(6, 14, Language.JavaScript), + new ShuffleTokenIntoDrawPileEffect("ubuntu_pro_trial") + }, "ubuntu_do_release_upgrade" => One(new UpgradeHandCardsEffect(1, 3)), "ubuntu_apt_install" => One(new GenerateCardEffect(Language.Python, Rarity.Common)), - "ubuntu_pro_trial" => Todo("TODO: ubuntu pro trial needs unplayable token handling."), - "mint_update_manager" => Todo("TODO: update manager needs delayed repeat system."), - "mint_cinnamon" => Todo("TODO: cinnamon needs shield effect support."), - "mint_nemo" => Todo("TODO: nemo needs conditional repeat based on shield."), - "mint_timeshift" => Todo("TODO: timeshift snapshot V3 needs delayed restore system before threshold/restore can be upgraded."), - "fedora_borrow_checker" => Todo("TODO: borrow checker needs shield and first-card tracking."), - "fedora_cargo_build" => Todo("TODO: cargo build --release needs overkill-to-shield handling."), - "fedora_dnf_update" => Todo("TODO: dnf update needs Java played count and cost mutation."), - "fedora_rawhide" => Todo("TODO: rawhide V3 needs permanent damage-growth doubling after growth effects exist."), - "fedora_selinux" => Todo("TODO: SELinux enforcing needs enemy attack mitigation statuses."), - _ => Todo("TODO: card effect is not implemented yet.") + "ubuntu_pro_trial" => One(new NoOpCardEffect("ubuntu pro trial is unplayable")), + "mint_update_manager" => One(new UpdateManagerEffect(5)), + "mint_cinnamon" => new ICardEffect[] + { + new ShieldEffect(3), + new DrawEffect(1) + }, + "mint_nemo" => One(new ConditionalShieldRepeatDamageEffect(6, Language.JavaScript)), + "mint_timeshift" => One(new TimeshiftSnapshotEffect()), + "fedora_borrow_checker" => One(new FirstCardShieldEffect(8, 8)), + "fedora_cargo_build" => One(new OverkillToShieldDamageEffect(10, Language.Rust)), + "fedora_dnf_update" => One(new DealDamageEffect(9, 9, Language.Java)), + "fedora_rawhide" => One(new RawhideEffect()), + "fedora_selinux" => One(new IncomingAttackHalveEffect(2)), + _ => Todo("card effect is not authored") }; } public static bool RequiresSingleTarget(CardDefinition definition) { return definition?.Id is "lang_js_console_log" or "lang_js_fetch" or "lang_js_typeof" or "mint_fix_broken" - or "shop_py_list_append" or "shop_js_math_random" or "shop_js_promise_all"; + or "shop_py_list_append" or "shop_js_math_random" or "shop_js_promise_all" or "ubuntu_snap_install" + or "mint_nemo" or "fedora_cargo_build" or "fedora_dnf_update"; } public static bool TargetsAllEnemies(CardDefinition definition) @@ -423,18 +684,28 @@ namespace KernelPanic.Combat "lang_js_console_log" => $"deal {UpgradeMath.ScaleAmount(3, card)}-{UpgradeMath.ScaleAmount(9, card)}.{marker}", "lang_js_fetch" => $"deal {UpgradeMath.ScaleAmount(5, card)}-{UpgradeMath.ScaleAmount(13, card)}.{marker}", "lang_js_typeof" => $"75%: deal {UpgradeMath.ScaleAmount(8, card)}. 25%: deal 0.{marker}", - "shop_py_list_append" => $"queue: deal {UpgradeMath.ScaleAmount(4, card)}. TODO: grows when re-queued.{marker}", - "shop_py_async_def" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}. TODO: gain 1 cycle next turn.{marker}", - "shop_py_zip" => $"TODO: next 2 queued cards resolve twice.{marker}", + "shop_py_list_append" => $"queue: deal {UpgradeMath.ScaleAmount(4 + UnityEngine.Mathf.Max(0, card.QueuePlayCount - 1), card)}; +1 each time re-queued.{marker}", + "shop_py_async_def" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}. gain {UpgradeMath.ScaleAmount(1, card)} cycle next turn.{marker}", + "shop_py_zip" => $"next {UpgradeMath.ScaleAmount(2, card)} queued cards resolve twice.{marker}", "shop_js_math_random" => $"deal {UpgradeMath.ScaleAmount(2, card)}-{UpgradeMath.ScaleAmount(20, card)}.{marker}", "shop_js_promise_all" => $"deal {UpgradeMath.ScaleAmount(4, card)}-{UpgradeMath.ScaleAmount(10, card)} three times.{marker}", - "shop_js_spread" => $"TODO: copy the lowest-cost card in hand.{marker}", + "shop_js_spread" => $"copy the lowest-cost card in hand.{marker}", "ubuntu_ask_ubuntu" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}.{marker}", "ubuntu_unattended_upgrades" => $"queue: gain {UpgradeMath.ScaleShield(4, card)} shield now and next 2 turns.{marker}", + "ubuntu_snap_install" => $"deal {UpgradeMath.ScaleAmount(6, card)}-{UpgradeMath.ScaleAmount(14, card)}. shuffle an ubuntu pro trial into your draw pile.{marker}", "ubuntu_do_release_upgrade" => $"upgrade {UpgradeMath.ScaleAmount(1, card)}-{UpgradeMath.ScaleAmount(3, card)} random hand cards.{marker}", "ubuntu_apt_install" => $"queue: generate a random common Python card at 0c.{marker}", "mint_fix_broken" => $"deal {UpgradeMath.ScaleAmount(8, card)}. cleanse harmful statuses.{marker}", - _ => string.IsNullOrWhiteSpace(card.Definition.Description) ? "TODO: effect not implemented." : $"{card.Definition.Description}{marker}" + "mint_update_manager" => $"queue: deal {UpgradeMath.ScaleAmount(5, card)} to all enemies. repeats at the end of your next turn.{marker}", + "mint_cinnamon" => $"queue: gain {UpgradeMath.ScaleShield(3, card)} shield. draw {UpgradeMath.ScaleAmount(1, card)}.{marker}", + "mint_nemo" => $"deal {UpgradeMath.ScaleAmount(6, card)}. if you have shield, deal it again.{marker}", + "mint_timeshift" => $"queue: record uptime now. at end of your next turn, restore to it if lower.{marker}", + "fedora_borrow_checker" => $"gain {UpgradeMath.ScaleShield(8, card)} shield. if first card this turn, gain {UpgradeMath.ScaleShield(8, card)} more.{marker}", + "fedora_cargo_build" => $"deal {UpgradeMath.ScaleAmount(10, card)}. overkill becomes shield.{marker}", + "fedora_dnf_update" => $"deal {UpgradeMath.ScaleAmount(9, card)}. costs 1 less per Java card played this combat.{marker}", + "fedora_rawhide" => $"draw {UpgradeMath.ScaleAmount(2, card)}. next card this turn gains bleeding edge.{marker}", + "fedora_selinux" => $"next {UpgradeMath.ScaleAmount(2, card)} enemy attacks deal half damage.{marker}", + _ => string.IsNullOrWhiteSpace(card.Definition.Description) ? "effect not authored." : $"{card.Definition.Description}{marker}" }; } diff --git a/Assets/Scripts/Combat/CardInstance.cs b/Assets/Scripts/Combat/CardInstance.cs index eab7a7f..1235dcc 100644 --- a/Assets/Scripts/Combat/CardInstance.cs +++ b/Assets/Scripts/Combat/CardInstance.cs @@ -23,6 +23,8 @@ namespace KernelPanic.Combat public int MagnitudeBonus { get; private set; } public int DrawRider { get; private set; } public int UpgradeLevel { get; private set; } + public int QueuePlayCount { get; private set; } + public bool WasFirstCardThisTurn { get; private set; } public bool IsBroken { get; set; } public bool IsLocked { get; set; } public bool CanUpgrade => UpgradeLevel == 0; @@ -67,6 +69,17 @@ namespace KernelPanic.Combat return true; } + public bool ApplyCombatMagnitudeBonus(int amount) + { + if (amount <= 0) + { + return false; + } + + MagnitudeBonus += amount; + return true; + } + public bool ApplyDrawRider(int amount) { if (amount <= 0) @@ -79,6 +92,35 @@ namespace KernelPanic.Combat return true; } + public void MarkQueued() + { + QueuePlayCount++; + } + + public void MarkPlayedThisTurn(bool wasFirst) + { + WasFirstCardThisTurn = wasFirst; + } + + public CardInstance CopyForCombat() + { + CardInstance copy = new(Definition) + { + TemporaryCostDelta = TemporaryCostDelta, + IsBroken = IsBroken, + IsLocked = IsLocked + }; + + copy.PermanentCostDelta = PermanentCostDelta; + copy.MagnitudeBonus = MagnitudeBonus; + copy.DrawRider = DrawRider; + copy.UpgradeLevel = UpgradeLevel; + copy.QueuePlayCount = QueuePlayCount; + copy.WasFirstCardThisTurn = WasFirstCardThisTurn; + copy.SetTargetSnapshot(TargetSnapshot); + return copy; + } + public void SetTargetSnapshot(IEnumerable targets) { _targetSnapshot.Clear(); @@ -98,6 +140,8 @@ namespace KernelPanic.Combat public void ResetCombatState() { TemporaryCostDelta = 0; + QueuePlayCount = 0; + WasFirstCardThisTurn = false; IsBroken = false; IsLocked = false; ClearTargetSnapshot(); diff --git a/Assets/Scripts/Combat/CombatManager.cs b/Assets/Scripts/Combat/CombatManager.cs index b4027a9..f816c54 100644 --- a/Assets/Scripts/Combat/CombatManager.cs +++ b/Assets/Scripts/Combat/CombatManager.cs @@ -25,6 +25,7 @@ namespace KernelPanic.Combat 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; @@ -37,6 +38,11 @@ namespace KernelPanic.Combat private bool ubuntuEmptyHandRefillUsed; private int fedoraCardsDiscountedThisTurn; private int fedoraCrashChance = 10; + private int cardsPlayedThisTurn; + private int javaCardsPlayedThisCombat; + private int rawhideBonusCharges; + private int queuedRepeatCharges; + private int nextTurnCycleBonus; public TurnPhase CurrentPhase => currentPhase; public RunConfig RunConfig => runConfig; @@ -145,8 +151,14 @@ namespace KernelPanic.Combat return false; } + if (card.Definition.IsToken) + { + Log($"{GetCardName(card)} is unplayable"); + return false; + } + bool fedoraBonus = CanApplyFedoraBonus(); - int cost = GetCardCost(card); + int cost = GetEffectiveCardCost(card); if (fedoraBonus) { cost = Mathf.Max(0, cost - 1); @@ -173,6 +185,13 @@ namespace KernelPanic.Combat playerState.Cycles -= cost; ResolutionTrack track = card.Definition.ResolutionTrack; + card.MarkPlayedThisTurn(cardsPlayedThisTurn == 0); + cardsPlayedThisTurn++; + if (card.Definition.Language == Language.Java) + { + javaCardsPlayedThisCombat++; + } + GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, track)); LogPlayIntent(card); card.SetTargetSnapshot(CaptureTargets(card)); @@ -180,7 +199,13 @@ namespace KernelPanic.Combat if (fedoraBonus) { fedoraCardsDiscountedThisTurn++; - if (RandomRoll.RollRange(1, 100, new RollContext(playerState)) <= fedoraCrashChance) + 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) @@ -198,11 +223,13 @@ namespace KernelPanic.Combat fedoraCrashChance = Mathf.Min(90, fedoraCrashChance + 10); playerState.DamageMultiplierPercent = runConfig.DistroVersion >= 2 ? 175 : 150; + ApplyFedoraGrowth(card, rawhideChargeUsed); } switch (track) { case ResolutionTrack.InterpreterQueue: + card.MarkQueued(); interpreterQueue.Enqueue(card); break; case ResolutionTrack.LazyStack: @@ -235,6 +262,17 @@ namespace KernelPanic.Combat return Mathf.Max(0, card.Definition.CycleCost + card.PermanentCostDelta + card.TemporaryCostDelta); } + public int GetEffectiveCardCost(CardInstance card) + { + int cost = GetCardCost(card); + if (card?.Definition != null && string.Equals(card.Definition.Id, "fedora_dnf_update", StringComparison.OrdinalIgnoreCase)) + { + cost -= javaCardsPlayedThisCombat; + } + + return Mathf.Max(0, cost); + } + public bool RequiresTarget(CardInstance card) { return card?.Definition != null && CardEffectFactory.RequiresSingleTarget(card.Definition) && LivingEnemyStates().Count > 0; @@ -258,6 +296,86 @@ namespace KernelPanic.Combat } } + 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 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 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(); @@ -268,7 +386,7 @@ namespace KernelPanic.Combat foreach (CardDefinition card in cardPool) { - if (card != null && !card.IsToken && !card.IsRunOnly) + if (card != null) { generatedCardPool.Add(card); } @@ -394,6 +512,12 @@ namespace KernelPanic.Combat ubuntuEmptyHandRefillUsed = false; fedoraCrashChance = 10; fedoraCardsDiscountedThisTurn = 0; + cardsPlayedThisTurn = 0; + javaCardsPlayedThisCombat = 0; + rawhideBonusCharges = 0; + queuedRepeatCharges = 0; + nextTurnCycleBonus = 0; + delayedEffects.Clear(); RandomRoll.Seed(runConfig.RunSeed); playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles()); ApplyVersionState(playerState); @@ -418,6 +542,7 @@ namespace KernelPanic.Combat playerState.MaxCycles = runManager.EffectiveMaxCycles(); playerState.Cycles = 0; playerState.Shield = 0; + playerState.IncomingAttackHalfCharges = 0; playerState.IsDefeated = false; playerState.MutableStatuses.Clear(); ApplyVersionState(playerState); @@ -456,6 +581,13 @@ namespace KernelPanic.Combat playerState.Cycles = playerState.MaxCycles; fedoraCardsDiscountedThisTurn = 0; + cardsPlayedThisTurn = 0; + if (nextTurnCycleBonus > 0) + { + playerState.Cycles += nextTurnCycleBonus; + Log($"deferred cycle grant: +{nextTurnCycleBonus}"); + nextTurnCycleBonus = 0; + } statusEffects.Tick(playerState, StatusTickTiming.StartOfTurn, playerState, damagePipeline); if (CheckLoss()) { @@ -481,7 +613,16 @@ namespace KernelPanic.Combat while (interpreterQueue.TryDequeue(out CardInstance card)) { LogPlayIntent(card); - ExecuteCardEffects(card, ResolveQueuedTargets(card)); + int resolutionCount = ConsumeQueuedResolutionCount(card); + for (int repeatIndex = 0; repeatIndex < resolutionCount; repeatIndex++) + { + ExecuteCardEffects(card, ResolveQueuedTargets(card)); + if (CheckWinOrLoss()) + { + return; + } + } + ResolveCardRiders(card); deckController.Discard(card); GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue)); @@ -541,6 +682,12 @@ namespace KernelPanic.Combat return; } + ResolveDelayedEndOfTurnEffects(); + if (CheckWinOrLoss()) + { + return; + } + RemoveDefeatedEnemies(); CheckWinOrLoss(); StateChanged?.Invoke(); @@ -638,6 +785,11 @@ namespace KernelPanic.Combat } private bool CanApplyFedoraBonus() + { + return CanApplyFedoraPassiveBonus() || rawhideBonusCharges > 0; + } + + private bool CanApplyFedoraPassiveBonus() { if (!IsDistro("fedora") || playerState == null) { @@ -648,6 +800,27 @@ namespace KernelPanic.Combat 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 static bool IsCrashImmune(CardInstance card) + { + string id = card?.Definition?.Id; + return string.Equals(id, "fedora_cargo_build", StringComparison.OrdinalIgnoreCase) + || string.Equals(id, "fedora_selinux", StringComparison.OrdinalIgnoreCase); + } + private void ApplyVersionState(CombatantState state) { if (state == null) @@ -856,6 +1029,7 @@ namespace KernelPanic.Combat 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; @@ -879,6 +1053,79 @@ namespace KernelPanic.Combat } } + 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, false)); + } + + 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(); @@ -944,5 +1191,35 @@ namespace KernelPanic.Combat } 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); + } + } } } diff --git a/Assets/Scripts/Combat/CombatantState.cs b/Assets/Scripts/Combat/CombatantState.cs index c698a88..192a9ac 100644 --- a/Assets/Scripts/Combat/CombatantState.cs +++ b/Assets/Scripts/Combat/CombatantState.cs @@ -31,6 +31,7 @@ namespace KernelPanic.Combat public bool AllowFlatDamageBuffs { get; set; } public int FlatEffectBonus { get; set; } public int DamageMultiplierPercent { get; set; } = 100; + public int IncomingAttackHalfCharges { get; set; } public IReadOnlyList Statuses => _statuses; public IReadOnlyDictionary ActiveStatuses => BuildStatusSnapshot(); diff --git a/Assets/Scripts/Combat/DeckController.cs b/Assets/Scripts/Combat/DeckController.cs index 3079962..03d3554 100644 --- a/Assets/Scripts/Combat/DeckController.cs +++ b/Assets/Scripts/Combat/DeckController.cs @@ -100,6 +100,20 @@ namespace KernelPanic.Combat } } + public void AddToDrawPile(CardInstance card, bool shuffle) + { + if (card == null) + { + return; + } + + _drawPile.Add(card); + if (shuffle) + { + ShuffleDrawPile(); + } + } + public void Exhaust(CardInstance card) { if (card != null) diff --git a/Assets/Scripts/Meta/DistroVersionCatalog.cs b/Assets/Scripts/Meta/DistroVersionCatalog.cs index 1dc5827..cfec5c9 100644 --- a/Assets/Scripts/Meta/DistroVersionCatalog.cs +++ b/Assets/Scripts/Meta/DistroVersionCatalog.cs @@ -41,7 +41,7 @@ namespace KernelPanic.Meta { 1 => "first card each turn costs 1 less and deals +50%, with crash risk", 2 => "first-card damage bonus rises to +75%", - 3 => "rawhide TODO: double permanent damage growth on non-crash first-card turn", + 3 => "non-crashing first-card plays gain +1 effect this combat; rawhide doubles that growth", 4 => "crashes grant +1 Cycle that turn", 5 => "first two cards each turn get the discount, bonus, and independent crash rolls", _ => "--" @@ -50,12 +50,12 @@ namespace KernelPanic.Meta { 1 => "random values roll max; no crits; ignores multiplicative damage buffs", 2 => "fixed effect values are treated as +2", - 3 => "timeshift snapshot TODO: stronger restore when delayed restore exists", + 3 => "timeshift snapshot restores 2 uptime beyond the snapshot, up to max uptime", 4 => "flat +damage can apply; multiplicative crit/mult remains ignored", - 5 => "TODO: re-resolve reduced effect when enemy reduction/coercion exists", + 5 => "timeshift snapshot restores to full uptime if lower", _ => "--" }, - _ => safeVersion == 1 ? "base release" : $"TODO: V{safeVersion} release effect" + _ => safeVersion == 1 ? "base release" : $"V{safeVersion} release effect" }; } } diff --git a/Assets/Scripts/UI/CombatSceneController.cs b/Assets/Scripts/UI/CombatSceneController.cs index 36667ac..152e29d 100644 --- a/Assets/Scripts/UI/CombatSceneController.cs +++ b/Assets/Scripts/UI/CombatSceneController.cs @@ -120,6 +120,12 @@ namespace KernelPanic.UI private IReadOnlyList BuildGeneratedCardPool() { List cards = new(); + if (cardDatabase != null) + { + cards.AddRange(cardDatabase.AllCards); + return cards; + } + if (languageDeckDatabase == null) { return cards; @@ -795,7 +801,7 @@ namespace KernelPanic.UI button.AddToClassList("hand-card-selected"); } - if (CombatManager.GetCardCost(card) > combatManager.PlayerState.Cycles) + if (GetDisplayCardCost(card) > combatManager.PlayerState.Cycles) { button.AddToClassList("hand-card-unaffordable"); } @@ -804,7 +810,7 @@ namespace KernelPanic.UI return button; } - private static VisualElement CardFaceView(CardInstance card) + private VisualElement CardFaceView(CardInstance card) { VisualElement face = new(); face.AddToClassList("hand-card"); @@ -812,7 +818,7 @@ namespace KernelPanic.UI return face; } - private static void PopulateCardFace(VisualElement target, CardInstance card) + private void PopulateCardFace(VisualElement target, CardInstance card) { string cardRarityClass = CardRarityClass(card.Definition.Rarity); if (!string.IsNullOrEmpty(cardRarityClass)) @@ -822,7 +828,7 @@ namespace KernelPanic.UI VisualElement top = new(); top.AddToClassList("card-top-row"); - Label cost = new(CombatManager.GetCardCost(card).ToString()); + Label cost = new(GetDisplayCardCost(card).ToString()); cost.AddToClassList("card-cost"); Label name = new($"{DisplayName(card.Definition)}{(card.IsUpgraded ? " +" : string.Empty)}"); name.AddToClassList("card-name"); @@ -1337,7 +1343,7 @@ namespace KernelPanic.UI chip.AddToClassList(LanguageClass(card.Definition.Language)); Label name = new(DisplayName(card.Definition)); name.AddToClassList("card-chip-name"); - Label meta = new($"{CombatManager.GetCardCost(card)}c / {TrackText(card.Definition.ResolutionTrack)}"); + Label meta = new($"{GetDisplayCardCost(card)}c / {TrackText(card.Definition.ResolutionTrack)}"); meta.AddToClassList("card-chip-meta"); chip.Add(name); chip.Add(meta); @@ -1351,6 +1357,11 @@ namespace KernelPanic.UI return label; } + private int GetDisplayCardCost(CardInstance card) + { + return combatManager == null ? CombatManager.GetCardCost(card) : combatManager.GetEffectiveCardCost(card); + } + private static VisualElement CreatePanel(string title, string subtitle, float width) { VisualElement panel = new();