Implement too many changes to list

This commit is contained in:
2026-07-07 11:46:31 +02:00
parent 8f66e83b65
commit 28486bff8c
50 changed files with 6344 additions and 1131 deletions
+447
View File
@@ -0,0 +1,447 @@
using System.Collections.Generic;
using System.Linq;
using KernelPanic.Core;
using KernelPanic.Data;
namespace KernelPanic.Combat
{
public sealed class DealDamageEffect : ICardEffect
{
private readonly int _minAmount;
private readonly int _maxAmount;
private readonly Language _language;
private readonly bool _trueDamage;
private readonly bool _canCrit;
private readonly bool _allEnemies;
public DealDamageEffect(int minAmount, int maxAmount, Language language, bool trueDamage = false, bool canCrit = false, bool allEnemies = false)
{
_minAmount = minAmount;
_maxAmount = maxAmount;
_language = language;
_trueDamage = trueDamage;
_canCrit = canCrit;
_allEnemies = allEnemies;
}
public void Execute(CombatContext context)
{
IReadOnlyList<CombatantState> targets = _allEnemies
? context.Enemies.Where(enemy => enemy.State != null && !enemy.State.IsDefeated).Select(enemy => enemy.State).ToList()
: context.Targets;
if (StatusEffectController.Has(context.Source, StatusType.RaceCondition) && targets.Count > 1)
{
targets = ShuffleTargets(targets, context.Source);
}
for (int i = 0; i < targets.Count; i++)
{
CombatantState target = targets[i];
if (target == null || target.IsDefeated)
{
continue;
}
int minAmount = UpgradeMath.ScaleAmount(_minAmount, context.Card);
int maxAmount = UpgradeMath.ScaleAmount(_maxAmount, context.Card);
int amount = minAmount == maxAmount
? minAmount
: RandomRoll.RollRange(minAmount, maxAmount, new RollContext(context.Source));
context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, _trueDamage, _canCrit));
}
}
private static IReadOnlyList<CombatantState> ShuffleTargets(IReadOnlyList<CombatantState> targets, CombatantState source)
{
List<CombatantState> shuffled = targets.ToList();
for (int i = shuffled.Count - 1; i > 0; i--)
{
int swapIndex = RandomRoll.RollRange(0, i, new RollContext(source));
(shuffled[i], shuffled[swapIndex]) = (shuffled[swapIndex], shuffled[i]);
}
return shuffled;
}
}
public sealed class ShieldEffect : ICardEffect
{
private readonly int _amount;
public ShieldEffect(int amount)
{
_amount = amount;
}
public void Execute(CombatContext context)
{
int amount = UpgradeMath.ScaleShield(_amount, context.Card);
context.Source.Shield += amount;
context.CombatManager.ReportEffectResult($"gained {amount} shield");
}
}
public sealed class ChanceDamageEffect : ICardEffect
{
private readonly int _damageAmount;
private readonly int _successPercent;
private readonly Language _language;
public ChanceDamageEffect(int damageAmount, int successPercent, Language language)
{
_damageAmount = damageAmount;
_successPercent = successPercent;
_language = language;
}
public void Execute(CombatContext context)
{
for (int i = 0; i < context.Targets.Count; i++)
{
CombatantState target = context.Targets[i];
if (target == null || target.IsDefeated)
{
continue;
}
int roll = RandomRoll.RollRange(1, 100, new RollContext(context.Source));
int damageAmount = UpgradeMath.ScaleAmount(_damageAmount, context.Card);
int amount = roll <= _successPercent ? damageAmount : 0;
context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, false));
context.CombatManager.ReportEffectResult(amount == 0 ? "typeof -> NaN (0)" : $"typeof -> {amount}");
}
}
}
public sealed class ApplyStatusEffect : ICardEffect
{
private readonly StatusType _statusType;
private readonly int _stacks;
private readonly int _duration;
private readonly bool _targetSelf;
private readonly bool _skipNextTick;
public ApplyStatusEffect(StatusType statusType, int stacks, int duration, bool targetSelf, bool skipNextTick = false)
{
_statusType = statusType;
_stacks = stacks;
_duration = duration;
_targetSelf = targetSelf;
_skipNextTick = skipNextTick;
}
public void Execute(CombatContext context)
{
if (_targetSelf)
{
context.StatusEffects.Apply(context.Source, _statusType, UpgradeMath.ScaleAmount(_stacks, context.Card), _duration, context.Source, _skipNextTick);
return;
}
for (int i = 0; i < context.Targets.Count; i++)
{
context.StatusEffects.Apply(context.Targets[i], _statusType, UpgradeMath.ScaleAmount(_stacks, context.Card), _duration, context.Source, _skipNextTick);
}
}
}
public sealed class CleanseEffect : ICardEffect
{
private readonly bool _harmfulOnly;
public CleanseEffect(bool harmfulOnly)
{
_harmfulOnly = harmfulOnly;
}
public void Execute(CombatContext context)
{
if (_harmfulOnly)
{
context.StatusEffects.CleanseHarmful(context.Source);
}
}
}
public sealed class DrawEffect : ICardEffect
{
private readonly int _count;
public DrawEffect(int count)
{
_count = count;
}
public void Execute(CombatContext context)
{
int room = context.HandController.RamCapacity - context.HandController.Cards.Count;
if (room <= 0)
{
context.CombatManager.ReportEffectResult("hand full: drew 0");
return;
}
// RAM is a hard hand cap. Draw effects only pull cards that can fit; blocked cards stay in the draw pile.
int requested = UpgradeMath.ScaleAmount(_count, context.Card);
int drawCount = UnityEngine.Mathf.Min(requested, room);
IReadOnlyList<CardInstance> drawn = context.DeckController.Draw(drawCount);
int added = 0;
for (int i = 0; i < drawn.Count; i++)
{
if (context.HandController.Add(drawn[i]))
{
added++;
}
}
string capped = drawCount < requested ? " (hand full)" : string.Empty;
context.CombatManager.ReportEffectResult($"drew {added}{capped}");
}
}
public sealed class UpgradeHandCardsEffect : ICardEffect
{
private readonly int _minCount;
private readonly int _maxCount;
public UpgradeHandCardsEffect(int minCount, int maxCount)
{
_minCount = minCount;
_maxCount = maxCount;
}
public void Execute(CombatContext context)
{
int minCount = UpgradeMath.ScaleAmount(_minCount, context.Card);
int maxCount = UpgradeMath.ScaleAmount(_maxCount, context.Card);
int targetCount = minCount == maxCount
? minCount
: RandomRoll.RollRange(minCount, maxCount, new RollContext(context.Source));
List<CardInstance> candidates = context.HandController.Cards
.Where(card => card != null && card.CanUpgrade)
.ToList();
int upgraded = 0;
while (upgraded < targetCount && candidates.Count > 0)
{
int index = RandomRoll.RollRange(0, candidates.Count - 1, new RollContext(context.Source));
CardInstance card = candidates[index];
candidates.RemoveAt(index);
if (card.Upgrade())
{
upgraded++;
}
}
context.CombatManager.ReportEffectResult(upgraded == 0 ? "no cards upgraded" : $"upgraded {upgraded} card(s)");
}
}
public sealed class GenerateCardEffect : ICardEffect
{
private readonly Language _language;
private readonly Rarity _rarity;
public GenerateCardEffect(Language language, Rarity rarity)
{
_language = language;
_rarity = rarity;
}
public void Execute(CombatContext context)
{
if (context.HandController.Cards.Count >= context.HandController.RamCapacity)
{
context.CombatManager.ReportEffectResult("hand full: generated 0");
return;
}
if (!context.CombatManager.TryCreateGeneratedCard(_language, _rarity, out CardInstance generatedCard))
{
context.CombatManager.ReportEffectResult($"no {_rarity.ToString().ToLowerInvariant()} {_language} card pool");
return;
}
if (context.HandController.Add(generatedCard))
{
context.CombatManager.ReportEffectResult($"generated {GetCardName(generatedCard)} at 0c");
}
else
{
context.CombatManager.ReportEffectResult("hand full: generated 0");
}
}
private static string GetCardName(CardInstance card)
{
if (card?.Definition == null)
{
return "--";
}
return string.IsNullOrWhiteSpace(card.Definition.DisplayName) ? card.Definition.Id : card.Definition.DisplayName;
}
}
public sealed class NoOpCardEffect : ICardEffect
{
private readonly string _todo;
public NoOpCardEffect(string todo)
{
_todo = todo;
}
public void Execute(CombatContext context)
{
context.CombatManager.LogEffectTodo(context.Card, _todo);
}
}
public static class CardEffectFactory
{
public static IReadOnlyList<ICardEffect> CreateEffects(CardDefinition definition)
{
List<ICardEffect> serializedEffects = CreateSerializedEffects(definition);
if (serializedEffects.Count > 0)
{
return serializedEffects;
}
return definition?.Id switch
{
"lang_py_print" => One(new DealDamageEffect(3, 3, Language.Python)),
"lang_py_import_antigravity" => One(new DrawEffect(2)),
"lang_py_for_loop" => One(new DealDamageEffect(2, 2, Language.Python, allEnemies: true)),
"lang_js_console_log" => One(new DealDamageEffect(3, 9, Language.JavaScript)),
"lang_js_fetch" => One(new DealDamageEffect(5, 13, Language.JavaScript)),
"ubuntu_ask_ubuntu" => One(new DrawEffect(2)),
"ubuntu_unattended_upgrades" => new ICardEffect[]
{
new ShieldEffect(4),
new ApplyStatusEffect(StatusType.UnattendedUpgrades, 4, 2, targetSelf: true, skipNextTick: true)
},
"mint_fix_broken" => new ICardEffect[]
{
new DealDamageEffect(8, 8, Language.JavaScript),
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_js_math_random" => One(new DealDamageEffect(2, 20, Language.JavaScript)),
"shop_js_promise_all" => new ICardEffect[]
{
new DealDamageEffect(4, 10, Language.JavaScript),
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."),
"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 needs delayed restore system."),
"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 needs next-card cost mutation and crash-risk hook."),
"fedora_selinux" => Todo("TODO: SELinux enforcing needs enemy attack mitigation statuses."),
_ => Todo("TODO: card effect is not implemented yet.")
};
}
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";
}
public static bool TargetsAllEnemies(CardDefinition definition)
{
return definition?.Id is "lang_py_for_loop";
}
private static List<ICardEffect> CreateSerializedEffects(CardDefinition definition)
{
List<ICardEffect> effects = new();
if (definition?.Effects == null)
{
return effects;
}
for (int i = 0; i < definition.Effects.Count; i++)
{
ICardEffect effect = definition.Effects[i] == null ? null : definition.Effects[i].CreateRuntimeEffect();
if (effect != null)
{
effects.Add(effect);
}
}
return effects;
}
private static IReadOnlyList<ICardEffect> One(ICardEffect effect)
{
return new[] { effect };
}
private static IReadOnlyList<ICardEffect> Todo(string message)
{
return One(new NoOpCardEffect(message));
}
public static string GetRulesText(CardInstance card)
{
if (card?.Definition == null)
{
return "--";
}
int level = card.UpgradeLevel;
string marker = level > 0 ? " [upgraded]" : string.Empty;
return card.Definition.Id switch
{
"lang_py_print" => $"queue: deal {UpgradeMath.ScaleAmount(3, card)}.{marker}",
"lang_py_import_antigravity" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}.{marker}",
"lang_py_for_loop" => $"queue: deal {UpgradeMath.ScaleAmount(2, card)} to all enemies.{marker}",
"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_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}",
"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_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}"
};
}
}
public static class UpgradeMath
{
public static int ScaleAmount(int baseAmount, CardInstance card)
{
return baseAmount + UnityEngine.Mathf.Max(0, card?.MagnitudeBonus ?? 0);
}
public static int ScaleShield(int baseAmount, CardInstance card)
{
return baseAmount + UnityEngine.Mathf.Max(0, card?.MagnitudeBonus ?? 0);
}
}
}
+86
View File
@@ -1,4 +1,6 @@
using KernelPanic.Data;
using System.Collections.Generic;
using System.Linq;
namespace KernelPanic.Combat
{
@@ -7,14 +9,98 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class CardInstance
{
private readonly List<CombatantState> _targetSnapshot = new();
public CardInstance(CardDefinition definition)
{
Definition = definition;
}
public CardDefinition Definition { get; }
public IReadOnlyList<CombatantState> TargetSnapshot => _targetSnapshot;
public int TemporaryCostDelta { get; set; }
public int PermanentCostDelta { get; private set; }
public int MagnitudeBonus { get; private set; }
public int DrawRider { get; private set; }
public int UpgradeLevel { get; private set; }
public bool IsBroken { get; set; }
public bool IsLocked { get; set; }
public bool CanUpgrade => UpgradeLevel == 0;
public bool CanReceiveRepositoryUpgrade => UpgradeLevel < 3;
public bool IsUpgraded => UpgradeLevel > 0;
public bool Upgrade()
{
if (!CanUpgrade)
{
return false;
}
UpgradeLevel = 1;
ApplyCostUpgrade(-1);
ApplyMagnitudeUpgrade(1);
return true;
}
public bool ApplyCostUpgrade(int delta)
{
if (Definition == null || Definition.CycleCost + PermanentCostDelta + TemporaryCostDelta <= 0)
{
return false;
}
PermanentCostDelta += delta;
UpgradeLevel++;
return true;
}
public bool ApplyMagnitudeUpgrade(int amount)
{
if (amount <= 0)
{
return false;
}
MagnitudeBonus += amount;
UpgradeLevel++;
return true;
}
public bool ApplyDrawRider(int amount)
{
if (amount <= 0)
{
return false;
}
DrawRider += amount;
UpgradeLevel++;
return true;
}
public void SetTargetSnapshot(IEnumerable<CombatantState> targets)
{
_targetSnapshot.Clear();
if (targets == null)
{
return;
}
_targetSnapshot.AddRange(targets.Where(target => target != null && !target.IsDefeated));
}
public void ClearTargetSnapshot()
{
_targetSnapshot.Clear();
}
public void ResetCombatState()
{
TemporaryCostDelta = 0;
IsBroken = false;
IsLocked = false;
ClearTargetSnapshot();
}
}
}
+22 -1
View File
@@ -7,15 +7,36 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class CombatContext
{
public CombatContext(CombatantState source, IReadOnlyList<CombatantState> targets, CombatManager combatManager)
public CombatContext(
CardInstance card,
CombatantState source,
IReadOnlyList<CombatantState> targets,
CombatManager combatManager,
DamagePipeline damagePipeline,
StatusEffectController statusEffects,
DeckController deckController,
HandController handController,
IReadOnlyList<EnemyInstance> enemies)
{
Card = card;
Source = source;
Targets = targets;
CombatManager = combatManager;
DamagePipeline = damagePipeline;
StatusEffects = statusEffects;
DeckController = deckController;
HandController = handController;
Enemies = enemies;
}
public CardInstance Card { get; }
public CombatantState Source { get; }
public IReadOnlyList<CombatantState> Targets { get; }
public CombatManager CombatManager { get; }
public DamagePipeline DamagePipeline { get; }
public StatusEffectController StatusEffects { get; }
public DeckController DeckController { get; }
public HandController HandController { get; }
public IReadOnlyList<EnemyInstance> Enemies { get; }
}
}
+783 -1
View File
@@ -1,5 +1,10 @@
using KernelPanic.Core;
using KernelPanic.Data;
using KernelPanic.Run;
using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;
namespace KernelPanic.Combat
{
@@ -10,26 +15,277 @@ namespace KernelPanic.Combat
{
[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<EnemyInstance> enemies = new();
private readonly HashSet<string> loggedEffectTodos = new();
private readonly List<CardDefinition> generatedCardPool = 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;
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<EnemyInstance> 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<string> CombatLog;
private void Awake()
{
runManager = GetComponent<RunManager>();
}
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()
{
SetPhase(TurnPhase.Interpret);
if (currentPhase == TurnPhase.Execute && !IsCombatPaused)
{
pendingTargetCard = null;
selectedEnemyIndex = -1;
SetPhase(TurnPhase.Interpret);
}
}
public void ContinueToNextWave()
{
if (!awaitingWaveContinue || runLost)
{
return;
}
awaitingWaveContinue = false;
StartWave(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;
}
int cost = GetCardCost(card);
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;
GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, track));
LogPlayIntent(card);
card.SetTargetSnapshot(CaptureTargets(card));
switch (track)
{
case ResolutionTrack.InterpreterQueue:
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;
}
pendingTargetCard = null;
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 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 SetGeneratedCardPool(IEnumerable<CardDefinition> cardPool)
{
generatedCardPool.Clear();
if (cardPool == null)
{
return;
}
foreach (CardDefinition card in cardPool)
{
if (card != null && !card.IsToken && !card.IsRunOnly)
{
generatedCardPool.Add(card);
}
}
}
public bool TryCreateGeneratedCard(Language language, Rarity rarity, out CardInstance generatedCard)
{
generatedCard = null;
List<CardDefinition> pool = new();
HashSet<string> seenIds = new();
IReadOnlyList<CardDefinition> 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));
@@ -51,6 +307,532 @@ namespace KernelPanic.Combat
private void EnterPhase(TurnPhase phase)
{
switch (phase)
{
case TurnPhase.Boot:
BootCombat();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.Allocate:
AllocateTurn();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.Execute:
StateChanged?.Invoke();
break;
case TurnPhase.Interpret:
InterpretQueue();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.EnemyProcess:
ProcessEnemies();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.GarbageCollection:
GarbageCollect();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
}
}
private void BootCombat()
{
runLost = false;
awaitingWaveContinue = false;
loggedEffectTodos.Clear();
RandomRoll.Seed(runConfig.RunSeed);
playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles());
StartWave(preservePlayerUptime: false);
Log($"booted {runConfig.Distro.DisplayName} with {runManager.RunDeck.Count} cards");
StateChanged?.Invoke();
}
private void StartWave(bool preservePlayerUptime)
{
int carriedUptime = playerState == null ? 0 : playerState.CurrentUptime;
if (!preservePlayerUptime || playerState == null)
{
playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles());
}
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.IsDefeated = false;
playerState.MutableStatuses.Clear();
}
handController = new HandController(playerState.Ram);
selectedEnemyIndex = -1;
pendingTargetCard = null;
List<CardInstance> 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();
DrawOpeningHand();
skipNextAllocateDraw = true;
}
private void AllocateTurn()
{
if (CheckLoss())
{
return;
}
playerState.Cycles = playerState.MaxCycles;
statusEffects.Tick(playerState, StatusTickTiming.StartOfTurn, playerState, damagePipeline);
if (CheckLoss())
{
return;
}
if (skipNextAllocateDraw)
{
skipNextAllocateDraw = false;
}
else
{
DrawForTurn();
}
StateChanged?.Invoke();
}
private void InterpretQueue()
{
while (interpreterQueue.TryDequeue(out CardInstance card))
{
LogPlayIntent(card);
ExecuteCardEffects(card, ResolveQueuedTargets(card));
ResolveCardRiders(card);
deckController.Discard(card);
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue));
if (CheckWinOrLoss())
{
return;
}
}
CheckWinOrLoss();
StateChanged?.Invoke();
}
private void ProcessEnemies()
{
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())
{
return;
}
if (enemy.State.IsDefeated)
{
continue;
}
Log($"{enemy.Name} executes {enemy.CurrentIntent.DisplayText}");
GameEvents.RaiseEnemyWouldAct(new EnemyWouldActEvent(enemy));
ExecuteEnemyIntent(enemy);
GameEvents.RaiseEnemyActed(new EnemyActedEvent(enemy, enemy.CurrentIntent));
if (CheckLoss())
{
return;
}
}
PickEnemyIntents();
StateChanged?.Invoke();
}
private void GarbageCollect()
{
selectedEnemyIndex = -1;
pendingTargetCard = null;
statusEffects.Tick(playerState, StatusTickTiming.EndOfTurn, playerState, damagePipeline);
if (CheckLoss())
{
return;
}
RemoveDefeatedEnemies();
CheckWinOrLoss();
StateChanged?.Invoke();
}
private void DrawOpeningHand()
{
int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, playerState.Ram);
DrawCardsToHand(openingDraw, "opening hand");
}
private void DrawForTurn()
{
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);
}
DrawCardsToHand(requested, "turn draw");
}
private void DrawCardsToHand(int requestedCount, string label)
{
if (handController == null || playerState == null || requestedCount <= 0)
{
return;
}
int room = playerState.Ram - handController.Cards.Count;
if (room <= 0)
{
Log($"{label}: hand full");
return;
}
int drawCount = Mathf.Min(requestedCount, room);
IReadOnlyList<CardInstance> drawn = deckController.Draw(drawCount);
int added = 0;
for (int i = 0; i < drawn.Count; i++)
{
if (handController.Add(drawn[i]))
{
added++;
}
}
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 CombatContext BuildContext(CardInstance card, IReadOnlyList<CombatantState> targets)
{
return new CombatContext(card, playerState, targets, this, damagePipeline, statusEffects, deckController, handController, enemies);
}
private void ExecuteCardEffects(CardInstance card, IReadOnlyList<CombatantState> targets)
{
IReadOnlyList<ICardEffect> 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;
}
DrawCardsToHand(card.DrawRider, "upgrade rider");
}
private IReadOnlyList<CombatantState> CaptureTargets(CardInstance card)
{
if (card?.Definition == null)
{
return Array.Empty<CombatantState>();
}
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<CombatantState>() : new[] { firstLiving };
}
return Array.Empty<CombatantState>();
}
private IReadOnlyList<CombatantState> ResolveTargets(CardInstance card)
{
List<CombatantState> 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<CombatantState> ResolveQueuedTargets(CardInstance card)
{
List<CombatantState> 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<CombatantState> 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<EnemyIntent> 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:
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 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;
}
}
+41
View File
@@ -0,0 +1,41 @@
namespace KernelPanic.Combat
{
/// <summary>
/// Central combat economy/scaffold tuning. Kept in code while the combat scaffold is still volatile;
/// promote to a ScriptableObject once designers need per-run or per-encounter tuning without recompiles.
/// </summary>
public static class CombatTuning
{
public const int OpeningHandSize = 5;
public const int DrawPerTurn = 1;
public const int MinimumHandFloor = 0;
public const int BaseEnemyUptimeMin = 5;
public const int BaseEnemyUptimeMax = 10;
public const int EnemyUptimeGrowthPerWave = 2;
public const int BaseEnemyAttack = 3;
public const int EnemyAttackGrowthEveryWaves = 2;
public const int EnemySlotAttackVariance = 1;
public const int EnemyStatusAttackDamage = 1;
public const int EnemyStatusDuration = 3;
public const int BaseEnemiesPerWave = 2;
public const int AdditionalEnemiesPerWave = 1;
public const int MaxEnemiesPerWave = 5;
public const int BitsPerKill = 1;
public const int ShopSize = 5;
public const int RerollBaseCost = 1;
public const int RerollCostStep = 1;
public const int RemoveCardCost = 1;
public const int NewCardOfferCost = 2;
public const int UpgradeOfferCost = 2;
public const int StatUpgradeCost = 3;
public const int StatUpgradeMaxCycles = 1;
public const int StatUpgradeMaxUptime = 4;
public const int StatUpgradeHeal = 5;
public const int StatUpgradeRam = 1;
public const int UpgradeMagnitudeBonus = 2;
}
}
+31 -2
View File
@@ -8,7 +8,7 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class CombatantState
{
private readonly Dictionary<StatusType, int> activeStatuses = new();
private readonly List<StatusInstance> _statuses = new();
public CombatantState(int maxUptime, int ram, int cycles)
{
@@ -16,6 +16,7 @@ namespace KernelPanic.Combat
CurrentUptime = maxUptime;
Ram = ram;
Cycles = cycles;
MaxCycles = cycles;
}
public int CurrentUptime { get; set; }
@@ -23,6 +24,34 @@ namespace KernelPanic.Combat
public int Shield { get; set; }
public int Ram { get; set; }
public int Cycles { get; set; }
public IReadOnlyDictionary<StatusType, int> ActiveStatuses => activeStatuses;
public int MaxCycles { get; set; }
public bool IsDefeated { get; set; }
public bool forceMaxRolls { get; set; }
public IReadOnlyList<StatusInstance> Statuses => _statuses;
public IReadOnlyDictionary<StatusType, int> ActiveStatuses => BuildStatusSnapshot();
internal List<StatusInstance> MutableStatuses => _statuses;
public bool HasStatus(StatusType type)
{
return _statuses.Exists(status => status.Type == type);
}
public int StacksOf(StatusType type)
{
StatusInstance status = _statuses.Find(instance => instance.Type == type);
return status == null ? 0 : status.Stacks;
}
private IReadOnlyDictionary<StatusType, int> BuildStatusSnapshot()
{
Dictionary<StatusType, int> snapshot = new();
for (int i = 0; i < _statuses.Count; i++)
{
snapshot[_statuses[i].Type] = _statuses[i].Stacks;
}
return snapshot;
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using KernelPanic.Core;
using UnityEngine;
namespace KernelPanic.Combat
{
public readonly struct DamageRequest
{
public DamageRequest(
CombatantState source,
CombatantState target,
int amount,
Language language,
bool trueDamage,
bool canCrit)
{
Source = source;
Target = target;
Amount = amount;
Language = language;
TrueDamage = trueDamage;
CanCrit = canCrit;
}
public CombatantState Source { get; }
public CombatantState Target { get; }
public int Amount { get; }
public Language Language { get; }
public bool TrueDamage { get; }
public bool CanCrit { get; }
}
public readonly struct DamageResult
{
public DamageResult(int finalAmount, bool targetDefeated)
{
FinalAmount = finalAmount;
TargetDefeated = targetDefeated;
}
public int FinalAmount { get; }
public bool TargetDefeated { get; }
}
/// <summary>
/// Single damage chokepoint for shield, uptime, damage events, and defeat decisions.
/// </summary>
public sealed class DamagePipeline
{
public DamageResult DealDamage(DamageRequest request)
{
if (request.Target == null || request.Target.IsDefeated)
{
return new DamageResult(0, false);
}
int amount = Mathf.Max(0, request.Amount);
amount = ApplyMultipliers(amount, request);
amount = ApplyCrit(amount, request);
amount = ApplyResistancesAndWeaknesses(amount, request);
int finalAmount = ApplyShieldAndUptime(amount, request);
GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language));
bool defeated = false;
if (request.Target.CurrentUptime <= 0 && !request.Target.IsDefeated)
{
request.Target.IsDefeated = true;
defeated = true;
GameEvents.RaiseCombatantDefeated(new CombatantDefeatedEvent(request.Target));
}
return new DamageResult(finalAmount, defeated);
}
private static int ApplyMultipliers(int amount, DamageRequest request)
{
// TODO: Apply buffs/debuffs and Mint's no-multiplier rule here.
return amount;
}
private static int ApplyCrit(int amount, DamageRequest request)
{
// TODO: Add crit calculation and crit modifiers.
return amount;
}
private static int ApplyResistancesAndWeaknesses(int amount, DamageRequest request)
{
// TODO: Apply enemy language resistances and weaknesses here.
return amount;
}
private static int ApplyShieldAndUptime(int amount, DamageRequest request)
{
CombatantState target = request.Target;
int remaining = amount;
int shieldDamage = 0;
if (!request.TrueDamage && target.Shield > 0)
{
shieldDamage = Mathf.Min(target.Shield, remaining);
target.Shield -= shieldDamage;
remaining -= shieldDamage;
}
int uptimeDamage = Mathf.Min(target.CurrentUptime, remaining);
target.CurrentUptime = Mathf.Max(0, target.CurrentUptime - uptimeDamage);
return shieldDamage + uptimeDamage;
}
}
}
+59 -13
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace KernelPanic.Combat
{
@@ -8,42 +8,88 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class DeckController
{
private readonly List<CardInstance> drawPile = new();
private readonly List<CardInstance> discardPile = new();
private readonly List<CardInstance> exhaustPile = new();
private readonly List<CardInstance> _drawPile = new();
private readonly List<CardInstance> _discardPile = new();
private readonly List<CardInstance> _exhaustPile = new();
public IReadOnlyList<CardInstance> DrawPile => drawPile;
public IReadOnlyList<CardInstance> DiscardPile => discardPile;
public IReadOnlyList<CardInstance> ExhaustPile => exhaustPile;
public IReadOnlyList<CardInstance> DrawPile => _drawPile;
public IReadOnlyList<CardInstance> DiscardPile => _discardPile;
public IReadOnlyList<CardInstance> ExhaustPile => _exhaustPile;
public int AvailableToDrawCount => _drawPile.Count + _discardPile.Count;
public void Initialize(IEnumerable<CardInstance> cards)
{
throw new NotImplementedException();
_drawPile.Clear();
_discardPile.Clear();
_exhaustPile.Clear();
if (cards != null)
{
_drawPile.AddRange(cards.Where(card => card != null));
}
ShuffleDrawPile();
}
public IReadOnlyList<CardInstance> Draw(int count)
{
throw new NotImplementedException();
List<CardInstance> drawn = new();
for (int i = 0; i < count; i++)
{
if (_drawPile.Count == 0)
{
ReshuffleDiscardIntoDraw();
}
if (_drawPile.Count == 0)
{
break;
}
int topIndex = _drawPile.Count - 1;
CardInstance card = _drawPile[topIndex];
_drawPile.RemoveAt(topIndex);
drawn.Add(card);
}
return drawn;
}
public void Discard(CardInstance card)
{
throw new NotImplementedException();
if (card != null)
{
_discardPile.Add(card);
}
}
public void Exhaust(CardInstance card)
{
throw new NotImplementedException();
if (card != null)
{
_exhaustPile.Add(card);
}
}
public void ShuffleDrawPile()
{
throw new NotImplementedException();
for (int i = _drawPile.Count - 1; i > 0; i--)
{
int swapIndex = RandomRoll.RollRange(0, i, RollContext.None);
(_drawPile[i], _drawPile[swapIndex]) = (_drawPile[swapIndex], _drawPile[i]);
}
}
public void ReshuffleDiscardIntoDraw()
{
throw new NotImplementedException();
if (_discardPile.Count == 0)
{
return;
}
_drawPile.AddRange(_discardPile);
_discardPile.Clear();
ShuffleDrawPile();
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.Collections.Generic;
namespace KernelPanic.Combat
{
/// <summary>
/// Runtime-only enemy placeholder used until encounter and EnemyDefinition assets are wired.
/// </summary>
public sealed class EnemyInstance
{
private readonly List<EnemyIntent> _intentPool = new();
public EnemyInstance(string name, int maxUptime, IEnumerable<EnemyIntent> intentPool)
{
Name = string.IsNullOrWhiteSpace(name) ? "unknown_process" : name;
State = new CombatantState(maxUptime, 0, 0);
if (intentPool != null)
{
_intentPool.AddRange(intentPool);
}
}
public string Name { get; }
public int CurrentUptime => State.CurrentUptime;
public int MaxUptime => State.MaxUptime;
public CombatantState State { get; }
public IReadOnlyList<EnemyIntent> IntentPool => _intentPool;
public EnemyIntent CurrentIntent { get; private set; }
public void PickNextIntent()
{
if (_intentPool.Count == 0)
{
CurrentIntent = default;
return;
}
int index = RandomRoll.RollRange(0, _intentPool.Count - 1, new RollContext(State));
CurrentIntent = _intentPool[index];
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using KernelPanic.Core;
namespace KernelPanic.Combat
{
public enum EnemyIntentKind
{
Attack,
Defend,
Buff,
StatusAttack,
Special
}
/// <summary>
/// Runtime enemy intent shape. Future EnemyDefinition assets should provide pools of this data.
/// </summary>
public readonly struct EnemyIntent
{
public EnemyIntent(
EnemyIntentKind kind,
int minValue,
int maxValue,
Language damageType,
string displayLabel,
string iconKey,
bool trueDamage = false,
StatusType statusType = StatusType.MemoryLeak,
int statusStacks = 0,
int statusDuration = -1)
{
Kind = kind;
MinValue = minValue;
MaxValue = maxValue;
DamageType = damageType;
DisplayLabel = string.IsNullOrWhiteSpace(displayLabel) ? kind.ToString() : displayLabel;
IconKey = string.IsNullOrWhiteSpace(iconKey) ? "?" : iconKey;
TrueDamage = trueDamage;
StatusType = statusType;
StatusStacks = statusStacks;
StatusDuration = statusDuration;
}
public EnemyIntentKind Kind { get; }
public int MinValue { get; }
public int MaxValue { get; }
public Language DamageType { get; }
public string DisplayLabel { get; }
public string IconKey { get; }
public bool TrueDamage { get; }
public StatusType StatusType { get; }
public int StatusStacks { get; }
public int StatusDuration { get; }
public string ValueText => MinValue == MaxValue ? MinValue.ToString() : $"{MinValue}-{MaxValue}";
public string DisplayText => $"{IconKey} {DisplayLabel} {ValueText}";
}
}
+13 -8
View File
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
namespace KernelPanic.Combat
@@ -8,7 +7,7 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class HandController
{
private readonly List<CardInstance> cards = new();
private readonly List<CardInstance> _cards = new();
public HandController(int ramCapacity)
{
@@ -16,26 +15,32 @@ namespace KernelPanic.Combat
}
public int RamCapacity { get; private set; }
public IReadOnlyList<CardInstance> Cards => cards;
public IReadOnlyList<CardInstance> Cards => _cards;
public bool CanAdd(CardInstance card)
{
throw new NotImplementedException();
return card != null && _cards.Count < RamCapacity;
}
public void Add(CardInstance card)
public bool Add(CardInstance card)
{
throw new NotImplementedException();
if (!CanAdd(card))
{
return false;
}
_cards.Add(card);
return true;
}
public bool Remove(CardInstance card)
{
throw new NotImplementedException();
return card != null && _cards.Remove(card);
}
public void Clear()
{
throw new NotImplementedException();
_cards.Clear();
}
}
}
+33 -6
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
namespace KernelPanic.Combat
{
@@ -8,23 +8,50 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class InterpreterQueue : IResolutionTrack
{
private readonly Queue<CardInstance> queuedCards = new();
private readonly Queue<CardInstance> _queuedCards = new();
private readonly List<CardInstance> _queuedView = new();
public int Count => queuedCards.Count;
public int Count => _queuedCards.Count;
public IReadOnlyList<CardInstance> Cards => _queuedView;
public void Enqueue(CardInstance card)
{
throw new NotImplementedException();
if (card == null)
{
return;
}
_queuedCards.Enqueue(card);
_queuedView.Add(card);
}
public void Resolve(CombatContext context)
{
throw new NotImplementedException();
while (_queuedCards.Count > 0)
{
CardInstance card = _queuedCards.Dequeue();
_queuedView.Remove(card);
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue));
}
}
public bool TryDequeue(out CardInstance card)
{
if (_queuedCards.Count == 0)
{
card = null;
return false;
}
card = _queuedCards.Dequeue();
_queuedView.Remove(card);
return true;
}
public void Clear()
{
throw new NotImplementedException();
_queuedCards.Clear();
_queuedView.Clear();
}
}
}
+30 -6
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
namespace KernelPanic.Combat
{
@@ -8,23 +8,47 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class LazyStack : IResolutionTrack
{
private readonly Stack<CardInstance> stackedCards = new();
private readonly Stack<CardInstance> _stackedCards = new();
private readonly List<CardInstance> _stackedView = new();
public int Count => stackedCards.Count;
public int Count => _stackedCards.Count;
public IReadOnlyList<CardInstance> Cards => _stackedView;
public void Enqueue(CardInstance card)
{
throw new NotImplementedException();
if (card == null)
{
return;
}
_stackedCards.Push(card);
_stackedView.Insert(0, card);
}
public void Resolve(CombatContext context)
{
throw new NotImplementedException();
ForceEvaluate(context);
}
public void ForceEvaluate(CombatContext context)
{
throw new NotImplementedException();
while (TryPop(out CardInstance card))
{
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.LazyStack));
}
}
public bool TryPop(out CardInstance card)
{
if (_stackedCards.Count == 0)
{
card = null;
return false;
}
card = _stackedCards.Pop();
_stackedView.Remove(card);
return true;
}
}
}
+6 -3
View File
@@ -1,4 +1,4 @@
using System;
using KernelPanic.Core;
namespace KernelPanic.Combat
{
@@ -9,12 +9,15 @@ namespace KernelPanic.Combat
{
public void Enqueue(CardInstance card)
{
throw new NotImplementedException();
if (card != null)
{
GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, ResolutionTrack.Native));
}
}
public void Resolve(CombatContext context)
{
throw new NotImplementedException();
// Native cards resolve immediately in CombatManager.PlayCard; this track has no queue.
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using System;
namespace KernelPanic.Combat
{
/// <summary>
/// Carries source data for combat rolls so later passives can bias the result.
/// </summary>
public readonly struct RollContext
{
public RollContext(CombatantState source)
{
Source = source;
}
public static RollContext None => new(null);
public CombatantState Source { get; }
}
/// <summary>
/// Single combat randomness chokepoint. Seeded from RunConfig.RunSeed during boot for reproducible runs.
/// </summary>
public static class RandomRoll
{
private static Random _random = new(0);
public static void Seed(int seed)
{
_random = new Random(seed);
}
public static int RollRange(int min, int max, RollContext context)
{
if (min > max)
{
(min, max) = (max, min);
}
// Mint's passive will flip this bool; no passive sets it in this scaffold.
if (context.Source != null && context.Source.forceMaxRolls)
{
return max;
}
return _random.Next(min, max + 1);
}
}
}
+191 -6
View File
@@ -1,26 +1,211 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
using UnityEngine;
namespace KernelPanic.Combat
{
/// <summary>
/// Applies, ticks, and expires status effects on a combatant.
/// Applies, ticks, cleanses, and expires status effects on a combatant.
/// </summary>
public sealed class StatusEffectController
{
public void Apply(CombatantState target, StatusType statusType, int stacks)
private static readonly Dictionary<StatusType, StatusDescriptor> Descriptors = new()
{
throw new NotImplementedException();
[StatusType.MemoryLeak] = new(StatusType.MemoryLeak, "Memory Leak", "leak", false, StatusStackingRule.Intensity, StatusTickTiming.StartOfTurn, "Takes damage equal to stacks at start of turn."),
[StatusType.Segfault] = new(StatusType.Segfault, "Segfault", "segv", false, StatusStackingRule.Unique, StatusTickTiming.StartOfTurn, "Loses one cycle this turn. TODO: lock a random card slot."),
[StatusType.RaceCondition] = new(StatusType.RaceCondition, "Race Condition", "race", false, StatusStackingRule.Refresh, StatusTickTiming.None, "Randomizes multi-target resolution order where supported."),
[StatusType.Deprecated] = new(StatusType.Deprecated, "Deprecated", "dep", false, StatusStackingRule.Duration, StatusTickTiming.StartOfTurn, "A temporary positive value decays. Currently reduces shield by 1."),
[StatusType.DependencyError] = new(StatusType.DependencyError, "Dependency Error", "deps", false, StatusStackingRule.Unique, StatusTickTiming.None, "Registered for off-support card cost rules. TODO: connect draw-time cost path."),
[StatusType.Deadlock] = new(StatusType.Deadlock, "Deadlock", "lock", false, StatusStackingRule.Duration, StatusTickTiming.None, "Registered goroutine freeze. TODO: connect goroutine token ticking."),
[StatusType.UnattendedUpgrades] = new(StatusType.UnattendedUpgrades, "Unattended Upgrades", "apt", true, StatusStackingRule.Duration, StatusTickTiming.EndOfTurn, "Grants shield at end of turn, then expires."),
};
public static StatusDescriptor GetDescriptor(StatusType statusType)
{
return Descriptors[statusType];
}
public void Tick(CombatantState target)
public static bool Has(CombatantState target, StatusType statusType)
{
throw new NotImplementedException();
return target != null && target.HasStatus(statusType);
}
public static int StacksOf(CombatantState target, StatusType statusType)
{
return target == null ? 0 : target.StacksOf(statusType);
}
public void Apply(CombatantState target, StatusType statusType, int stacks, int duration, CombatantState source = null, bool skipNextTick = false)
{
if (target == null || target.IsDefeated)
{
return;
}
StatusDescriptor descriptor = GetDescriptor(statusType);
StatusInstance existing = target.MutableStatuses.Find(status => status.Type == statusType);
int safeStacks = Mathf.Max(1, stacks);
int safeDuration = duration == -1 ? -1 : Mathf.Max(1, duration);
if (existing == null)
{
target.MutableStatuses.Add(new StatusInstance(statusType, safeStacks, safeDuration, source, skipNextTick));
}
else
{
existing.Source = source ?? existing.Source;
Merge(existing, descriptor.StackingRule, safeStacks, safeDuration, skipNextTick);
}
GameEvents.RaiseStatusApplied(new StatusAppliedEvent(source, target, statusType, safeStacks, safeDuration));
}
public void Tick(CombatantState target, StatusTickTiming timing, CombatantState source, DamagePipeline damagePipeline)
{
if (target == null || target.IsDefeated)
{
return;
}
List<StatusInstance> snapshot = new(target.MutableStatuses);
for (int i = 0; i < snapshot.Count; i++)
{
StatusInstance status = snapshot[i];
if (!target.MutableStatuses.Contains(status))
{
continue;
}
StatusDescriptor descriptor = GetDescriptor(status.Type);
if (descriptor.TickTiming != timing)
{
continue;
}
if (status.SkipNextTick)
{
status.SkipNextTick = false;
continue;
}
ResolveTick(status, target, status.Source ?? source, damagePipeline);
DecayDuration(target, status);
}
}
public int Cleanse(CombatantState target, Predicate<StatusInstance> predicate)
{
if (target == null || predicate == null)
{
return 0;
}
int removed = 0;
for (int i = target.MutableStatuses.Count - 1; i >= 0; i--)
{
StatusInstance status = target.MutableStatuses[i];
if (predicate(status))
{
target.MutableStatuses.RemoveAt(i);
removed++;
GameEvents.RaiseStatusCleansed(new StatusCleansedEvent(target, status.Type));
}
}
return removed;
}
public int CleanseHarmful(CombatantState target)
{
return Cleanse(target, status => !GetDescriptor(status.Type).IsBeneficial);
}
public void Expire(CombatantState target, StatusType statusType)
{
throw new NotImplementedException();
if (target == null)
{
return;
}
for (int i = target.MutableStatuses.Count - 1; i >= 0; i--)
{
if (target.MutableStatuses[i].Type == statusType)
{
target.MutableStatuses.RemoveAt(i);
GameEvents.RaiseStatusExpired(new StatusExpiredEvent(target, statusType));
}
}
}
private static void Merge(StatusInstance existing, StatusStackingRule stackingRule, int stacks, int duration, bool skipNextTick)
{
switch (stackingRule)
{
case StatusStackingRule.Intensity:
existing.Stacks += stacks;
existing.Duration = MergeDuration(existing.Duration, duration);
break;
case StatusStackingRule.Duration:
existing.Stacks = Mathf.Max(existing.Stacks, stacks);
existing.Duration = existing.Duration == -1 || duration == -1 ? -1 : existing.Duration + duration;
break;
case StatusStackingRule.Refresh:
existing.Stacks = Mathf.Max(existing.Stacks, stacks);
existing.Duration = duration;
break;
case StatusStackingRule.Unique:
break;
}
existing.SkipNextTick |= skipNextTick;
}
private static int MergeDuration(int current, int incoming)
{
if (current == -1 || incoming == -1)
{
return -1;
}
return Mathf.Max(current, incoming);
}
private static void ResolveTick(StatusInstance status, CombatantState target, CombatantState source, DamagePipeline damagePipeline)
{
switch (status.Type)
{
case StatusType.MemoryLeak:
damagePipeline.DealDamage(new DamageRequest(source, target, status.Stacks, Language.C, false, false));
break;
case StatusType.Segfault:
target.Cycles = Mathf.Max(0, target.Cycles - 1);
break;
case StatusType.Deprecated:
target.Shield = Mathf.Max(0, target.Shield - 1);
break;
case StatusType.UnattendedUpgrades:
target.Shield += status.Stacks;
break;
case StatusType.DependencyError:
case StatusType.Deadlock:
case StatusType.RaceCondition:
break;
}
}
private void DecayDuration(CombatantState target, StatusInstance status)
{
if (status.Duration == -1)
{
return;
}
status.Duration--;
if (status.Duration <= 0)
{
Expire(target, status.Type);
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using KernelPanic.Core;
namespace KernelPanic.Combat
{
public enum StatusStackingRule
{
Intensity,
Duration,
Refresh,
Unique
}
public enum StatusTickTiming
{
None,
StartOfTurn,
EndOfTurn
}
public sealed class StatusInstance
{
public StatusInstance(StatusType type, int stacks, int duration, CombatantState source = null, bool skipNextTick = false)
{
Type = type;
Stacks = stacks;
Duration = duration;
Source = source;
SkipNextTick = skipNextTick;
}
public StatusType Type { get; }
public int Stacks { get; set; }
public int Duration { get; set; }
public CombatantState Source { get; set; }
public bool SkipNextTick { get; set; }
}
public readonly struct StatusDescriptor
{
public StatusDescriptor(
StatusType type,
string displayName,
string iconKey,
bool isBeneficial,
StatusStackingRule stackingRule,
StatusTickTiming tickTiming,
string tooltip)
{
Type = type;
DisplayName = displayName;
IconKey = iconKey;
IsBeneficial = isBeneficial;
StackingRule = stackingRule;
TickTiming = tickTiming;
Tooltip = tooltip;
}
public StatusType Type { get; }
public string DisplayName { get; }
public string IconKey { get; }
public bool IsBeneficial { get; }
public StatusStackingRule StackingRule { get; }
public StatusTickTiming TickTiming { get; }
public string Tooltip { get; }
}
}