Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2

Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 08:21:44 -07:00
parent 8e0fe0230c
commit 9e1a1f606c
80 changed files with 19088 additions and 210 deletions

View File

@@ -9,9 +9,23 @@ type CombatStats struct {
Attack int
Defense int
Speed int
CritRate float64
DodgeRate float64
BlockRate float64
CritRate float64 // legacy; superseded by nat-20 crits but still consumed by autoCrit logic & equipment scaling.
DodgeRate float64 // legacy; no longer queried by hit resolution but still computed for narrative scaling.
BlockRate float64 // still used to halve damage on a successful hit.
// D&D layer. AC and AttackBonus drive d20-vs-AC hit resolution.
// Set via dnd_combat.go's applyDnDPlayerLayer / applyDnDArenaMonsterLayer / applyDnDDungeonMonsterLayer.
AC int
AttackBonus int
// Phase 8 — equipment-driven damage. When Weapon is non-nil, the d20
// attack path rolls weapon damage dice + AbilityModForDamage instead of
// the legacy calcDamage penetration formula. When nil, legacy math
// applies (used by monsters and any combatant without a D&D weapon).
Weapon *WeaponProfile
AbilityModForDamage int
WeaponProficient bool // false → -4 attack penalty (appendix §8 implementation note)
TwoHandedMode bool // true + versatile weapon → use larger versatile die
}
type CombatModifiers struct {
@@ -33,6 +47,11 @@ type CombatModifiers struct {
ReflectNext float64 // consumable: fraction of next hit reflected
AutoCritFirst bool // consumable: first player hit is auto-crit
FlatDmgStart int // consumable: flat damage to enemy pre-combat
// D&D race passives (Phase 3 — race traits with combat hooks).
LuckyReroll bool // Halfling: reroll the first nat 1 of the fight
RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage
PoisonResist bool // Dwarf: poison tick damage halved
}
type Combatant struct {
@@ -61,6 +80,10 @@ type CombatEvent struct {
PlayerHP int
EnemyHP int
Desc string // optional flavor (item name, ability name)
// D&D layer fields. Set only on events from the d20-vs-AC resolution path.
// Roll is the raw d20 (1..20); RollAgainst is the target AC.
Roll int
RollAgainst int
}
type CombatResult struct {
@@ -127,6 +150,11 @@ type combatState struct {
// Sovereign reprieve
deathSaveUsed bool
// D&D race-passive state
luckyUsed bool // Halfling Lucky reroll consumed
raged bool // Orc Rage already triggered this fight
pendingRageAttack bool // next player attack gets +50% damage
round int
events []CombatEvent
}
@@ -353,6 +381,9 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
// ── Attack Resolution ────────────────────────────────────────────────────────
// resolvePlayerAttack — d20 + AttackBonus vs enemy AC.
// Nat 20 = auto-hit + crit. Nat 1 = auto-miss tagged "fumble".
// Block (on hit) halves damage. autoCrit consumable forces a crit on hit.
func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
phaseName := phase.Name
@@ -366,44 +397,98 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
return false
}
// Enemy dodge
enemyDodge := enemy.Stats.DodgeRate * phase.SpeedWeight
if enemyDodge > 0 && rand.Float64() < enemyDodge {
// Orc Rage: trigger on the first attack after dropping below 50% HP.
// Use HP*2 < MaxHP rather than HP < MaxHP/2 so the threshold is exact
// regardless of MaxHP parity (avoids per-character drift on odd MaxHP).
if player.Mods.RageReady && !st.raged && st.playerHP > 0 &&
st.playerHP*2 < player.Stats.MaxHP {
st.raged = true
st.pendingRageAttack = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "rage",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: "Orc Rage",
})
}
roll := 1 + rand.IntN(20)
// Halfling Lucky: reroll the first nat 1 of the fight.
if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed {
st.luckyUsed = true
newRoll := 1 + rand.IntN(20)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "lucky_reroll",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: newRoll, RollAgainst: enemy.Stats.AC, Desc: "Halfling Lucky",
})
roll = newRoll
}
isFumble := roll == 1
isNat20 := roll == 20
// Class proficiency penalty (appendix §8): -4 attack with a non-proficient weapon.
attackBonus := player.Stats.AttackBonus
if player.Stats.Weapon != nil && !player.Stats.WeaponProficient {
attackBonus -= 4
}
total := roll + attackBonus
if isFumble || (!isNat20 && total < enemy.Stats.AC) {
desc := ""
if isFumble {
desc = "fumble"
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "miss",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: enemy.Stats.AC, Desc: desc,
})
return false
}
// Calculate damage
dmg := calcDamage(player.Stats.Attack, phase.AttackWeight, player.Mods.DamageBonus,
enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct)
// Damage roll: weapon dice path (Phase 8) or legacy penetration formula.
var dmg int
if player.Stats.Weapon != nil {
// Unproficient wielders don't add their ability mod to damage.
mod := player.Stats.AbilityModForDamage
if !player.Stats.WeaponProficient {
mod = 0
}
total, _ := rollWeaponDamage(player.Stats.Weapon, mod, player.Stats.TwoHandedMode)
dmg = total
// Class damage bonus / streak bonus / etc. layered on top via DamageBonus.
if player.Mods.DamageBonus > 0 {
dmg = int(float64(dmg) * (1 + player.Mods.DamageBonus))
}
// Apply enemy damage reduction (consumables, sets) the same way calcDamage does.
if enemy.Mods.DamageReduct > 0 && enemy.Mods.DamageReduct != 1.0 {
dmg = int(float64(dmg) * enemy.Mods.DamageReduct)
}
} else {
dmg = calcDamage(player.Stats.Attack, phase.AttackWeight, player.Mods.DamageBonus,
enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct)
}
// Block: half damage
blocked := enemy.Stats.BlockRate > 0 && rand.Float64() < enemy.Stats.BlockRate
if blocked {
dmg = max(1, dmg/2)
}
// Crit check
critRate := player.Stats.CritRate
if phaseName == "Decisive" {
critRate *= 2
}
isCrit := false
isCrit := isNat20
if st.autoCrit {
isCrit = true
st.autoCrit = false
dmg *= 2
} else if critRate > 0 && rand.Float64() < critRate {
isCrit = true
}
if isCrit {
// Crit: double damage. (5e rolls extra dice; we double total to
// match the engine's pre-Phase-8 crit semantics.)
dmg *= 2
}
// Orc Rage: +50% damage on this attack, then consume.
if st.pendingRageAttack {
dmg = int(float64(dmg) * 1.5)
st.pendingRageAttack = false
}
dmg = max(1, dmg)
// Cleave handling for enemy is in resolveEnemyAttack
action := "hit"
if isCrit {
action = "crit"
@@ -415,20 +500,21 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: action,
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: enemy.Stats.AC,
})
return st.enemyHP <= 0
}
// resolveEnemyAttack — enemy rolls d20 + AttackBonus vs player AC.
// Pet whiff and spore-cloud miss preempt the roll. Ward absorbs a hit fully.
// Block halves damage. Reflect bounces a fraction back. Death save fires if HP hits 0.
func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult, petWhiff, petDeflect, sporeMiss bool) bool {
phaseName := phase.Name
// Spore cloud round consumed when enemy attacks (even if whiffed/missed)
if st.sporeRounds > 0 {
st.sporeRounds--
}
// Pet whiff → guaranteed miss
if petWhiff {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_whiff",
@@ -437,7 +523,6 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
return false
}
// Spore cloud miss
if sporeMiss {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "spore_miss",
@@ -446,17 +531,24 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
return false
}
// Player dodge
playerDodge := player.Stats.DodgeRate * phase.SpeedWeight
if playerDodge > 0 && rand.Float64() < playerDodge {
roll := 1 + rand.IntN(20)
isFumble := roll == 1
isNat20 := roll == 20
total := roll + enemy.Stats.AttackBonus
if isFumble || (!isNat20 && total < player.Stats.AC) {
desc := ""
if isFumble {
desc = "fumble"
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "miss",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: player.Stats.AC, Desc: desc,
})
return false
}
// Ward: absorb full hit
if st.wardCharges > 0 {
st.wardCharges--
st.events = append(st.events, CombatEvent{
@@ -473,25 +565,17 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
dmg := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
// Player block
blocked := player.Stats.BlockRate > 0 && rand.Float64() < player.Stats.BlockRate
if blocked {
dmg = max(1, dmg/2)
}
// Crit
critRate := enemy.Stats.CritRate
if phaseName == "Decisive" {
critRate *= 2
}
isCrit := critRate > 0 && rand.Float64() < critRate
isCrit := isNat20
if isCrit {
dmg *= 2
}
dmg = max(1, dmg)
// Pet deflect: halve damage
if petDeflect {
dmg = max(1, dmg/2)
st.events = append(st.events, CombatEvent{
@@ -511,9 +595,9 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: action,
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: player.Stats.AC,
})
// Reflect: bounce portion back (after player takes the hit)
if st.reflectFrac > 0 {
reflected := max(1, int(float64(dmg)*st.reflectFrac))
st.reflectFrac = 0
@@ -554,6 +638,9 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
case "poison":
st.poisonTicks = 2
st.poisonDmg = 3 + rand.IntN(3)
if player.Mods.PoisonResist {
st.poisonDmg = max(1, st.poisonDmg/2)
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,