Fix C, more enemies and less scaling for enemies.

This commit is contained in:
2026-07-09 20:02:37 +02:00
parent 1e53968c36
commit e3ea42c93e
24 changed files with 1582 additions and 1113 deletions
+70 -47
View File
@@ -35,6 +35,7 @@ namespace KernelPanic.Combat
targets = ShuffleTargets(targets, context.Source);
}
bool dealtDamage = false;
for (int i = 0; i < targets.Count; i++)
{
CombatantState target = targets[i];
@@ -54,11 +55,14 @@ namespace KernelPanic.Combat
? _trueDamage ? target.CurrentUptime : target.CurrentUptime + target.Shield
: 0;
DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, _trueDamage, _canCrit));
dealtDamage |= result.IncomingAmount > 0;
if (rustOverkillToShield)
{
GrantRustOverkillShield(context, result, survivable);
}
}
SegfaultRecoil.TryApply(context, _language, dealtDamage);
}
private static void GrantRustOverkillShield(CombatContext context, DamageResult result, int survivable)
@@ -164,7 +168,8 @@ namespace KernelPanic.Combat
int roll = RandomRoll.RollRange(1, 100, new RollContext(context.Source));
int damageAmount = UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_damageAmount, context), context.Card);
int amount = roll <= _successPercent ? damageAmount : 0;
context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, true));
DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, true));
SegfaultRecoil.TryApply(context, _language, result.IncomingAmount > 0);
context.CombatManager.ReportEffectResult(amount == 0 ? "typeof -> NaN (0)" : $"typeof -> {amount}");
}
}
@@ -189,41 +194,6 @@ namespace KernelPanic.Combat
}
}
public sealed class SegfaultDamageEffect : ICardEffect
{
private readonly int _amount;
private readonly int _selfDamage;
private readonly int _segfaultPercent;
private readonly bool _trueDamage;
private readonly bool _drawOnSegfault;
public SegfaultDamageEffect(int amount, int selfDamage, int segfaultPercent, bool trueDamage = false, bool drawOnSegfault = false)
{
_amount = amount;
_selfDamage = selfDamage;
_segfaultPercent = segfaultPercent;
_trueDamage = trueDamage;
_drawOnSegfault = drawOnSegfault;
}
public void Execute(CombatContext context)
{
new DealDamageEffect(_amount, _amount, Language.C, _trueDamage).Execute(context);
bool segfault = RandomRoll.RollRange(1, 100, new RollContext(context.Source)) <= _segfaultPercent;
if (!segfault)
{
return;
}
context.DamagePipeline.DealDamage(new DamageRequest(context.Source, context.Source, _selfDamage, Language.C, false, false));
context.CombatManager.ReportEffectResult($"segfault: took {_selfDamage}");
if (_drawOnSegfault)
{
context.CombatManager.DrawCards(2, "free() recoil");
}
}
}
public sealed class PreviousLanguageDamageEffect : ICardEffect
{
private readonly int _baseAmount;
@@ -687,6 +657,7 @@ namespace KernelPanic.Combat
int amount = UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_amount, context), context.Card);
int survivable = target.CurrentUptime + target.Shield;
DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, true));
SegfaultRecoil.TryApply(context, _language, result.IncomingAmount > 0);
int overkill = UnityEngine.Mathf.Max(0, result.IncomingAmount - survivable);
if (overkill > 0)
{
@@ -875,6 +846,58 @@ namespace KernelPanic.Combat
}
}
internal static class SegfaultRecoil
{
private const int DefaultPercent = 20;
private const int DefaultUptimeDamage = 3;
public static void TryApply(CombatContext context, Language damageLanguage, bool dealtDamage)
{
if (!dealtDamage
|| context == null
|| context.SegfaultRecoilRolled
|| context.Source == null
|| context.Source.IsDefeated
|| context.Card?.Definition?.Language != Language.C
|| damageLanguage != Language.C)
{
return;
}
context.SegfaultRecoilRolled = true;
GetProfile(context.Card, out int percent, out int uptimeDamage, out bool drawOnSegfault);
if (RandomRoll.RollRange(1, 100, new RollContext(context.Source)) > percent)
{
return;
}
DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(null, context.Source, uptimeDamage, Language.C, true, false));
context.CombatManager.ReportEffectResult($"segfault: uptime -{result.FinalAmount}");
if (drawOnSegfault)
{
context.CombatManager.DrawCards(2, "free() recoil");
}
}
private static void GetProfile(CardInstance card, out int percent, out int uptimeDamage, out bool drawOnSegfault)
{
percent = DefaultPercent;
uptimeDamage = DefaultUptimeDamage;
drawOnSegfault = false;
switch (card?.Definition?.Id)
{
case "shop_c_free":
drawOnSegfault = true;
break;
case "shop_c_kernel_exploit":
percent = 50;
uptimeDamage = 8;
break;
}
}
}
public static class CardEffectFactory
{
public static IReadOnlyList<ICardEffect> CreateEffects(CardDefinition definition)
@@ -899,7 +922,7 @@ namespace KernelPanic.Combat
new DrawEffect(1)
},
"lang_rs_borrow" => One(new ShieldEffect(5)),
"lang_c_malloc" => One(new SegfaultDamageEffect(8, 3, 20)),
"lang_c_malloc" => One(new DealDamageEffect(8, 8, Language.C)),
"lang_c_printf" => One(new DealDamageEffect(4, 4, Language.C)),
"lang_c_pointer" => One(new PreviousLanguageDamageEffect(6, 10, Language.C)),
"lang_java_public_static_main" => One(new DealDamageEffect(7, 7, Language.Java)),
@@ -958,9 +981,9 @@ namespace KernelPanic.Combat
"arch_makepkg" => One(new BtwScaledDamageEffect(4, 2, Language.C)),
"arch_aur_helper" => One(new RandomCommonLanguageCardEffect()),
"arch_minimal_install" => One(new MinimalInstallEffect()),
"shop_c_free" => One(new SegfaultDamageEffect(12, 3, 20, drawOnSegfault: true)),
"shop_c_free" => One(new DealDamageEffect(12, 12, Language.C)),
"shop_c_sizeof" => One(new SizeofDamageEffect()),
"shop_c_kernel_exploit" => One(new SegfaultDamageEffect(25, 8, 50, true)),
"shop_c_kernel_exploit" => One(new DealDamageEffect(25, 25, Language.C, true)),
_ => Todo("card effect is not authored")
};
}
@@ -1030,9 +1053,9 @@ namespace KernelPanic.Combat
"lang_rs_fn_main" => $"deal {UpgradeMath.ScaleAmount(6, card)}. overkill becomes shield.{marker}",
"lang_rs_unwrap" => $"deal {UpgradeMath.ScaleAmount(9, card)}. overkill becomes shield. draw {UpgradeMath.ScaleAmount(1, card)}.{marker}",
"lang_rs_borrow" => $"gain {UpgradeMath.ScaleShield(5, card)} shield.{marker}",
"lang_c_malloc" => $"deal {UpgradeMath.ScaleAmount(8, card)}. 20%: segfault, take 3.{marker}",
"lang_c_printf" => $"deal {UpgradeMath.ScaleAmount(4, card)}.{marker}",
"lang_c_pointer" => $"deal {UpgradeMath.ScaleAmount(6, card)}; {UpgradeMath.ScaleAmount(10, card)} if the previous card was C.{marker}",
"lang_c_malloc" => $"deal {UpgradeMath.ScaleAmount(8, card)}. 20%: segfault, uptime -3.{marker}",
"lang_c_printf" => $"deal {UpgradeMath.ScaleAmount(4, card)}. 20%: segfault, uptime -3.{marker}",
"lang_c_pointer" => $"deal {UpgradeMath.ScaleAmount(6, card)}; {UpgradeMath.ScaleAmount(10, card)} if the previous card was C. 20%: segfault, uptime -3.{marker}",
"lang_java_public_static_main" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(7, card)}. JIT: Java cards cost 1 less this combat each time you play a Java card; discount decays by 1 after each wave.{marker}",
"lang_java_system_out_println" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(5, card)}. JIT applies; discount decays by 1 after each wave.{marker}",
"lang_java_new_object" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(3, card)}. JIT applies; discount decays by 1 after each wave.{marker}",
@@ -1057,14 +1080,14 @@ namespace KernelPanic.Combat
"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}",
"arch_pacman_syu" => $"deal {UpgradeMath.ScaleAmount(8, card)}. TODO: 20% break a random hand card.{marker}",
"arch_pacman_syu" => $"deal {UpgradeMath.ScaleAmount(8, card)}. 20%: segfault, uptime -3. TODO: 20% break a random hand card.{marker}",
"arch_consult_wiki" => $"fix a broken card and draw {UpgradeMath.ScaleAmount(1, card)}; if none, deal {UpgradeMath.ScaleAmount(6, card)}.{marker}",
"arch_makepkg" => $"deal {UpgradeMath.ScaleAmount(4, card)} + btw x2; x3 at Arch v3+.{marker}",
"arch_makepkg" => $"deal {UpgradeMath.ScaleAmount(4, card)} + btw x2; x3 at Arch v3+. 20%: segfault, uptime -3.{marker}",
"arch_aur_helper" => $"add a random C/Rust common card to hand; it costs 0 this turn.{marker}",
"arch_minimal_install" => $"exhaust up to 2 hand cards. per card: gain 3 Shield and 1 Cycle.{marker}",
"shop_c_free" => $"deal {UpgradeMath.ScaleAmount(12, card)}. segfault recoil draws 2.{marker}",
"shop_c_sizeof" => $"deal 3 x C cards in hand.{marker}",
"shop_c_kernel_exploit" => $"deal {UpgradeMath.ScaleAmount(25, card)} ignoring Shield. 50%: take 8.{marker}",
"shop_c_free" => $"deal {UpgradeMath.ScaleAmount(12, card)}. 20%: segfault, uptime -3 and draw 2.{marker}",
"shop_c_sizeof" => $"deal 3 x C cards in hand. 20%: segfault, uptime -3.{marker}",
"shop_c_kernel_exploit" => $"deal {UpgradeMath.ScaleAmount(25, card)} ignoring Shield. 50%: segfault, uptime -8.{marker}",
_ => string.IsNullOrWhiteSpace(card.Definition.Description) ? "effect not authored." : $"{card.Definition.Description}{marker}"
};
}
+2
View File
@@ -38,5 +38,7 @@ namespace KernelPanic.Combat
public DeckController DeckController { get; }
public HandController HandController { get; }
public IReadOnlyList<EnemyInstance> Enemies { get; }
internal bool SegfaultRecoilRolled { get; set; }
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@ namespace KernelPanic.Combat
public const int BaseEnemyUptimeMin = 5;
public const int BaseEnemyUptimeMax = 10;
public const int EnemyUptimeGrowthPerWave = 2;
public const int EnemyUptimeGrowthPerWave = 1;
public const int BaseEnemyAttack = 3;
public const int EnemyAttackGrowthEveryWaves = 2;
+1 -1
View File
@@ -26,7 +26,7 @@ namespace KernelPanic.Combat
public int Cycles { get; set; }
public int MaxCycles { get; set; }
public bool IsDefeated { get; set; }
public bool forceMaxRolls { get; set; }
public bool ForceMaxRolls { get; set; }
public bool IgnoreDamageMultipliers { get; set; }
public bool AllowFlatDamageBuffs { get; set; }
public int FlatEffectBonus { get; set; }
+1 -1
View File
@@ -116,7 +116,7 @@ namespace KernelPanic.Combat
private static int ApplyCrit(int amount, DamageRequest request, out bool wasCritical)
{
wasCritical = false;
if (!request.CanCrit || amount <= 0 || request.Source != null && request.Source.forceMaxRolls)
if (!request.CanCrit || amount <= 0 || request.Source != null && request.Source.ForceMaxRolls)
{
return amount;
}
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
using UnityEngine;
namespace KernelPanic.Combat
{
[Flags]
public enum EnemyBehaviorFlags
{
None = 0,
Revive = 1 << 0,
Split = 1 << 1,
Grow = 1 << 2,
ObfuscateIntent = 1 << 3,
DefendAllies = 1 << 4,
Countdown = 1 << 5
}
public sealed class EnemyArchetypeDescriptor
{
public EnemyArchetypeDescriptor(
string id,
string displayName,
int baseUptimeMin,
int baseUptimeMax,
EnemyBehaviorFlags behaviorFlags,
IReadOnlyList<EnemyIntent> intentPool)
{
Id = string.IsNullOrWhiteSpace(id) ? "unknown_process" : id;
DisplayName = string.IsNullOrWhiteSpace(displayName) ? Id : displayName;
BaseUptimeMin = Mathf.Max(1, baseUptimeMin);
BaseUptimeMax = Mathf.Max(BaseUptimeMin, baseUptimeMax);
BehaviorFlags = behaviorFlags;
IntentPool = intentPool ?? Array.Empty<EnemyIntent>();
}
public string Id { get; }
public string DisplayName { get; }
public int BaseUptimeMin { get; }
public int BaseUptimeMax { get; }
public EnemyBehaviorFlags BehaviorFlags { get; }
public IReadOnlyList<EnemyIntent> IntentPool { get; }
}
/// <summary>
/// Runtime archetype construction site. Future EnemyDefinition assets should replace this table
/// by providing the same descriptor data: id, display name, base Uptime range, intents, and flags.
/// </summary>
public static class EnemyArchetypeCatalog
{
public const int ZombieReapThresholdPercent = 30;
public const int MemoryLeakAttackGrowthPerTurn = 1;
public const int ForkBombTotalCap = 5;
public const int CronInitialCountdown = 2;
private static readonly EnemyArchetypeDescriptor ZombieProcess = new(
"zombie_process",
"Zombie Process",
6,
10,
EnemyBehaviorFlags.Revive,
new[]
{
new EnemyIntent(EnemyIntentKind.Attack, 3, 4, Language.C, "attack", "!")
});
private static readonly EnemyArchetypeDescriptor MemoryLeak = new(
"memory_leak",
"Memory Leak",
4,
7,
EnemyBehaviorFlags.Grow,
new[]
{
new EnemyIntent(EnemyIntentKind.StatusAttack, 1, 1, Language.C, "leak", "!", false, StatusType.MemoryLeak, 1, CombatTuning.EnemyStatusDuration)
});
private static readonly EnemyArchetypeDescriptor ForkBomb = new(
"fork_bomb",
"Fork Bomb",
3,
5,
EnemyBehaviorFlags.Split,
new[]
{
new EnemyIntent(EnemyIntentKind.Special, 0, 0, Language.C, "fork", ":", false, StatusType.MemoryLeak, 0, -1, "split")
});
private static readonly EnemyArchetypeDescriptor Daemon = new(
"daemon",
"Daemon",
10,
14,
EnemyBehaviorFlags.ObfuscateIntent,
new[]
{
new EnemyIntent(EnemyIntentKind.Attack, 6, 8, Language.C, "daemon hit", "!")
});
private static readonly EnemyArchetypeDescriptor Defender = new(
"firewalld",
"firewalld",
5,
8,
EnemyBehaviorFlags.DefendAllies,
new[]
{
new EnemyIntent(EnemyIntentKind.Defend, 3, 5, Language.C, "shield adjacent", "#"),
new EnemyIntent(EnemyIntentKind.Defend, 3, 5, Language.C, "shield adjacent", "#"),
new EnemyIntent(EnemyIntentKind.Attack, 1, 2, Language.C, "poke", "!")
});
private static readonly EnemyArchetypeDescriptor CronJob = new(
"cron_job",
"Cron Job",
6,
9,
EnemyBehaviorFlags.Countdown,
new[]
{
new EnemyIntent(EnemyIntentKind.Attack, 10, 12, Language.C, "cron fire", "@")
});
public static EnemyArchetypeDescriptor Get(string id)
{
return id switch
{
"memory_leak" => MemoryLeak,
"fork_bomb" => ForkBomb,
"daemon" => Daemon,
"firewalld" => Defender,
"cron_job" => CronJob,
_ => ZombieProcess
};
}
}
}
+122 -3
View File
@@ -10,24 +10,57 @@ namespace KernelPanic.Combat
private readonly List<EnemyIntent> _intentPool = new();
public EnemyInstance(string name, int maxUptime, IEnumerable<EnemyIntent> intentPool)
: this(new EnemyArchetypeDescriptor(name, name, maxUptime, maxUptime, EnemyBehaviorFlags.None, new List<EnemyIntent>(intentPool ?? System.Array.Empty<EnemyIntent>())), maxUptime)
{
Name = string.IsNullOrWhiteSpace(name) ? "unknown_process" : name;
}
public EnemyInstance(EnemyArchetypeDescriptor archetype, int maxUptime)
{
Archetype = archetype ?? EnemyArchetypeCatalog.Get("zombie_process");
Name = Archetype.DisplayName;
State = new CombatantState(maxUptime, 0, 0);
if (intentPool != null)
if (Archetype.IntentPool != null)
{
_intentPool.AddRange(intentPool);
_intentPool.AddRange(Archetype.IntentPool);
}
CountdownRemaining = HasBehavior(EnemyBehaviorFlags.Countdown) ? EnemyArchetypeCatalog.CronInitialCountdown : 0;
}
public string Name { get; }
public EnemyArchetypeDescriptor Archetype { get; }
public string ArchetypeId => Archetype.Id;
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 int TurnsAlive { get; private set; }
public int CountdownRemaining { get; private set; }
public bool IntentRevealed { get; private set; }
public bool HasRevived { get; private set; }
public bool PendingRevive { get; private set; }
public bool Reaped { get; private set; }
public bool LethalHitThisTurn { get; private set; }
public bool HasPendingMarker => PendingRevive || HasRevived;
public EnemyIntent DisplayIntent => BuildDisplayIntent();
public void PickNextIntent()
{
if (PendingRevive)
{
CurrentIntent = new EnemyIntent(EnemyIntentKind.Special, 0, 0, KernelPanic.Core.Language.C, "reviving", "~", false, KernelPanic.Core.StatusType.MemoryLeak, 0, -1, "next");
return;
}
if (HasBehavior(EnemyBehaviorFlags.Countdown))
{
CurrentIntent = CountdownRemaining > 0
? new EnemyIntent(EnemyIntentKind.Special, CountdownRemaining, CountdownRemaining, KernelPanic.Core.Language.C, "countdown", "@")
: _intentPool.Count > 0 ? _intentPool[0] : default;
return;
}
if (_intentPool.Count == 0)
{
CurrentIntent = default;
@@ -36,6 +69,92 @@ namespace KernelPanic.Combat
int index = RandomRoll.RollRange(0, _intentPool.Count - 1, new RollContext(State));
CurrentIntent = _intentPool[index];
if (HasBehavior(EnemyBehaviorFlags.Grow))
{
int growth = TurnsAlive * EnemyArchetypeCatalog.MemoryLeakAttackGrowthPerTurn;
CurrentIntent = CurrentIntent.WithValues(CurrentIntent.MinValue + growth, CurrentIntent.MaxValue + growth, "growing leak");
}
}
public bool HasBehavior(EnemyBehaviorFlags behavior)
{
return (Archetype.BehaviorFlags & behavior) != 0;
}
public void MarkDamaged()
{
if (HasBehavior(EnemyBehaviorFlags.ObfuscateIntent))
{
IntentRevealed = true;
}
}
public void MarkTurnSurvived()
{
TurnsAlive++;
}
public void ResetTurnLethalMarker()
{
LethalHitThisTurn = false;
}
public void MarkLethalHit()
{
LethalHitThisTurn = true;
}
public void MarkPendingRevive()
{
PendingRevive = true;
State.IsDefeated = false;
State.CurrentUptime = 0;
State.Shield = 0;
CurrentIntent = new EnemyIntent(EnemyIntentKind.Special, 0, 0, KernelPanic.Core.Language.C, "reviving", "~", false, KernelPanic.Core.StatusType.MemoryLeak, 0, -1, "next");
}
public void ReviveAtHalfUptime()
{
PendingRevive = false;
HasRevived = true;
State.IsDefeated = false;
State.CurrentUptime = UnityEngine.Mathf.Max(1, UnityEngine.Mathf.CeilToInt(State.MaxUptime * 0.5f));
PickNextIntent();
}
public void MarkReaped()
{
PendingRevive = false;
Reaped = true;
State.IsDefeated = true;
State.CurrentUptime = 0;
}
public void AdvanceCountdownAfterAction()
{
if (!HasBehavior(EnemyBehaviorFlags.Countdown))
{
return;
}
CountdownRemaining = CurrentIntent.Kind == EnemyIntentKind.Attack
? EnemyArchetypeCatalog.CronInitialCountdown
: UnityEngine.Mathf.Max(0, CountdownRemaining - 1);
}
private EnemyIntent BuildDisplayIntent()
{
if (PendingRevive)
{
return new EnemyIntent(EnemyIntentKind.Special, 0, 0, KernelPanic.Core.Language.C, "reviving", "~", false, KernelPanic.Core.StatusType.MemoryLeak, 0, -1, "next");
}
if (HasBehavior(EnemyBehaviorFlags.ObfuscateIntent) && !IntentRevealed)
{
return new EnemyIntent(EnemyIntentKind.Special, 0, 0, KernelPanic.Core.Language.C, "unknown", "?", false, KernelPanic.Core.StatusType.MemoryLeak, 0, -1, "?");
}
return CurrentIntent;
}
}
}
+23 -2
View File
@@ -26,7 +26,8 @@ namespace KernelPanic.Combat
bool trueDamage = false,
StatusType statusType = StatusType.MemoryLeak,
int statusStacks = 0,
int statusDuration = -1)
int statusDuration = -1,
string valueTextOverride = null)
{
Kind = kind;
MinValue = minValue;
@@ -38,6 +39,7 @@ namespace KernelPanic.Combat
StatusType = statusType;
StatusStacks = statusStacks;
StatusDuration = statusDuration;
ValueTextOverride = valueTextOverride;
}
public EnemyIntentKind Kind { get; }
@@ -50,8 +52,27 @@ namespace KernelPanic.Combat
public StatusType StatusType { get; }
public int StatusStacks { get; }
public int StatusDuration { get; }
public string ValueTextOverride { get; }
public string ValueText => MinValue == MaxValue ? MinValue.ToString() : $"{MinValue}-{MaxValue}";
public string ValueText => string.IsNullOrWhiteSpace(ValueTextOverride)
? MinValue == MaxValue ? MinValue.ToString() : $"{MinValue}-{MaxValue}"
: ValueTextOverride;
public string DisplayText => $"{IconKey} {DisplayLabel} {ValueText}";
public EnemyIntent WithValues(int minValue, int maxValue, string displayLabel = null, string iconKey = null, string valueTextOverride = null)
{
return new EnemyIntent(
Kind,
minValue,
maxValue,
DamageType,
displayLabel ?? DisplayLabel,
iconKey ?? IconKey,
TrueDamage,
StatusType,
StatusStacks,
StatusDuration,
valueTextOverride);
}
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ namespace KernelPanic.Combat
}
// Mint's passive will flip this bool; no passive sets it in this scaffold.
if (context.Source != null && context.Source.forceMaxRolls)
if (context.Source != null && context.Source.ForceMaxRolls)
{
return max;
}