mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
WIP: in-flight combat/expedition/flavor changes
Bundle of uncommitted working-tree edits across combat engine, expedition cycle, flavor pools, and TwinBee/zone narration. Includes new files: combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus CHANGES_24H.md and REBALANCE_NOTES.md scratch notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,20 +2,18 @@ package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 2 — D&D combat layer hookup.
|
||||
// D&D combat layer.
|
||||
//
|
||||
// Phase 2 strategy: keep legacy HP/damage/dodge-rate scaling intact. The D&D
|
||||
// layer adds AC + d20-vs-AC hit resolution on top. This preserves the
|
||||
// existing balance while making combat read as D&D for opted-in players.
|
||||
//
|
||||
// HP rescaling and condition-system overhaul are deferred to later phases.
|
||||
// applyDnDPlayerLayer reseats stats.MaxHP onto the dnd_character HP scale
|
||||
// (c.HPMax + the gear/arena/housing bonus DerivePlayerStats captured in
|
||||
// HPBonus). Wound carry-over via applyDnDHPScaling is a direct integer
|
||||
// copy — there is no longer a percentage bridge between scales.
|
||||
|
||||
// ── Tunable constants ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,12 +78,15 @@ func dndPlayerAttackBonus(c *DnDCharacter) int {
|
||||
return classAttackStatMod(c) + proficiencyBonus(c.Level) + dndClassWeaponBonus[c.Class]
|
||||
}
|
||||
|
||||
// applyDnDPlayerLayer sets AC and AttackBonus on a stat block from the
|
||||
// player's D&D character. Called after DerivePlayerStats has populated
|
||||
// HP/Attack/Defense/etc. Phase 8: also wires equipped weapon/armor profiles
|
||||
// into CombatStats so the d20 attack path uses real weapon dice and AC
|
||||
// computation per gogobee_equipment_appendix.md.
|
||||
// applyDnDPlayerLayer sets HP/AC/AttackBonus on a stat block from the
|
||||
// player's D&D character. Combat MaxHP is the sheet HPMax plus the
|
||||
// equipment/arena/housing bonus DerivePlayerStats accumulated into
|
||||
// stats.HPBonus — so dnd_character.hp_current can be the single source of
|
||||
// truth for wounds while gear keeps adding flat HP to the fight.
|
||||
//
|
||||
// Phase 8 wires equipped weapon/armor profiles in via applyDnDEquipmentLayer.
|
||||
func applyDnDPlayerLayer(stats *CombatStats, c *DnDCharacter) {
|
||||
stats.MaxHP = c.HPMax + stats.HPBonus
|
||||
stats.AC = c.ArmorClass
|
||||
stats.AttackBonus = dndPlayerAttackBonus(c)
|
||||
}
|
||||
@@ -176,15 +177,23 @@ func applyDnDDungeonMonsterLayer(stats *CombatStats, tier int) {
|
||||
// (which is allowed without respec cooldown when auto_migrated=1).
|
||||
func classStatPriority(class DnDClass) [6]int {
|
||||
// Returned array is in STR, DEX, CON, INT, WIS, CHA order.
|
||||
//
|
||||
// Design note: every class gets DEX ≥ 14 baseline. DEX drives AC (via
|
||||
// armor synthesis) and initiative, so dumping it leaves players in
|
||||
// armor dead-zones where T3 chain shirt provides no AC over their
|
||||
// class floor. The previous Cleric DEX=10 / Mage DEX=12 spread was
|
||||
// faithful to standard array prioritization but produced unfun
|
||||
// outcomes — gear upgrades didn't visibly help. Pumping the floor
|
||||
// trades canonical purity for "armor upgrades feel like upgrades."
|
||||
switch class {
|
||||
case ClassFighter:
|
||||
return [6]int{15, 13, 14, 8, 12, 10} // STR, CON, DEX prioritized
|
||||
return [6]int{15, 14, 13, 8, 12, 10} // STR, DEX, CON
|
||||
case ClassRogue:
|
||||
return [6]int{8, 15, 13, 14, 10, 12} // DEX, INT, CON
|
||||
case ClassMage:
|
||||
return [6]int{8, 12, 13, 15, 14, 10} // INT, WIS, CON
|
||||
return [6]int{8, 14, 13, 15, 12, 10} // INT, DEX, CON
|
||||
case ClassCleric:
|
||||
return [6]int{12, 10, 13, 8, 15, 14} // WIS, CHA, CON
|
||||
return [6]int{12, 14, 13, 8, 15, 10} // WIS, DEX, CON
|
||||
case ClassRanger:
|
||||
return [6]int{12, 15, 13, 10, 14, 8} // DEX, WIS, CON
|
||||
}
|
||||
@@ -280,6 +289,7 @@ func autoBuildCharacter(userID id.UserID, char *AdventureCharacter) *DnDCharacte
|
||||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||||
c.ShortRestCharges = c.Level
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -406,79 +416,42 @@ func formatN(n int, word string) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ── Combat HP scaling (rest teeth) ───────────────────────────────────────────
|
||||
// ── Combat HP carry-over ─────────────────────────────────────────────────────
|
||||
|
||||
// dndWoundFloor — when scaling combat MaxHP from sheet HP%, never reduce
|
||||
// below this fraction of the legacy max. Prevents one-shot deaths when the
|
||||
// sheet is at 0 HP.
|
||||
const dndWoundFloor = 0.25
|
||||
|
||||
// applyDnDHPScaling scales playerStats.MaxHP based on the player's current
|
||||
// dnd_character HP fraction. A fully-rested player fights at full legacy HP;
|
||||
// a wounded player fights at reduced HP, with a floor at dndWoundFloor.
|
||||
//
|
||||
// This is what makes !rest mechanically meaningful — without it, the rest
|
||||
// system is purely cosmetic.
|
||||
// applyDnDHPScaling sets stats.StartHP from the player's persisted wounds.
|
||||
// A full-HP character enters at MaxHP (StartHP=0 means "use MaxHP"); a
|
||||
// wounded character enters at c.HPCurrent + HPBonus — the persistent dnd
|
||||
// wound layered with the fresh equipment cushion. There is no scale
|
||||
// conversion; persistDnDHPAfterCombat is the inverse direct copy.
|
||||
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
|
||||
if c == nil || c.HPMax <= 0 {
|
||||
if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax {
|
||||
return
|
||||
}
|
||||
pct := float64(c.HPCurrent) / float64(c.HPMax)
|
||||
if pct >= 1.0 {
|
||||
return
|
||||
startHP := c.HPCurrent + stats.HPBonus
|
||||
if startHP < 1 {
|
||||
startHP = 1
|
||||
}
|
||||
if pct < dndWoundFloor {
|
||||
pct = dndWoundFloor
|
||||
if startHP > stats.MaxHP {
|
||||
startHP = stats.MaxHP
|
||||
}
|
||||
// Round half-up. Naive int truncation loses up to 1 HP on every
|
||||
// round-trip through the dndChar scale (which is coarser than the
|
||||
// legacy combat scale): 101/123 → persist as 64/78 → restore as
|
||||
// int(100.92) = 100, silently dropping a HP between fights.
|
||||
scaled := int(math.Round(float64(stats.MaxHP) * pct))
|
||||
if scaled < 1 {
|
||||
scaled = 1
|
||||
}
|
||||
if scaled > stats.MaxHP {
|
||||
scaled = stats.MaxHP
|
||||
}
|
||||
// Set StartHP rather than overwriting MaxHP. The combat engine reads
|
||||
// StartHP as the entry-HP, but display denominators (e.g. "101/123")
|
||||
// keep using MaxHP — so wounded carry-over reads "wounded out of full"
|
||||
// rather than "full out of shrunk-max", which previously made wound
|
||||
// state invisible across battles.
|
||||
stats.StartHP = scaled
|
||||
stats.StartHP = startHP
|
||||
}
|
||||
|
||||
// ── HP persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
// persistDnDHPAfterCombat updates dnd_character.hp_current to reflect wounds
|
||||
// from a fight, scaled to the D&D HP scale (since combat uses legacy HP).
|
||||
//
|
||||
// We compute the % of legacy HP the player ended with and apply the same %
|
||||
// to dnd_character.hp_max. This keeps the player's "displayed health" in
|
||||
// the sheet honest even though the combat engine itself uses legacy HP.
|
||||
//
|
||||
// No-op if the player has no dnd_character row or hasn't completed setup.
|
||||
func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) {
|
||||
// persistDnDHPAfterCombat copies endHP into dnd_character.hp_current,
|
||||
// clamped to [0, c.HPMax]. The HPBonus from gear is fight-only and does
|
||||
// not carry over — anything above c.HPMax is treated as unused armor
|
||||
// cushion and clamped down. No-op if the row is missing or pending setup.
|
||||
func persistDnDHPAfterCombat(userID id.UserID, endHP int) {
|
||||
c, err := LoadDnDCharacter(userID)
|
||||
if err != nil || c == nil || c.PendingSetup {
|
||||
return
|
||||
}
|
||||
if legacyStartHP <= 0 || c.HPMax <= 0 {
|
||||
if c.HPMax <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
pct := float64(legacyEndHP) / float64(legacyStartHP)
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 1 {
|
||||
pct = 1
|
||||
}
|
||||
|
||||
// Round half-up to mirror applyDnDHPScaling — a fight that ends at
|
||||
// 101/123 should round-trip cleanly through the dndChar (78-scale)
|
||||
// store and come back as 101, not 100.
|
||||
newHP := int(math.Round(float64(c.HPMax) * pct))
|
||||
newHP := endHP
|
||||
if newHP < 0 {
|
||||
newHP = 0
|
||||
}
|
||||
@@ -494,11 +467,10 @@ func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) {
|
||||
}
|
||||
}
|
||||
|
||||
// dndHPSnapshot returns the user's D&D-scale (current, max) HP. Caller
|
||||
// captures pre-combat values via this helper, runs combat (which calls
|
||||
// persistDnDHPAfterCombat internally), then re-snapshots for the post
|
||||
// values. Used so combat outcome narration shows sheet HP rather than
|
||||
// the legacy combat-engine HP scale.
|
||||
// dndHPSnapshot returns the user's persisted (current, max) HP. Combat
|
||||
// callers snapshot pre-combat, run the fight (persistDnDHPAfterCombat
|
||||
// updates hp_current), then re-snapshot for the post values to render
|
||||
// the sheet's wound state in narration.
|
||||
func dndHPSnapshot(userID id.UserID) (cur, max int) {
|
||||
c, err := LoadDnDCharacter(userID)
|
||||
if err != nil || c == nil {
|
||||
|
||||
Reference in New Issue
Block a user