mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 10 SUB2a-ii: Battle Master + Assassin L5/L7
Battle Master:
- New "superiority" resource pool (4/long-rest at L5), provisioned via
initSubclassResources at !subclass selection.
- Three armed maneuvers fueled by superiority dice:
* Precision Attack — +d8 (≈+4) to first attack roll
* Tripping Attack — enemy skips first attack (reuses Phase 9
SpellEnemySkipFirst)
* Rally — +(d8 + CHA) HealItem at <50% HP
- L7 Know Your Enemy proxied as +1 AttackBonus passive.
Assassin:
- L5 Assassinate: AssassinateAdvantage (re-roll first miss, take better
of two d20s) + AssassinateBonusDmg = level (5 at L5) stacked on top
of the Rogue's existing Sneak Attack auto-crit.
- L7 Impostor bumps the bonus damage by +3.
- L5 Infiltration Expertise → +5 Deception; L7 Impostor → +10.
Engine:
- CombatModifiers gains FirstAttackBonus, AssassinateAdvantage,
AssassinateBonusDmg.
- resolvePlayerAttack consumes each on the first attack only via new
combatState one-shot flags.
Tests added: 14 covering passive gating (BM L7+, Assassin L5/L7),
maneuver Apply flags, superiority pool init for BM only, !arm gating
across class/subclass for precision_attack, Deception bonus tiers,
statistical lift from FirstAttackBonus and AssassinateBonusDmg in
SimulateCombat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,20 @@ type CombatModifiers struct {
|
||||
PhysicalResistRage bool // halve incoming physical damage while raging
|
||||
FrenzyDmgBonus float64 // multiplicative dmg bump while raging (Frenzy approximation)
|
||||
|
||||
// Phase 10 SUB2a-ii — Battle Master + Assassin.
|
||||
// FirstAttackBonus: flat bonus added to the player's first d20 attack
|
||||
// roll only (Battle Master Precision Attack ≈ +d8). Consumed on first
|
||||
// roll regardless of hit/miss (5e: "before or after rolling").
|
||||
// AssassinateAdvantage: re-roll the player's first miss (better of two
|
||||
// d20s on the first attack) — proxy for "advantage vs. creatures that
|
||||
// haven't acted yet".
|
||||
// AssassinateBonusDmg: flat extra damage stacked on the first hit (rides
|
||||
// the existing AutoCritFirst doubling — proxy for Death Strike's
|
||||
// "crits vs. surprised").
|
||||
FirstAttackBonus int
|
||||
AssassinateAdvantage bool
|
||||
AssassinateBonusDmg int
|
||||
|
||||
// Phase 9 — pending spell resolution. Set by applyPendingCast in
|
||||
// dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is
|
||||
// dealt as a pre-combat event with SpellPreDamageDesc as the narrative
|
||||
@@ -185,6 +199,11 @@ type combatState struct {
|
||||
// the enemy would otherwise attack).
|
||||
enemySkipFirst bool
|
||||
|
||||
// Phase 10 SUB2a-ii first-attack one-shots.
|
||||
firstAttackBonusUsed bool
|
||||
assassinateRerollUsed bool
|
||||
assassinateBonusUsed bool
|
||||
|
||||
round int
|
||||
events []CombatEvent
|
||||
}
|
||||
@@ -483,6 +502,21 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
|
||||
})
|
||||
roll = newRoll
|
||||
}
|
||||
// Phase 10 SUB2a-ii — Assassin advantage: re-roll the first attack of
|
||||
// the fight, take the better of two d20s. Models 5e's "advantage vs.
|
||||
// creatures that haven't acted yet" applied to the opening strike only.
|
||||
if player.Mods.AssassinateAdvantage && !st.assassinateRerollUsed {
|
||||
st.assassinateRerollUsed = true
|
||||
alt := 1 + rand.IntN(20)
|
||||
if alt > roll {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: "assassinate_advantage",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Roll: alt, RollAgainst: enemy.Stats.AC, Desc: "Assassinate",
|
||||
})
|
||||
roll = alt
|
||||
}
|
||||
}
|
||||
isFumble := roll == 1
|
||||
// Phase 10 SUB2a — Champion Improved Critical lowers the crit floor.
|
||||
// CritThreshold==0 means "use default" (nat 20). Champion sets 19.
|
||||
@@ -496,6 +530,12 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
|
||||
if player.Stats.Weapon != nil && !player.Stats.WeaponProficient {
|
||||
attackBonus -= 4
|
||||
}
|
||||
// Phase 10 SUB2a-ii — Battle Master Precision Attack: +d8 (modeled as
|
||||
// flat +4) to the first attack roll only. Consumed even on miss.
|
||||
if player.Mods.FirstAttackBonus > 0 && !st.firstAttackBonusUsed {
|
||||
attackBonus += player.Mods.FirstAttackBonus
|
||||
st.firstAttackBonusUsed = true
|
||||
}
|
||||
total := roll + attackBonus
|
||||
|
||||
if isFumble || (!isCritRoll && total < enemy.Stats.AC) {
|
||||
@@ -565,6 +605,15 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
|
||||
dmg = int(float64(dmg) * (1 + player.Mods.FrenzyDmgBonus))
|
||||
}
|
||||
}
|
||||
// Phase 10 SUB2a-ii — Assassin Death Strike proxy: bonus damage on the
|
||||
// first hit only. Stacks on top of the Rogue's Sneak Attack auto-crit
|
||||
// (which already doubled the base damage above) — the bonus itself is
|
||||
// applied AFTER the crit doubling so the math is "double base + flat
|
||||
// surprise damage". Consumed on first hit regardless of crit status.
|
||||
if player.Mods.AssassinateBonusDmg > 0 && !st.assassinateBonusUsed {
|
||||
dmg += player.Mods.AssassinateBonusDmg
|
||||
st.assassinateBonusUsed = true
|
||||
}
|
||||
dmg = max(1, dmg)
|
||||
|
||||
action := "hit"
|
||||
|
||||
@@ -152,6 +152,35 @@ func initResources(userID id.UserID, class DnDClass) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// subclassResourceMax — resources granted by a subclass on top of the class
|
||||
// pool. Phase 10 SUB2a-ii: Battle Master gets 4 superiority dice (5e
|
||||
// long-rest refresh in our model; spec says short-rest, but we keep parity
|
||||
// with the existing class pools' refresh cadence). Returns ("", 0) if the
|
||||
// subclass has no extra pool.
|
||||
func subclassResourceMax(sub DnDSubclass) (string, int) {
|
||||
switch sub {
|
||||
case SubclassBattleMaster:
|
||||
return "superiority", 4
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// initSubclassResources adds the subclass-specific resource pool. Called
|
||||
// from applySubclassChoice; idempotent. If a player switches subclasses,
|
||||
// the prior pool's row is left in place — refreshAllResources still touches
|
||||
// it but no ability references it once the subclass changes, so it's inert.
|
||||
func initSubclassResources(userID id.UserID, sub DnDSubclass) error {
|
||||
resType, max := subclassResourceMax(sub)
|
||||
if resType == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
INSERT OR IGNORE INTO dnd_resources (user_id, resource_type, current_value, max_value)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
string(userID), resType, max, max)
|
||||
return err
|
||||
}
|
||||
|
||||
// getResource returns (current, max). Returns (0, 0, true) if no row exists.
|
||||
func getResource(userID id.UserID, resType string) (int, int, error) {
|
||||
var cur, max int
|
||||
|
||||
@@ -163,6 +163,18 @@ func subclassSkillBonus(c *DnDCharacter, info dndSkillInfo) int {
|
||||
if c.Level >= 5 && info.Key == SkillSleightOfHand {
|
||||
return 5
|
||||
}
|
||||
case SubclassAssassin:
|
||||
// L5 Infiltration Expertise: crafting a false identity is a
|
||||
// Deception skill workout. L7 Impostor — perfect mimicry of voice
|
||||
// and manner — bumps the bonus further.
|
||||
if info.Key == SkillDeception {
|
||||
if c.Level >= 7 {
|
||||
return 10
|
||||
}
|
||||
if c.Level >= 5 {
|
||||
return 5
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -119,6 +119,11 @@ func (p *AdventurePlugin) applySubclassChoice(
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't save subclass choice: "+err.Error())
|
||||
}
|
||||
// Phase 10 SUB2a-ii — provision subclass-specific resource pools (e.g.
|
||||
// Battle Master superiority dice). Idempotent. Failure is non-fatal —
|
||||
// the player still gets the subclass; resources can be re-initialized
|
||||
// next long rest.
|
||||
_ = initSubclassResources(ctx.Sender, chosen)
|
||||
if charged {
|
||||
// Debit last; race vs. balance change is rare and strictly safer
|
||||
// to favor the player than risk taking euros without applying the
|
||||
|
||||
@@ -22,6 +22,27 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar
|
||||
if c.Level >= 5 {
|
||||
mods.CritThreshold = 19
|
||||
}
|
||||
case SubclassBattleMaster:
|
||||
// L7 Know Your Enemy: 5e gives factual info about the target after
|
||||
// 1 min observation. We don't model "observation time" — proxy the
|
||||
// tactical edge as a flat +1 to attack rolls from L7 onward.
|
||||
if c.Level >= 7 {
|
||||
stats.AttackBonus++
|
||||
}
|
||||
case SubclassAssassin:
|
||||
// L5 Assassinate: advantage on the opening strike + bonus damage
|
||||
// stacked on top of the Rogue's existing Sneak Attack auto-crit
|
||||
// (proxy for "crits vs. surprised targets"). Bonus scales with
|
||||
// level; L7 Impostor — flavored as deeper study of the mark — adds
|
||||
// a small surprise-damage bump.
|
||||
if c.Level >= 5 {
|
||||
mods.AssassinateAdvantage = true
|
||||
bonus := c.Level
|
||||
if c.Level >= 7 {
|
||||
bonus += 3
|
||||
}
|
||||
mods.AssassinateBonusDmg = bonus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +77,52 @@ func init() {
|
||||
mods.FrenzyDmgBonus = 0.5
|
||||
},
|
||||
}
|
||||
|
||||
// Phase 10 SUB2a-ii — Battle Master superiority-die maneuvers. Each
|
||||
// consumes one die from the new "superiority" resource pool (4/long-rest
|
||||
// at L5; long-rest refresh in our model — 5e refreshes on short rest).
|
||||
// We don't model 5e's interactive "before/after the roll" choice; a die
|
||||
// commits at !arm time and fires on the next combat.
|
||||
//
|
||||
// Three non-reaction maneuvers covered:
|
||||
// - Precision Attack → +d8 (≈+4 flat) on first attack roll
|
||||
// - Tripping Attack → enemy skips its first attack (reuses Phase 9
|
||||
// SpellEnemySkipFirst)
|
||||
// - Rally → temp-HP buffer at <50% (reuses HealItem; self
|
||||
// buff since one-shot combat has no ally-target UI)
|
||||
dndActiveAbilities["precision_attack"] = DnDAbility{
|
||||
ID: "precision_attack",
|
||||
Name: "Precision Attack",
|
||||
Class: ClassFighter,
|
||||
Subclass: SubclassBattleMaster,
|
||||
Resource: "superiority",
|
||||
Description: "Add +d8 (≈+4) to your first attack roll this combat (consumes 1 superiority die).",
|
||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||
mods.FirstAttackBonus += 4
|
||||
},
|
||||
}
|
||||
dndActiveAbilities["trip_attack"] = DnDAbility{
|
||||
ID: "trip_attack",
|
||||
Name: "Tripping Attack",
|
||||
Class: ClassFighter,
|
||||
Subclass: SubclassBattleMaster,
|
||||
Resource: "superiority",
|
||||
Description: "Trip the enemy on engagement: they skip their first attack (consumes 1 superiority die).",
|
||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||
mods.SpellEnemySkipFirst = true
|
||||
},
|
||||
}
|
||||
dndActiveAbilities["rally"] = DnDAbility{
|
||||
ID: "rally",
|
||||
Name: "Rally",
|
||||
Class: ClassFighter,
|
||||
Subclass: SubclassBattleMaster,
|
||||
Resource: "superiority",
|
||||
Description: "Steel yourself for the fight: heal at low HP for d8 + CHA mod (consumes 1 superiority die).",
|
||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||
mods.HealItem += 4 + abilityModifier(c.CHA)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// persistDnDPostCombatSubclass handles end-of-combat subclass bookkeeping.
|
||||
|
||||
@@ -375,6 +375,308 @@ func TestExhaustion_SchemaRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── SUB2a-ii — Battle Master + Assassin ─────────────────────────────────
|
||||
|
||||
func TestApplySubclassPassives_BattleMasterL7AttackBonus(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 7}
|
||||
stats := &CombatStats{AttackBonus: 3}
|
||||
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||
applySubclassPassives(stats, mods, c)
|
||||
if stats.AttackBonus != 4 {
|
||||
t.Errorf("L7 Battle Master AttackBonus = %d, want 4 (3 base + 1 Know Your Enemy)", stats.AttackBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySubclassPassives_BattleMasterPreL7NoBonus(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 6}
|
||||
stats := &CombatStats{AttackBonus: 3}
|
||||
applySubclassPassives(stats, &CombatModifiers{DamageReduct: 1.0}, c)
|
||||
if stats.AttackBonus != 3 {
|
||||
t.Errorf("L6 Battle Master AttackBonus = %d, want 3 (no Know Your Enemy yet)", stats.AttackBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySubclassPassives_AssassinL5(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 5}
|
||||
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||
applySubclassPassives(&CombatStats{}, mods, c)
|
||||
if !mods.AssassinateAdvantage {
|
||||
t.Error("L5 Assassin should set AssassinateAdvantage")
|
||||
}
|
||||
if mods.AssassinateBonusDmg != 5 {
|
||||
t.Errorf("L5 Assassin BonusDmg = %d, want 5", mods.AssassinateBonusDmg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySubclassPassives_AssassinL7Bumps(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 7}
|
||||
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||
applySubclassPassives(&CombatStats{}, mods, c)
|
||||
if mods.AssassinateBonusDmg != 10 {
|
||||
t.Errorf("L7 Assassin BonusDmg = %d, want 10 (level 7 + 3 Impostor)", mods.AssassinateBonusDmg)
|
||||
}
|
||||
}
|
||||
|
||||
// Manoeuvre Apply functions wire the right mods.
|
||||
|
||||
func TestPrecisionAttack_ApplySetsFirstAttackBonus(t *testing.T) {
|
||||
ab, ok := dndActiveAbilities["precision_attack"]
|
||||
if !ok {
|
||||
t.Fatal("precision_attack not registered")
|
||||
}
|
||||
if ab.Class != ClassFighter || ab.Subclass != SubclassBattleMaster {
|
||||
t.Errorf("gating: %s/%s, want fighter/battle_master", ab.Class, ab.Subclass)
|
||||
}
|
||||
if ab.Resource != "superiority" {
|
||||
t.Errorf("resource = %s, want superiority", ab.Resource)
|
||||
}
|
||||
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||
ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5}, mods)
|
||||
if mods.FirstAttackBonus != 4 {
|
||||
t.Errorf("FirstAttackBonus = %d, want 4", mods.FirstAttackBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTripAttack_ApplySetsEnemySkipFirst(t *testing.T) {
|
||||
ab := dndActiveAbilities["trip_attack"]
|
||||
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||
ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5}, mods)
|
||||
if !mods.SpellEnemySkipFirst {
|
||||
t.Error("Tripping Attack should set SpellEnemySkipFirst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRally_ApplySetsHealItem(t *testing.T) {
|
||||
ab := dndActiveAbilities["rally"]
|
||||
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||
// CHA 14 → +2 mod, expect HealItem += 6.
|
||||
ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5, CHA: 14}, mods)
|
||||
if mods.HealItem != 6 {
|
||||
t.Errorf("Rally HealItem = %d, want 6 (4 + CHA mod 2)", mods.HealItem)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resource init at subclass selection ──────────────────────────────────
|
||||
|
||||
func TestInitSubclassResources_BattleMasterGetsSuperiority(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@bm_init:example")
|
||||
if err := createAdvCharacter(uid, "bm_init"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := initSubclassResources(uid, SubclassBattleMaster); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cur, max, err := getResource(uid, "superiority")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cur != 4 || max != 4 {
|
||||
t.Errorf("superiority pool = %d/%d, want 4/4", cur, max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitSubclassResources_NonBattleMasterIsNoop(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@bm_noop:example")
|
||||
if err := createAdvCharacter(uid, "bm_noop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := initSubclassResources(uid, SubclassChampion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cur, max, err := getResource(uid, "superiority")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cur != 0 || max != 0 {
|
||||
t.Errorf("superiority pool for Champion = %d/%d, want 0/0", cur, max)
|
||||
}
|
||||
}
|
||||
|
||||
// !arm gating for subclass-only abilities.
|
||||
|
||||
func TestArmPrecisionAttack_BlockedForChampion(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@bm_block:example")
|
||||
if err := createAdvCharacter(uid, "bm_block"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassChampion,
|
||||
Level: 6, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := initResources(uid, ClassFighter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
if err := p.handleDnDArmCmd(MessageContext{Sender: uid}, "precision_attack"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.ArmedAbility == "precision_attack" {
|
||||
t.Error("non-Battle-Master should not be able to arm precision_attack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArmPrecisionAttack_AllowedForBattleMaster(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@bm_ok:example")
|
||||
if err := createAdvCharacter(uid, "bm_ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBattleMaster,
|
||||
Level: 5, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := initSubclassResources(uid, SubclassBattleMaster); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
if err := p.handleDnDArmCmd(MessageContext{Sender: uid}, "precision_attack"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.ArmedAbility != "precision_attack" {
|
||||
t.Errorf("ArmedAbility = %q, want precision_attack", got.ArmedAbility)
|
||||
}
|
||||
cur, _, _ := getResource(uid, "superiority")
|
||||
if cur != 3 {
|
||||
t.Errorf("superiority after arm = %d, want 3", cur)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skill bonuses ────────────────────────────────────────────────────────
|
||||
|
||||
func TestSubclassSkillBonus_AssassinDeception(t *testing.T) {
|
||||
dec, _ := skillInfo(SkillDeception)
|
||||
persuasion, _ := skillInfo(SkillPersuasion)
|
||||
|
||||
l5 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 5}
|
||||
if got := subclassSkillBonus(l5, dec); got != 5 {
|
||||
t.Errorf("L5 Assassin Deception = %d, want 5", got)
|
||||
}
|
||||
if got := subclassSkillBonus(l5, persuasion); got != 0 {
|
||||
t.Errorf("L5 Assassin Persuasion = %d, want 0 (Infiltration is Deception only)", got)
|
||||
}
|
||||
|
||||
l7 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 7}
|
||||
if got := subclassSkillBonus(l7, dec); got != 10 {
|
||||
t.Errorf("L7 Assassin Deception = %d, want 10 (Impostor)", got)
|
||||
}
|
||||
|
||||
l4 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 4}
|
||||
if got := subclassSkillBonus(l4, dec); got != 0 {
|
||||
t.Errorf("L4 Assassin Deception = %d, want 0 (subclass abilities gate at L5)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── End-to-end: one-shot first-attack effects ────────────────────────────
|
||||
|
||||
func bmPrecisionPlayer() Combatant {
|
||||
return Combatant{
|
||||
Name: "BM", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 100, Attack: 15, Defense: 8, Speed: 10, AttackBonus: 5, AC: 16,
|
||||
CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05,
|
||||
},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0, FirstAttackBonus: 4},
|
||||
}
|
||||
}
|
||||
|
||||
func plainBMPlayer() Combatant {
|
||||
c := bmPrecisionPlayer()
|
||||
c.Mods.FirstAttackBonus = 0
|
||||
return c
|
||||
}
|
||||
|
||||
// Probabilistic: a stiff +4 on the opening swing should noticeably lift
|
||||
// hit-rate against an enemy AC tuned to mostly-shrug the baseline player.
|
||||
func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
|
||||
const trials = 1500
|
||||
hardEnemy := Combatant{
|
||||
Name: "Wall",
|
||||
Stats: CombatStats{
|
||||
MaxHP: 80, Attack: 10, Defense: 6, Speed: 6, AC: 22, AttackBonus: 4,
|
||||
CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05,
|
||||
},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
}
|
||||
plainWins, bmWins := 0, 0
|
||||
for i := 0; i < trials; i++ {
|
||||
if SimulateCombat(plainBMPlayer(), hardEnemy, defaultCombatPhases).PlayerWon {
|
||||
plainWins++
|
||||
}
|
||||
if SimulateCombat(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases).PlayerWon {
|
||||
bmWins++
|
||||
}
|
||||
}
|
||||
if bmWins-plainWins < 25 {
|
||||
t.Errorf("Precision Attack lift too small: plain=%d bm=%d", plainWins, bmWins)
|
||||
}
|
||||
}
|
||||
|
||||
// Surface check: AssassinateBonusDmg only consumed once.
|
||||
func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
|
||||
// Drive resolvePlayerAttack via a SimulateCombat run and confirm the
|
||||
// total enemy damage taken on hit is the *base* damage on subsequent
|
||||
// hits (i.e. only the first hit got the +bonus). Statistical compare
|
||||
// against an Assassin L5 vs. an identical player without the bonus.
|
||||
const trials = 800
|
||||
build := func(bonus int) Combatant {
|
||||
return Combatant{
|
||||
Name: "Assn", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 100, Attack: 15, Defense: 8, Speed: 10, AttackBonus: 5, AC: 16,
|
||||
CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05,
|
||||
},
|
||||
Mods: CombatModifiers{
|
||||
DamageReduct: 1.0, AutoCritFirst: true, AssassinateBonusDmg: bonus,
|
||||
},
|
||||
}
|
||||
}
|
||||
enemy := func() Combatant {
|
||||
return Combatant{
|
||||
Name: "Mark",
|
||||
Stats: CombatStats{
|
||||
MaxHP: 80, Attack: 12, Defense: 8, Speed: 8, AC: 14, AttackBonus: 3,
|
||||
CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05,
|
||||
},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
}
|
||||
}
|
||||
plainWins, assnWins := 0, 0
|
||||
for i := 0; i < trials; i++ {
|
||||
if SimulateCombat(build(0), enemy(), defaultCombatPhases).PlayerWon {
|
||||
plainWins++
|
||||
}
|
||||
if SimulateCombat(build(8), enemy(), defaultCombatPhases).PlayerWon {
|
||||
assnWins++
|
||||
}
|
||||
}
|
||||
if assnWins <= plainWins {
|
||||
t.Errorf("Assassinate bonus damage didn't help: plain=%d assn=%d", plainWins, assnWins)
|
||||
}
|
||||
}
|
||||
|
||||
// Surface-level sanity: rage shows up in characterActiveAbilities for a
|
||||
// Berserker but not a Champion. Tests the gating helper directly so we
|
||||
// don't need the resource-pool DB read that renderArmList does.
|
||||
|
||||
Reference in New Issue
Block a user