mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Combat: back the flavor-only monster abilities with real effects
Turn the four placeholder ability effects into working mechanics: spell_resist halves player spell damage, reveal_action rolls the player's next swing at disadvantage, fear_immune fizzles control spells, and ally_buff grants an accumulating enemy attack bonus. All four are armed by applyAbility, read by the shared resolution primitives, and round-tripped through CombatStatuses for turn-based suspend/resume. New branches are guarded by zero-valued state so the auto-resolve characterization golden is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,8 +74,8 @@ func TestTurnAbilityFires(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestApplyAbility_Slice2Effects covers the immediate-resolution monster
|
// TestApplyAbility_Slice2Effects covers the immediate-resolution monster
|
||||||
// ability effects: damage riders, the enemy self-heal, and the flavor-only
|
// ability effects: damage riders and the enemy self-heal. All resolve fully
|
||||||
// placeholders. All resolve fully within applyAbility with no persistent state.
|
// within applyAbility with no persistent state.
|
||||||
func TestApplyAbility_Slice2Effects(t *testing.T) {
|
func TestApplyAbility_Slice2Effects(t *testing.T) {
|
||||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||||
st := &combatState{
|
st := &combatState{
|
||||||
@@ -123,19 +123,6 @@ func TestApplyAbility_Slice2Effects(t *testing.T) {
|
|||||||
t.Errorf("self_heal: enemy HP = %d, want in (30, %d]", stHeal.enemyHP, enH.Stats.MaxHP)
|
t.Errorf("self_heal: enemy HP = %d, want in (30, %d]", stHeal.enemyHP, enH.Stats.MaxHP)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flavor-only placeholders emit an event but change no HP.
|
|
||||||
for _, eff := range []string{"spell_resist", "reveal_action", "fear_immune", "ally_buff"} {
|
|
||||||
st, player, enemy := newState(100, 60)
|
|
||||||
enemy.Ability = &MonsterAbility{Name: "Test " + eff, Effect: eff}
|
|
||||||
applyAbility(st, &player, &enemy, phase, res)
|
|
||||||
if st.playerHP != 100 || st.enemyHP != 60 {
|
|
||||||
t.Errorf("%s: HP changed (%d/%d), want 100/60 (flavor-only)", eff, st.playerHP, st.enemyHP)
|
|
||||||
}
|
|
||||||
if len(st.events) != 1 || st.events[0].Action != "ability_flavor" {
|
|
||||||
t.Errorf("%s: want one ability_flavor event, got %+v", eff, st.events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A damage rider that drops the player returns true (no death save armed).
|
// A damage rider that drops the player returns true (no death save armed).
|
||||||
stKill, plK, enK := newState(1, 60)
|
stKill, plK, enK := newState(1, 60)
|
||||||
enK.Ability = &MonsterAbility{Name: "Smash", Effect: "bonus_damage"}
|
enK.Ability = &MonsterAbility{Name: "Smash", Effect: "bonus_damage"}
|
||||||
@@ -232,6 +219,80 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApplyAbility_Slice4Effects covers the former flavor-only placeholders,
|
||||||
|
// now backed by real state: applyAbility arms a combatState field and announces
|
||||||
|
// itself, and the shared resolution primitives / helpers read that state.
|
||||||
|
func TestApplyAbility_Slice4Effects(t *testing.T) {
|
||||||
|
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||||
|
st := &combatState{
|
||||||
|
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
||||||
|
rng: rand.New(rand.NewPCG(11, 11)),
|
||||||
|
}
|
||||||
|
return st, basePlayer(), baseEnemy()
|
||||||
|
}
|
||||||
|
phase := &turnCombatPhase
|
||||||
|
res := &CombatResult{}
|
||||||
|
|
||||||
|
armChecks := []struct {
|
||||||
|
effect string
|
||||||
|
armed func(*combatState) bool
|
||||||
|
}{
|
||||||
|
{"spell_resist", func(s *combatState) bool { return s.enemySpellResist }},
|
||||||
|
{"reveal_action", func(s *combatState) bool { return s.enemyRevealNext }},
|
||||||
|
{"fear_immune", func(s *combatState) bool { return s.enemyFearImmune }},
|
||||||
|
{"ally_buff", func(s *combatState) bool { return s.enemyAtkBuff > 0 }},
|
||||||
|
}
|
||||||
|
for _, c := range armChecks {
|
||||||
|
st, pl, en := newState(100, 60)
|
||||||
|
en.Ability = &MonsterAbility{Name: "Test " + c.effect, Effect: c.effect}
|
||||||
|
if applyAbility(st, &pl, &en, phase, res) {
|
||||||
|
t.Errorf("%s: should not down a full-HP player", c.effect)
|
||||||
|
}
|
||||||
|
if !c.armed(st) {
|
||||||
|
t.Errorf("%s: combatState field not armed", c.effect)
|
||||||
|
}
|
||||||
|
if st.playerHP != 100 || st.enemyHP != 60 {
|
||||||
|
t.Errorf("%s: HP changed (%d/%d), want 100/60", c.effect, st.playerHP, st.enemyHP)
|
||||||
|
}
|
||||||
|
if len(st.events) != 1 {
|
||||||
|
t.Errorf("%s: want one announcement event, got %d", c.effect, len(st.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The passive immunities are honoured straight off the ability profile,
|
||||||
|
// before the per-round proc has armed the flag.
|
||||||
|
st, _, en := newState(100, 60)
|
||||||
|
en.Ability = &MonsterAbility{Name: "Spell Immunity", Effect: "spell_resist"}
|
||||||
|
if !enemyResistsSpells(&en, st) {
|
||||||
|
t.Error("enemyResistsSpells: should be true from the ability profile alone")
|
||||||
|
}
|
||||||
|
en.Ability = &MonsterAbility{Name: "Draconic Loyalty", Effect: "fear_immune"}
|
||||||
|
if !enemyImmuneToControl(&en, st) {
|
||||||
|
t.Error("enemyImmuneToControl: should be true from the ability profile alone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ally_buff accumulates but is capped.
|
||||||
|
stB, plB, enB := newState(100, 60)
|
||||||
|
enB.Ability = &MonsterAbility{Name: "Rally", Effect: "ally_buff"}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
applyAbility(stB, &plB, &enB, phase, res)
|
||||||
|
}
|
||||||
|
if stB.enemyAtkBuff != 15 {
|
||||||
|
t.Errorf("ally_buff: accumulated to %d, want the 15 cap", stB.enemyAtkBuff)
|
||||||
|
}
|
||||||
|
if got := enemyAttackStat(&enB, stB, 1.0); got != enB.Stats.Attack+15 {
|
||||||
|
t.Errorf("enemyAttackStat: got %d, want %d (base + buff)", got, enB.Stats.Attack+15)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reveal_action is consumed by the next player weapon attack.
|
||||||
|
stR, plR, enR := newState(100, 60)
|
||||||
|
stR.enemyRevealNext = true
|
||||||
|
resolvePlayerAttack(stR, &plR, &enR, phase, res)
|
||||||
|
if stR.enemyRevealNext {
|
||||||
|
t.Error("reveal_action: flag should be cleared after the player attack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestTurnEngine_Multiattack drives a single enemy_turn and confirms a
|
// TestTurnEngine_Multiattack drives a single enemy_turn and confirms a
|
||||||
// registered multiattack creature resolves one attack roll per profile entry,
|
// registered multiattack creature resolves one attack roll per profile entry,
|
||||||
// while an unregistered creature still resolves exactly one.
|
// while an unregistered creature still resolves exactly one.
|
||||||
|
|||||||
@@ -202,9 +202,8 @@ type MonsterAbility struct {
|
|||||||
// enrage, armor_break, stun, bonus_damage, aoe, aoe_fire, death_aoe, execute,
|
// enrage, armor_break, stun, bonus_damage, aoe, aoe_fire, death_aoe, execute,
|
||||||
// self_heal, max_hp_drain); stateful effects armed here and read by the
|
// self_heal, max_hp_drain); stateful effects armed here and read by the
|
||||||
// shared resolution primitives (evade, block, advantage, retaliate,
|
// shared resolution primitives (evade, block, advantage, retaliate,
|
||||||
// regenerate, survive_at_1, stat_drain, debuff); flavor-only placeholders
|
// regenerate, survive_at_1, stat_drain, debuff, spell_resist, reveal_action,
|
||||||
// (spell_resist, reveal_action, fear_immune, ally_buff). Anything else is a
|
// fear_immune, ally_buff). Anything else is a silent no-op.
|
||||||
// silent no-op.
|
|
||||||
Effect string
|
Effect string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +300,13 @@ type combatState struct {
|
|||||||
playerACDebuff int // debuff: flat reduction to the player's effective AC
|
playerACDebuff int // debuff: flat reduction to the player's effective AC
|
||||||
maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP
|
maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP
|
||||||
|
|
||||||
|
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
|
||||||
|
// backed by real state.
|
||||||
|
enemySpellResist bool // spell_resist: player spell damage against this enemy is halved
|
||||||
|
enemyRevealNext bool // reveal_action: the player's next weapon attack is rolled at disadvantage
|
||||||
|
enemyFearImmune bool // fear_immune: player control spells (enemy-skip) fizzle against this enemy
|
||||||
|
enemyAtkBuff int // ally_buff: flat, accumulating bonus to the enemy's attack damage
|
||||||
|
|
||||||
round int
|
round int
|
||||||
events []CombatEvent
|
events []CombatEvent
|
||||||
|
|
||||||
@@ -392,6 +398,10 @@ func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *r
|
|||||||
// runs — the modifiers carry the resolved damage and narrative hook.
|
// runs — the modifiers carry the resolved damage and narrative hook.
|
||||||
if player.Mods.SpellPreDamageDesc != "" {
|
if player.Mods.SpellPreDamageDesc != "" {
|
||||||
dmg := player.Mods.SpellPreDamage
|
dmg := player.Mods.SpellPreDamage
|
||||||
|
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
|
||||||
|
if resisted {
|
||||||
|
dmg = max(1, dmg/2)
|
||||||
|
}
|
||||||
if dmg > 0 {
|
if dmg > 0 {
|
||||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||||
}
|
}
|
||||||
@@ -400,6 +410,12 @@ func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *r
|
|||||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
Desc: player.Mods.SpellPreDamageDesc,
|
Desc: player.Mods.SpellPreDamageDesc,
|
||||||
})
|
})
|
||||||
|
if resisted {
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
|
||||||
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
if st.enemyHP <= 0 {
|
if st.enemyHP <= 0 {
|
||||||
return finalize(result, st, player, enemy)
|
return finalize(result, st, player, enemy)
|
||||||
}
|
}
|
||||||
@@ -455,11 +471,20 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
|
|||||||
enemyHeldThisRound := false
|
enemyHeldThisRound := false
|
||||||
if st.enemySkipFirst {
|
if st.enemySkipFirst {
|
||||||
st.enemySkipFirst = false
|
st.enemySkipFirst = false
|
||||||
enemyHeldThisRound = true
|
if enemyImmuneToControl(enemy, st) {
|
||||||
st.events = append(st.events, CombatEvent{
|
// fear_immune: the control spell can't take hold — the enemy acts
|
||||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
|
// as normal this round.
|
||||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
st.events = append(st.events, CombatEvent{
|
||||||
})
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
|
||||||
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
enemyHeldThisRound = true
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
|
||||||
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monster ability: check at round start
|
// Monster ability: check at round start
|
||||||
@@ -665,6 +690,18 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
|
|||||||
}
|
}
|
||||||
|
|
||||||
roll := 1 + st.roll(20)
|
roll := 1 + st.roll(20)
|
||||||
|
// Reveal action (monster ability): the enemy read this swing coming — it's
|
||||||
|
// rolled at disadvantage (2d20, keep the lower). One-shot, consumed here.
|
||||||
|
if st.enemyRevealNext {
|
||||||
|
st.enemyRevealNext = false
|
||||||
|
if alt := 1 + st.roll(20); alt < roll {
|
||||||
|
roll = alt
|
||||||
|
}
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "revealed",
|
||||||
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
// Halfling Lucky: reroll the first nat 1 of the fight.
|
// Halfling Lucky: reroll the first nat 1 of the fight.
|
||||||
if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed {
|
if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed {
|
||||||
st.luckyUsed = true
|
st.luckyUsed = true
|
||||||
@@ -856,7 +893,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
|
|||||||
if st.enraged {
|
if st.enraged {
|
||||||
atkMult = 1.5
|
atkMult = 1.5
|
||||||
}
|
}
|
||||||
dmg := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||||
|
|
||||||
blocked := player.Stats.BlockRate > 0 && st.randFloat() < player.Stats.BlockRate
|
blocked := player.Stats.BlockRate > 0 && st.randFloat() < player.Stats.BlockRate
|
||||||
@@ -995,7 +1032,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
|
|||||||
if st.enraged {
|
if st.enraged {
|
||||||
atkMult = 1.5
|
atkMult = 1.5
|
||||||
}
|
}
|
||||||
dmg := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||||
dmg = max(1, dmg)
|
dmg = max(1, dmg)
|
||||||
st.playerHP = max(0, st.playerHP-dmg)
|
st.playerHP = max(0, st.playerHP-dmg)
|
||||||
@@ -1014,7 +1051,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
|
|||||||
if st.enraged {
|
if st.enraged {
|
||||||
atkMult = 1.5
|
atkMult = 1.5
|
||||||
}
|
}
|
||||||
dmg1 := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
dmg1 := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||||
dmg1 = max(1, dmg1)
|
dmg1 = max(1, dmg1)
|
||||||
st.playerHP = max(0, st.playerHP-dmg1)
|
st.playerHP = max(0, st.playerHP-dmg1)
|
||||||
@@ -1192,14 +1229,50 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
|
|||||||
return !trySave(st, player, phaseName)
|
return !trySave(st, player, phaseName)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "spell_resist", "reveal_action", "fear_immune", "ally_buff":
|
case "spell_resist":
|
||||||
// Slice-2 placeholder: no mechanical effect yet (these need persistent
|
// Spell Immunity: the enemy is wrapped in anti-magic. Player spell damage
|
||||||
// per-fight state, deferred to the next slice), but the ability still
|
// (the pre-combat cast in auto-resolve, mid-fight !cast in the turn engine)
|
||||||
// announces itself so the fight log doesn't silently swallow a proc.
|
// is halved — read by enemyResistsSpells. Arm once; it's a passive, so a
|
||||||
|
// repeat proc is a silent no-op.
|
||||||
|
if !st.enemySpellResist {
|
||||||
|
st.enemySpellResist = true
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_resist",
|
||||||
|
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reveal_action":
|
||||||
|
// The enemy reads the player's intent — their next weapon swing comes in
|
||||||
|
// at disadvantage (2d20 keep-lower). One-shot, consumed by the next
|
||||||
|
// resolvePlayerAttack. Re-armed (and re-announced) on every proc.
|
||||||
|
st.enemyRevealNext = true
|
||||||
st.events = append(st.events, CombatEvent{
|
st.events = append(st.events, CombatEvent{
|
||||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "ability_flavor",
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "reveal_armed",
|
||||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
case "fear_immune":
|
||||||
|
// The enemy's resolve can't be broken: player control spells (Hold Person,
|
||||||
|
// Sleep, Command — the enemy-skip mechanic) fizzle against it. Read by
|
||||||
|
// enemyImmuneToControl. Arm once; a passive, so a repeat proc is a no-op.
|
||||||
|
if !st.enemyFearImmune {
|
||||||
|
st.enemyFearImmune = true
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_immune",
|
||||||
|
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ally_buff":
|
||||||
|
// The enemy rallies — a flat, accumulating bonus to the damage its attacks
|
||||||
|
// deal (read by enemyAttackStat). Capped so a long fight can't run away.
|
||||||
|
buff := 2 + st.roll(3)
|
||||||
|
st.enemyAtkBuff = min(15, st.enemyAtkBuff+buff)
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "ally_buff",
|
||||||
|
Damage: buff, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@@ -1211,11 +1284,11 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
|
|||||||
// player's Defense applies (1.0 = a normal hit, lower = an armor-piercing
|
// player's Defense applies (1.0 = a normal hit, lower = an armor-piercing
|
||||||
// burst). Enrage's 1.5x attack multiplier is honored, matching cleave/lifesteal.
|
// burst). Enrage's 1.5x attack multiplier is honored, matching cleave/lifesteal.
|
||||||
func abilityHitDamage(st *combatState, player, enemy *Combatant, phase *CombatPhase, atkFrac, defFrac float64) int {
|
func abilityHitDamage(st *combatState, player, enemy *Combatant, phase *CombatPhase, atkFrac, defFrac float64) int {
|
||||||
atk := float64(enemy.Stats.Attack) * atkFrac
|
mult := atkFrac
|
||||||
if st.enraged {
|
if st.enraged {
|
||||||
atk *= 1.5
|
mult *= 1.5
|
||||||
}
|
}
|
||||||
dmg := calcDamage(st.rng, int(atk), phase.AttackWeight, enemy.Mods.DamageBonus,
|
dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, mult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||||
playerDefense(player, st), phase.DefenseWeight*defFrac, player.Mods.DamageReduct)
|
playerDefense(player, st), phase.DefenseWeight*defFrac, player.Mods.DamageReduct)
|
||||||
return max(1, dmg)
|
return max(1, dmg)
|
||||||
}
|
}
|
||||||
@@ -1251,6 +1324,29 @@ func playerDefense(player *Combatant, st *combatState) int {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enemyAttackStat is the enemy's effective Attack for a damage roll: the base
|
||||||
|
// stat scaled by mult (1.0 = normal, 1.5 = enraged, <1 = a partial-power rider)
|
||||||
|
// plus any accumulated ally_buff bonus. With no buff (every characterization
|
||||||
|
// scenario) it collapses to the pre-slice-4 expression.
|
||||||
|
func enemyAttackStat(enemy *Combatant, st *combatState, mult float64) int {
|
||||||
|
return max(1, int(float64(enemy.Stats.Attack)*mult)+st.enemyAtkBuff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enemyResistsSpells reports whether the enemy's spell_resist (Spell Immunity)
|
||||||
|
// ability is in play. It's a passive — the per-round proc may not have armed
|
||||||
|
// st.enemySpellResist yet, so the ability profile is checked too, letting the
|
||||||
|
// pre-combat spell and a round-1 mid-fight cast be resisted all the same.
|
||||||
|
func enemyResistsSpells(enemy *Combatant, st *combatState) bool {
|
||||||
|
return st.enemySpellResist || (enemy.Ability != nil && enemy.Ability.Effect == "spell_resist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// enemyImmuneToControl reports whether the enemy's fear_immune ability is in
|
||||||
|
// play. Like spell_resist it's a passive, so the ability profile is checked
|
||||||
|
// alongside the armed flag.
|
||||||
|
func enemyImmuneToControl(enemy *Combatant, st *combatState) bool {
|
||||||
|
return st.enemyFearImmune || (enemy.Ability != nil && enemy.Ability.Effect == "fear_immune")
|
||||||
|
}
|
||||||
|
|
||||||
// effPlayerMaxHP is the player's MaxHP after any max_hp_drain monster ability,
|
// effPlayerMaxHP is the player's MaxHP after any max_hp_drain monster ability,
|
||||||
// floored at 1. Heal clamps use this so a drained player can't be topped back
|
// floored at 1. Heal clamps use this so a drained player can't be topped back
|
||||||
// up past the lowered ceiling. With no drain (every characterization scenario)
|
// up past the lowered ceiling. With no drain (every characterization scenario)
|
||||||
|
|||||||
@@ -332,6 +332,22 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
|||||||
case "max_hp_drain":
|
case "max_hp_drain":
|
||||||
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
||||||
|
|
||||||
|
// Monster abilities — slice 4 (former flavor-only placeholders)
|
||||||
|
case "spell_resist":
|
||||||
|
return pickRand(narrativeSpellResist)
|
||||||
|
case "spell_fizzle":
|
||||||
|
return fmt.Sprintf(pickRand(narrativeSpellFizzle), e.Damage)
|
||||||
|
case "reveal_armed":
|
||||||
|
return pickRand(narrativeRevealArmed)
|
||||||
|
case "revealed":
|
||||||
|
return pickRand(narrativeRevealed)
|
||||||
|
case "fear_immune":
|
||||||
|
return pickRand(narrativeFearImmune)
|
||||||
|
case "fear_resist":
|
||||||
|
return pickRand(narrativeFearResist)
|
||||||
|
case "ally_buff":
|
||||||
|
return pickRand(narrativeAllyBuff)
|
||||||
|
|
||||||
case "timeout":
|
case "timeout":
|
||||||
return pickRand(narrativeTimeout)
|
return pickRand(narrativeTimeout)
|
||||||
|
|
||||||
@@ -743,6 +759,48 @@ var narrativeMaxHPDrain = []string{
|
|||||||
"☠️ %d HP siphoned away, max and all. There's less of you to work with now.",
|
"☠️ %d HP siphoned away, max and all. There's less of you to work with now.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var narrativeSpellResist = []string{
|
||||||
|
"🚫 The enemy's form shimmers with anti-magic. Spells are going to land soft from here.",
|
||||||
|
"🚫 A warding sheen wraps the enemy — magic slides off it like rain off glass.",
|
||||||
|
"🚫 The enemy shrugs the weave aside. Whatever you cast, it'll only half-stick.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var narrativeSpellFizzle = []string{
|
||||||
|
"🚫 Your spell breaks against the enemy's anti-magic — only %d damage bleeds through.",
|
||||||
|
"🚫 The weave fizzles on contact. The enemy's resistance eats half of it; %d lands.",
|
||||||
|
"🚫 Half your magic just evaporates. %d damage is all the enemy lets through.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var narrativeRevealArmed = []string{
|
||||||
|
"👁️ The enemy's eyes track your intent — it knows what you're about to do.",
|
||||||
|
"👁️ The enemy reads you. Your next swing is telegraphed before you've thrown it.",
|
||||||
|
"👁️ Something behind the enemy's gaze clicks. It's already seen your next move.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var narrativeRevealed = []string{
|
||||||
|
"👁️ The enemy saw it coming — your strike is forced wide before it begins.",
|
||||||
|
"👁️ Telegraphed and countered. The enemy was already moving as you committed.",
|
||||||
|
"👁️ Your swing meets a defence that read it a beat early. No clean angle left.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var narrativeFearImmune = []string{
|
||||||
|
"🐲 The enemy's resolve is iron — nothing you do is going to rattle it.",
|
||||||
|
"🐲 Whatever fear you were counting on, the enemy doesn't have the receptors for it.",
|
||||||
|
"🐲 The enemy stands utterly unshaken. Control magic is going to slide right off.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var narrativeFearResist = []string{
|
||||||
|
"🐲 Your control spell washes over the enemy and finds no purchase. It acts anyway.",
|
||||||
|
"🐲 The enemy shrugs off the compulsion mid-cast and keeps coming.",
|
||||||
|
"🐲 The spell should have held it. The enemy's will simply refuses the idea.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var narrativeAllyBuff = []string{
|
||||||
|
"📢 The enemy rallies itself with a roar — its blows are about to hit harder. (+%d attack)",
|
||||||
|
"📢 Something emboldens the enemy. Its strikes pick up weight. (+%d attack)",
|
||||||
|
"📢 The enemy steadies and surges. Expect heavier hits from here. (+%d attack)",
|
||||||
|
}
|
||||||
|
|
||||||
// Outcome flavor
|
// Outcome flavor
|
||||||
var narrativeNearDeathWin = []string{
|
var narrativeNearDeathWin = []string{
|
||||||
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ type CombatStatuses struct {
|
|||||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||||
MaxHPDrain int `json:"max_hp_drain,omitempty"`
|
MaxHPDrain int `json:"max_hp_drain,omitempty"`
|
||||||
|
|
||||||
|
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||||
|
// EnemyRevealNext is a one-shot; the other three persist for the fight.
|
||||||
|
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
|
||||||
|
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
|
||||||
|
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
|
||||||
|
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
|
||||||
|
|
||||||
// Persistent stat buffs from mid-fight !cast / !consume, accumulated as
|
// Persistent stat buffs from mid-fight !cast / !consume, accumulated as
|
||||||
// deltas against the freshly-rebuilt combatant. applySessionBuffs folds
|
// deltas against the freshly-rebuilt combatant. applySessionBuffs folds
|
||||||
// these back onto the player every round; diffTurnBuff produces them.
|
// these back onto the player every round; diffTurnBuff produces them.
|
||||||
|
|||||||
@@ -144,7 +144,12 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
|||||||
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
|
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
|
||||||
playerACDebuff: sess.Statuses.PlayerACDebuff,
|
playerACDebuff: sess.Statuses.PlayerACDebuff,
|
||||||
maxHPDrain: sess.Statuses.MaxHPDrain,
|
maxHPDrain: sess.Statuses.MaxHPDrain,
|
||||||
rng: rng,
|
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||||
|
enemySpellResist: sess.Statuses.EnemySpellResist,
|
||||||
|
enemyRevealNext: sess.Statuses.EnemyRevealNext,
|
||||||
|
enemyFearImmune: sess.Statuses.EnemyFearImmune,
|
||||||
|
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
|
||||||
|
rng: rng,
|
||||||
}
|
}
|
||||||
return &turnEngine{
|
return &turnEngine{
|
||||||
sess: sess,
|
sess: sess,
|
||||||
@@ -270,8 +275,20 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
te.sess.Phase = CombatPhaseEnemyTurn
|
te.sess.Phase = CombatPhaseEnemyTurn
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if eff.EnemyDamage > 0 {
|
action := eff.Action
|
||||||
st.enemyHP = max(0, st.enemyHP-eff.EnemyDamage)
|
if action == "" {
|
||||||
|
action = "spell_cast"
|
||||||
|
}
|
||||||
|
// spell_resist (Spell Immunity): a spell's damage against the enemy is
|
||||||
|
// halved. Consumables that happen to deal damage are not spells, so the
|
||||||
|
// resist is gated on the spell_cast action.
|
||||||
|
enemyDmg := eff.EnemyDamage
|
||||||
|
spellFizzled := enemyDmg > 0 && action == "spell_cast" && enemyResistsSpells(te.enemy, st)
|
||||||
|
if spellFizzled {
|
||||||
|
enemyDmg = max(1, enemyDmg/2)
|
||||||
|
}
|
||||||
|
if enemyDmg > 0 {
|
||||||
|
st.enemyHP = max(0, st.enemyHP-enemyDmg)
|
||||||
}
|
}
|
||||||
if eff.PlayerHeal > 0 {
|
if eff.PlayerHeal > 0 {
|
||||||
// Respect any max_hp_drain monster ability — a drained player can't be
|
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||||
@@ -279,14 +296,16 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
|
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
|
||||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||||
}
|
}
|
||||||
action := eff.Action
|
|
||||||
if action == "" {
|
|
||||||
action = "spell_cast"
|
|
||||||
}
|
|
||||||
st.events = append(st.events, CombatEvent{
|
st.events = append(st.events, CombatEvent{
|
||||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
|
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
|
||||||
Damage: eff.EnemyDamage, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
|
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
|
||||||
})
|
})
|
||||||
|
if spellFizzled {
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_fizzle",
|
||||||
|
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
if enemyDown(st, turnCombatPhase.Name) {
|
if enemyDown(st, turnCombatPhase.Name) {
|
||||||
te.finish(CombatStatusWon)
|
te.finish(CombatStatusWon)
|
||||||
return
|
return
|
||||||
@@ -296,21 +315,38 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if eff.EnemySkip {
|
if eff.EnemySkip {
|
||||||
st.enemySkipFirst = true
|
// fear_immune enemies shrug off control spells — the skip never arms.
|
||||||
|
if enemyImmuneToControl(te.enemy, st) {
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
|
||||||
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
st.enemySkipFirst = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
te.sess.Phase = CombatPhaseEnemyTurn
|
te.sess.Phase = CombatPhaseEnemyTurn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (te *turnEngine) stepEnemyTurn() {
|
func (te *turnEngine) stepEnemyTurn() {
|
||||||
// A control spell cast last phase forfeits the enemy's attack this round.
|
// A control spell cast last phase forfeits the enemy's attack this round —
|
||||||
|
// unless the enemy is fear_immune, in which case the control fizzled and it
|
||||||
|
// acts as normal.
|
||||||
if te.st.enemySkipFirst {
|
if te.st.enemySkipFirst {
|
||||||
te.st.enemySkipFirst = false
|
te.st.enemySkipFirst = false
|
||||||
te.st.events = append(te.st.events, CombatEvent{
|
if enemyImmuneToControl(te.enemy, te.st) {
|
||||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
te.st.events = append(te.st.events, CombatEvent{
|
||||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
|
||||||
})
|
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||||
te.sess.Phase = CombatPhaseRoundEnd
|
})
|
||||||
return
|
} else {
|
||||||
|
te.st.events = append(te.st.events, CombatEvent{
|
||||||
|
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
||||||
|
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||||
|
})
|
||||||
|
te.sess.Phase = CombatPhaseRoundEnd
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
|
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
|
||||||
@@ -458,6 +494,10 @@ func (te *turnEngine) commit() {
|
|||||||
s.PlayerAtkDrain = st.playerAtkDrain
|
s.PlayerAtkDrain = st.playerAtkDrain
|
||||||
s.PlayerACDebuff = st.playerACDebuff
|
s.PlayerACDebuff = st.playerACDebuff
|
||||||
s.MaxHPDrain = st.maxHPDrain
|
s.MaxHPDrain = st.maxHPDrain
|
||||||
|
s.EnemySpellResist = st.enemySpellResist
|
||||||
|
s.EnemyRevealNext = st.enemyRevealNext
|
||||||
|
s.EnemyFearImmune = st.enemyFearImmune
|
||||||
|
s.EnemyAtkBuff = st.enemyAtkBuff
|
||||||
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user