acdream/src/AcDream.Core/Spells/SpellModel.cs
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

216 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
namespace AcDream.Core.Spells;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R1 — spell system data model + cast state machine.
// Full research: docs/research/deepdives/r01-spell-system.md
// Wire evidence: holtburger fixtures + ACE Player_Spells.cs.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Magic school. Each maps to a skill (LifeMagic, CreatureMagic, ItemMagic, WarMagic, VoidMagic).
/// See r01 §1 + <c>ACE.Entity.Enum.MagicSchool</c>.
/// </summary>
public enum MagicSchool : uint
{
None = 0,
WarMagic = 1,
LifeMagic = 2,
CreatureEnchantment = 3,
ItemEnchantment = 4,
PortalMagic = 5,
// VoidMagic added in later retail revisions; uses LifeMagic skill.
VoidMagic = 6,
}
/// <summary>
/// Per-spell targeting category. Validates before cast.
/// </summary>
public enum SpellTargetType : uint
{
None = 0,
Self = 1,
Item = 2,
Creature = 3,
Object = 4,
SelfOrItem = 5,
Undef = 6,
OtherItem = 7, // targeted item but not your own
}
/// <summary>
/// Spell category for enchantment stacking. Retail rule: same category + higher
/// "power" replaces lower; different categories stack additively. See r01 §5.
/// </summary>
public enum SpellCategory : uint
{
Undef = 0,
// There are ~600 categories in the retail dat. We reference them by number
// rather than port the whole enum here.
}
[Flags]
public enum SpellFlags : uint
{
None = 0,
Beneficial = 0x00000001,
Resistable = 0x00000002,
Projectile = 0x00000004,
EnchantmentDispel = 0x00000008,
PurgeOnReset = 0x00000010,
NotIndoors = 0x00000020,
Melee = 0x00000040,
Missile = 0x00000080,
// more flags in r01 §1
}
/// <summary>
/// Per-spell on-disk record from the SpellTable dat (0x2Fxxxxxx).
/// 27 fields in retail; r01 §1 has the full layout.
/// </summary>
public sealed class SpellDatEntry
{
public uint SpellId { get; init; }
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public MagicSchool School { get; init; }
public int Power { get; init; } // difficulty / mastery
public float CastingTime { get; init; } // seconds
public float Duration { get; init; } // for enchants
public int BaseMana { get; init; }
public int ManaMod { get; init; } // per extra target
public int ManaConversionBase { get; init; }
public float ManaConversionMod { get; init; }
public SpellTargetType TargetType { get; init; }
public SpellCategory Category { get; init; }
public SpellFlags Flags { get; init; }
public IReadOnlyList<int> FormulaComponentIds { get; init; } = Array.Empty<int>();
public int RangeConstant { get; init; }
public int EconomyMod { get; init; }
public uint Icon { get; init; } // 0x06xxxxxx
public uint SpellStatModKey { get; init; } // what property it buffs
public int SpellStatModVal { get; init; }
}
/// <summary>
/// Spell component from the SpellComponentsTable dat (0x30xxxxxx).
/// Scarabs, herbs, talismans, taper wax. Each has a base consumption rate.
/// </summary>
public sealed class SpellComponentEntry
{
public int ComponentId { get; init; }
public string Name { get; init; } = "";
public uint Icon { get; init; }
public double CdmBonus { get; init; } // component-destruction modifier
public double ManaMod { get; init; }
public int Type { get; init; } // 1=scarab, 2=herb, 3=talisman, 4=taper, 5=pwax
}
/// <summary>
/// Runtime cast state — retail's model is a 4-phase state machine:
/// Preparing → Casting (syllables) → Releasing → Idle (success) / Fizzled.
/// See r01 §3 for the exact transitions and timing.
/// </summary>
public enum SpellCastPhase
{
Idle,
Preparing, // server validating
Casting, // syllables being played
Releasing, // cast animation hit-frame → effect resolves
Fizzled,
Complete,
}
public sealed class SpellCastStateMachine
{
public SpellCastPhase Phase { get; private set; } = SpellCastPhase.Idle;
public uint SpellId { get; private set; }
public uint? TargetGuid { get; private set; }
public double StartedAt { get; private set; }
public double CastDuration { get; private set; }
// Fired when the server confirms the cast started.
public event Action<SpellCastStateMachine>? OnPhaseChanged;
public void BeginCast(uint spellId, uint? targetGuid, double castDurationSec, double nowSec)
{
SpellId = spellId;
TargetGuid = targetGuid;
StartedAt = nowSec;
CastDuration = castDurationSec;
TransitionTo(SpellCastPhase.Preparing);
}
public void ServerAckCastingStart() => TransitionTo(SpellCastPhase.Casting);
public void ServerReleaseCast() => TransitionTo(SpellCastPhase.Releasing);
public void ServerCompleteCast() => TransitionTo(SpellCastPhase.Complete);
public void ServerFizzle() => TransitionTo(SpellCastPhase.Fizzled);
private void TransitionTo(SpellCastPhase next)
{
Phase = next;
OnPhaseChanged?.Invoke(this);
}
}
/// <summary>
/// Active buff/debuff on a character. Retail stacking rules:
/// • Same category, same caster: replace if higher power
/// • Same category, different caster: max power wins, both tracked
/// • Different category: stack additively
/// See r01 §5.
/// </summary>
public sealed class ActiveBuff
{
public uint SpellId { get; init; }
public uint CasterGuid { get; init; }
public SpellCategory Category { get; init; }
public int Power { get; init; }
public double StartedAt { get; init; }
public double Duration { get; init; }
public double EndsAt => StartedAt + Duration;
public int StatModKey { get; init; }
public int StatModValue { get; init; }
}
/// <summary>
/// Fizzle + mana math. Retail-faithful formulas — see r01 §4.
/// </summary>
public static class SpellMath
{
/// <summary>
/// Sigmoid fizzle curve. Returns chance-to-succeed in [0, 1].
/// <c>chance = 1 / (1 + e^(-0.07 × (skill - difficulty)))</c>
/// Hard floor: if skill &lt; difficulty-50, return 0 (auto-fizzle).
/// </summary>
public static double ChanceOfSuccess(int skill, int difficulty)
{
if (skill < difficulty - 50) return 0.0;
double x = -0.07 * (skill - difficulty);
return 1.0 / (1.0 + Math.Exp(x));
}
/// <summary>
/// Computes actual mana cost. Two sigmoid rolls at difficulty/2 then
/// difficulty, each can reduce cost. See r01 §4.
/// </summary>
public static int ComputeManaCost(SpellDatEntry spell, int numTargets,
int manaConvSkill, Random rng)
{
double cost = spell.BaseMana + spell.ManaMod * Math.Max(0, numTargets - 1);
// First reduction roll at half difficulty
if (rng.NextDouble() < ChanceOfSuccess(manaConvSkill, spell.Power / 2))
cost *= 0.5;
// Second reduction roll at full difficulty
if (rng.NextDouble() < ChanceOfSuccess(manaConvSkill, spell.Power))
cost *= 0.5;
return (int)Math.Max(1, Math.Round(cost));
}
}