mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
Compare commits
4 Commits
d7ced471a1
...
4934383a9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4934383a9a | ||
|
|
a46b773750 | ||
|
|
b80de43db1 | ||
|
|
81dda51907 |
@@ -220,6 +220,10 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
// migration walks dnd_character once at startup; idempotent via
|
// migration walks dnd_character once at startup; idempotent via
|
||||||
// JobCompleted gate.
|
// JobCompleted gate.
|
||||||
bootstrapPhase5BHPRefresh()
|
bootstrapPhase5BHPRefresh()
|
||||||
|
// J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh
|
||||||
|
// existing caster rows once at startup so the lift reaches live
|
||||||
|
// players without waiting for level-up.
|
||||||
|
bootstrapCasterHPRefresh()
|
||||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ var srdProfiles = map[string]SRDProfile{
|
|||||||
{Name: "Scourge", AttackBonus: 9, Damage: 13},
|
{Name: "Scourge", AttackBonus: 9, Damage: 13},
|
||||||
{Name: "Scourge", AttackBonus: 9, Damage: 12},
|
{Name: "Scourge", AttackBonus: 9, Damage: 12},
|
||||||
}},
|
}},
|
||||||
"boss_thornmother": {Attacks: []SRDAttack{ // Attack 18 → ~23
|
"boss_thornmother": {Attacks: []SRDAttack{ // D8-f #2: 2→3 lashes, ~23→~36 (faceroll → band)
|
||||||
{Name: "Thorned Lash", AttackBonus: 8, Damage: 12},
|
{Name: "Thorned Lash", AttackBonus: 9, Damage: 13},
|
||||||
{Name: "Thorned Lash", AttackBonus: 8, Damage: 11},
|
{Name: "Thorned Lash", AttackBonus: 9, Damage: 12},
|
||||||
|
{Name: "Thorned Lash", AttackBonus: 9, Damage: 11},
|
||||||
}},
|
}},
|
||||||
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49
|
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49
|
||||||
{Name: "Bite", AttackBonus: 11, Damage: 19},
|
{Name: "Bite", AttackBonus: 11, Damage: 19},
|
||||||
@@ -169,6 +170,24 @@ var srdProfiles = map[string]SRDProfile{
|
|||||||
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
|
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
|
||||||
{Name: "Bite", AttackBonus: 6, Damage: 4},
|
{Name: "Bite", AttackBonus: 6, Damage: 4},
|
||||||
}},
|
}},
|
||||||
|
|
||||||
|
// ── Feywild elites (D8-f #2) ─────────────────────────────────────────
|
||||||
|
// Feywild martials facerolled at 97–100% even after an HP/AC raise: the
|
||||||
|
// roster was single-attack and low-damage, so tankier monsters just took
|
||||||
|
// longer to kill. Multiattack is the lever that actually pulls leaders
|
||||||
|
// into band (mirrors underdark's drow_elite). Casters trail (by design).
|
||||||
|
"fomorian": {Attacks: []SRDAttack{ // Attack 13 → ~21 (2 big fists)
|
||||||
|
{Name: "Greatclub", AttackBonus: 9, Damage: 11},
|
||||||
|
{Name: "Greatclub", AttackBonus: 9, Damage: 10},
|
||||||
|
}},
|
||||||
|
"night_hag": {Attacks: []SRDAttack{ // Attack 8 → ~12 (claw flurry)
|
||||||
|
{Name: "Claws", AttackBonus: 7, Damage: 6},
|
||||||
|
{Name: "Claws", AttackBonus: 7, Damage: 6},
|
||||||
|
}},
|
||||||
|
"green_hag": {Attacks: []SRDAttack{ // Attack 6 → ~10 (claw flurry)
|
||||||
|
{Name: "Claws", AttackBonus: 6, Damage: 5},
|
||||||
|
{Name: "Claws", AttackBonus: 6, Damage: 5},
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
// enemyAttackProfile returns the attack rolls a creature makes on its turn.
|
// enemyAttackProfile returns the attack rolls a creature makes on its turn.
|
||||||
|
|||||||
84
internal/plugin/bootstrap_caster_hp.go
Normal file
84
internal/plugin/bootstrap_caster_hp.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootstrapCasterHPRefresh recomputes hp_max for caster characters after
|
||||||
|
// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped.
|
||||||
|
// Without this, existing caster rows keep their pre-lift hp_max until
|
||||||
|
// the next computeMaxHP recall (level-up / reset). Mirrors
|
||||||
|
// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the
|
||||||
|
// absolute wound (delta added to hp_current, capped at new max). Run
|
||||||
|
// once per startup, idempotent via db.JobCompleted.
|
||||||
|
func bootstrapCasterHPRefresh() {
|
||||||
|
const jobName = "caster_hp_refresh_v1"
|
||||||
|
if db.JobCompleted(jobName, "once") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT user_id, class, con_score, dnd_level, hp_max, hp_current
|
||||||
|
FROM dnd_character WHERE dnd_level > 0`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("caster hp refresh: enumerate failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
userID string
|
||||||
|
class DnDClass
|
||||||
|
conScore int
|
||||||
|
level int
|
||||||
|
hpMax int
|
||||||
|
hpCurrent int
|
||||||
|
}
|
||||||
|
var batch []row
|
||||||
|
for rows.Next() {
|
||||||
|
var r row
|
||||||
|
var classStr string
|
||||||
|
if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil {
|
||||||
|
slog.Warn("caster hp refresh: scan failed", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.class = DnDClass(classStr)
|
||||||
|
batch = append(batch, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshed := 0
|
||||||
|
for _, r := range batch {
|
||||||
|
if casterHPMult(r.class) == 1.0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conMod := abilityModifier(r.conScore)
|
||||||
|
newMax := computeMaxHP(r.class, conMod, r.level)
|
||||||
|
if newMax <= r.hpMax {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delta := newMax - r.hpMax
|
||||||
|
newCurrent := r.hpCurrent + delta
|
||||||
|
if newCurrent > newMax {
|
||||||
|
newCurrent = newMax
|
||||||
|
}
|
||||||
|
if newCurrent < 1 {
|
||||||
|
newCurrent = 1
|
||||||
|
}
|
||||||
|
if _, err := d.Exec(`UPDATE dnd_character
|
||||||
|
SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
newMax, newCurrent, r.userID); err != nil {
|
||||||
|
slog.Warn("caster hp refresh: update failed", "user", r.userID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refreshed++
|
||||||
|
}
|
||||||
|
|
||||||
|
db.MarkJobCompleted(jobName, "once")
|
||||||
|
if refreshed > 0 {
|
||||||
|
slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,19 @@ func encounterIDForRoom(roomIdx int) string {
|
|||||||
return fmt.Sprintf("room%d", roomIdx)
|
return fmt.Sprintf("room%d", roomIdx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replyDM sends a player-facing combat reply unless ctx.Silent is set. The
|
||||||
|
// turn-engine combat commands route all their DMs through here so the
|
||||||
|
// background autopilot can drive a boss/elite fight on the real engine
|
||||||
|
// (long-expedition D8-f) without spamming the player a DM per round — the
|
||||||
|
// state mutations (HP, XP, threat, run-clear) still happen; only the
|
||||||
|
// narration is dropped. Non-silent callers (manual !fight) are unchanged.
|
||||||
|
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
|
||||||
|
if ctx.Silent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, text)
|
||||||
|
}
|
||||||
|
|
||||||
// ── !fight ──────────────────────────────────────────────────────────────────
|
// ── !fight ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||||
@@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
|||||||
|
|
||||||
run, err := getActiveZoneRun(ctx.Sender)
|
run, err := getActiveZoneRun(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
|
||||||
}
|
}
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.")
|
return p.replyDM(ctx, "No active zone run. Use `!zone enter <id>` first.")
|
||||||
}
|
}
|
||||||
roomType := run.CurrentRoomType()
|
roomType := run.CurrentRoomType()
|
||||||
if roomType != RoomElite && roomType != RoomBoss {
|
if roomType != RoomElite && roomType != RoomBoss {
|
||||||
return p.SendDM(ctx.Sender, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
|
return p.replyDM(ctx, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
|
||||||
}
|
}
|
||||||
|
|
||||||
encID := encounterIDForRoom(run.CurrentRoom)
|
encID := encounterIDForRoom(run.CurrentRoom)
|
||||||
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
|
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
|
||||||
switch existing.Status {
|
switch existing.Status {
|
||||||
case CombatStatusActive:
|
case CombatStatusActive:
|
||||||
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
|
return p.replyDM(ctx, "You're already in this fight — `!attack` or `!flee`.")
|
||||||
case CombatStatusWon:
|
case CombatStatusWon:
|
||||||
return p.SendDM(ctx.Sender, "You've already cleared this room. "+continueHint(ctx.Sender))
|
return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender))
|
||||||
default:
|
default:
|
||||||
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
|
return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,26 +81,26 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
|||||||
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
|
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
|
return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
|
||||||
}
|
}
|
||||||
|
|
||||||
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
|
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error())
|
return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
|
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
|
||||||
if playerHP <= 0 {
|
if playerHP <= 0 {
|
||||||
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.")
|
return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.")
|
||||||
}
|
}
|
||||||
enemyHP := enemy.Stats.MaxHP
|
enemyHP := enemy.Stats.MaxHP
|
||||||
|
|
||||||
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
|
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == ErrCombatSessionAlreadyActive {
|
if err == ErrCombatSessionAlreadyActive {
|
||||||
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.")
|
return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error())
|
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
|
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
|
||||||
@@ -118,7 +131,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(combatTurnPrompt(sess))
|
b.WriteString(combatTurnPrompt(sess))
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.replyDM(ctx, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── !attack / !flee ─────────────────────────────────────────────────────────
|
// ── !attack / !flee ─────────────────────────────────────────────────────────
|
||||||
@@ -138,22 +151,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
|
|||||||
|
|
||||||
sess, err := getActiveCombatSession(ctx.Sender)
|
sess, err := getActiveCombatSession(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
|
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||||
}
|
}
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
|
return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
|
||||||
}
|
}
|
||||||
|
|
||||||
player, enemy, err := p.combatantsForSession(sess)
|
player, enemy, err := p.combatantsForSession(sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
events, err := runCombatRound(sess, &player, &enemy, action)
|
events, err := runCombatRound(sess, &player, &enemy, action)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderRoundResult turns a resolved round into the player-facing block: the
|
// renderRoundResult turns a resolved round into the player-facing block: the
|
||||||
@@ -393,35 +406,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
|||||||
|
|
||||||
sess, err := getActiveCombatSession(ctx.Sender)
|
sess, err := getActiveCombatSession(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
|
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||||
}
|
}
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
// Race: the fight closed between the route check and the lock.
|
// Race: the fight closed between the route check and the lock.
|
||||||
return p.SendDM(ctx.Sender, "You're not in a fight anymore.")
|
return p.replyDM(ctx, "You're not in a fight anymore.")
|
||||||
}
|
}
|
||||||
|
|
||||||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||||||
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
||||||
if err != nil || c == nil {
|
if err != nil || c == nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.")
|
||||||
}
|
}
|
||||||
if !isSpellcaster(c) {
|
if !isSpellcaster(c) {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.replyDM(ctx, fmt.Sprintf(
|
||||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
|
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
|
||||||
}
|
}
|
||||||
|
|
||||||
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
|
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
return p.SendDM(ctx.Sender, errMsg)
|
return p.replyDM(ctx, errMsg)
|
||||||
}
|
}
|
||||||
if spell.Effect == EffectReaction {
|
if spell.Effect == EffectReaction {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.replyDM(ctx, fmt.Sprintf(
|
||||||
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
|
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
player, enemy, err := p.combatantsForSession(sess)
|
player, enemy, err := p.combatantsForSession(sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
var eff *turnActionEffect
|
var eff *turnActionEffect
|
||||||
@@ -433,11 +446,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
|||||||
applySpellBuff(spell, c, &as, &am, slotLevel)
|
applySpellBuff(spell, c, &as, &am, slotLevel)
|
||||||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||||||
if !d.any() {
|
if !d.any() {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.replyDM(ctx, fmt.Sprintf(
|
||||||
"%s has no effect the turn-based engine can apply yet.", spell.Name))
|
"%s has no effect the turn-based engine can apply yet.", spell.Name))
|
||||||
}
|
}
|
||||||
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||||||
return p.SendDM(ctx.Sender, msg)
|
return p.replyDM(ctx, msg)
|
||||||
}
|
}
|
||||||
sess.Statuses.applyBuffDelta(d)
|
sess.Statuses.applyBuffDelta(d)
|
||||||
player, enemy, err = p.combatantsForSession(sess)
|
player, enemy, err = p.combatantsForSession(sess)
|
||||||
@@ -445,7 +458,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
|||||||
if spell.Level > 0 {
|
if spell.Level > 0 {
|
||||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
label := spell.Name + " — active"
|
label := spell.Name + " — active"
|
||||||
if d.heal > 0 {
|
if d.heal > 0 {
|
||||||
@@ -458,11 +471,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
|||||||
} else {
|
} else {
|
||||||
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
|
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
|
||||||
if !supported {
|
if !supported {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.replyDM(ctx, fmt.Sprintf(
|
||||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
|
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
|
||||||
}
|
}
|
||||||
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||||||
return p.SendDM(ctx.Sender, msg)
|
return p.replyDM(ctx, msg)
|
||||||
}
|
}
|
||||||
eff = &turnActionEffect{
|
eff = &turnActionEffect{
|
||||||
Label: out.Desc,
|
Label: out.Desc,
|
||||||
@@ -478,9 +491,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
|||||||
if spell.Level > 0 {
|
if spell.Level > 0 {
|
||||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||||
}
|
}
|
||||||
|
|
||||||
// chargeSpellCost debits a spell's material component and leveled slot for a
|
// chargeSpellCost debits a spell's material component and leveled slot for a
|
||||||
@@ -551,37 +564,37 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
|||||||
|
|
||||||
sess, err := getActiveCombatSession(ctx.Sender)
|
sess, err := getActiveCombatSession(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
|
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||||
}
|
}
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
return p.SendDM(ctx.Sender, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
|
return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
|
||||||
}
|
}
|
||||||
|
|
||||||
inv := p.loadConsumableInventory(ctx.Sender)
|
inv := p.loadConsumableInventory(ctx.Sender)
|
||||||
args = strings.TrimSpace(args)
|
args = strings.TrimSpace(args)
|
||||||
if args == "" {
|
if args == "" {
|
||||||
if len(inv) == 0 {
|
if len(inv) == 0 {
|
||||||
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
|
return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
|
||||||
}
|
}
|
||||||
names := make([]string, len(inv))
|
names := make([]string, len(inv))
|
||||||
for i, c := range inv {
|
for i, c := range inv {
|
||||||
names[i] = c.Def.Name
|
names[i] = c.Def.Name
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
||||||
}
|
}
|
||||||
|
|
||||||
item, ambig := matchConsumable(inv, args)
|
item, ambig := matchConsumable(inv, args)
|
||||||
if ambig != "" {
|
if ambig != "" {
|
||||||
return p.SendDM(ctx.Sender, ambig)
|
return p.replyDM(ctx, ambig)
|
||||||
}
|
}
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args))
|
return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args))
|
||||||
}
|
}
|
||||||
|
|
||||||
def := item.Def
|
def := item.Def
|
||||||
player, enemy, err := p.combatantsForSession(sess)
|
player, enemy, err := p.combatantsForSession(sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
eff := &turnActionEffect{Action: "use_consumable"}
|
eff := &turnActionEffect{Action: "use_consumable"}
|
||||||
@@ -600,13 +613,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
|||||||
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
|
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
|
||||||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||||||
if !d.any() {
|
if !d.any() {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.replyDM(ctx, fmt.Sprintf(
|
||||||
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
|
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
|
||||||
}
|
}
|
||||||
sess.Statuses.applyBuffDelta(d)
|
sess.Statuses.applyBuffDelta(d)
|
||||||
player, enemy, err = p.combatantsForSession(sess)
|
player, enemy, err = p.combatantsForSession(sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||||
}
|
}
|
||||||
eff.Label = def.Name + " — active"
|
eff.Label = def.Name + " — active"
|
||||||
eff.PlayerHeal = d.heal
|
eff.PlayerHeal = d.heal
|
||||||
@@ -615,7 +628,7 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
|||||||
|
|
||||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
|
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||||
}
|
}
|
||||||
// Round resolved and persisted — now burn the item. A removal failure here
|
// Round resolved and persisted — now burn the item. A removal failure here
|
||||||
// leaves the player a free use (logged, rare); better than charging them
|
// leaves the player a free use (logged, rare); better than charging them
|
||||||
@@ -623,5 +636,5 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
|||||||
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
|
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
|
||||||
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
|
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,20 @@ func abilityModifier(score int) int {
|
|||||||
// startup to refresh stale rows.
|
// startup to refresh stale rows.
|
||||||
const phase5BHPMult = 1.5
|
const phase5BHPMult = 1.5
|
||||||
|
|
||||||
|
// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed
|
||||||
|
// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor
|
||||||
|
// lift alone wasn't enough to crack the wall. Applied multiplicatively in
|
||||||
|
// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor
|
||||||
|
// stays intact for martials. Refreshed for existing rows by
|
||||||
|
// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1).
|
||||||
|
func casterHPMult(class DnDClass) float64 {
|
||||||
|
switch class {
|
||||||
|
case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer:
|
||||||
|
return 1.25
|
||||||
|
}
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
|
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
|
||||||
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
|
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
|
||||||
// The result is scaled by phase5BHPMult — see that constant for the
|
// The result is scaled by phase5BHPMult — see that constant for the
|
||||||
@@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
|
|||||||
}
|
}
|
||||||
hp += gain
|
hp += gain
|
||||||
}
|
}
|
||||||
// Phase 5-B player power floor — round to nearest int.
|
// Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
|
||||||
scaled := int(float64(hp)*phase5BHPMult + 0.5)
|
scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
|
||||||
if scaled < 1 {
|
if scaled < 1 {
|
||||||
scaled = 1
|
scaled = 1
|
||||||
}
|
}
|
||||||
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
|
|||||||
switch class {
|
switch class {
|
||||||
case ClassFighter, ClassPaladin:
|
case ClassFighter, ClassPaladin:
|
||||||
floor = 6 // heavy/medium-armor baseline
|
floor = 6 // heavy/medium-armor baseline
|
||||||
case ClassCleric, ClassRanger, ClassDruid:
|
case ClassCleric, ClassDruid:
|
||||||
|
floor = 5
|
||||||
|
case ClassRanger:
|
||||||
floor = 3
|
floor = 3
|
||||||
case ClassRogue, ClassBard, ClassWarlock:
|
case ClassBard, ClassWarlock:
|
||||||
|
floor = 3
|
||||||
|
case ClassRogue:
|
||||||
floor = 1
|
floor = 1
|
||||||
|
case ClassMage, ClassSorcerer:
|
||||||
|
floor = 2
|
||||||
}
|
}
|
||||||
return 10 + dexMod + floor
|
return 10 + dexMod + floor
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"green_hag": {
|
"green_hag": {
|
||||||
ID: "green_hag", Name: "Green Hag",
|
ID: "green_hag", Name: "Green Hag",
|
||||||
CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12,
|
CR: 3, HP: 105, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12,
|
||||||
BlockRate: 0.15,
|
BlockRate: 0.15,
|
||||||
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
|
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
|
||||||
XPValue: 700,
|
XPValue: 700,
|
||||||
@@ -463,7 +463,7 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"drow_elite_warrior": {
|
"drow_elite_warrior": {
|
||||||
ID: "drow_elite_warrior", Name: "Drow Elite Warrior",
|
ID: "drow_elite_warrior", Name: "Drow Elite Warrior",
|
||||||
CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
|
CR: 5, HP: 95, AC: 19, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||||
BlockRate: 0.20,
|
BlockRate: 0.20,
|
||||||
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
|
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
|
||||||
XPValue: 1800,
|
XPValue: 1800,
|
||||||
@@ -471,23 +471,23 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"drow_mage": {
|
"drow_mage": {
|
||||||
ID: "drow_mage", Name: "Drow Mage",
|
ID: "drow_mage", Name: "Drow Mage",
|
||||||
CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
|
CR: 7, HP: 65, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
|
||||||
BlockRate: 0.05,
|
BlockRate: 0.05,
|
||||||
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
|
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.25, Effect: "aoe"},
|
||||||
XPValue: 2900,
|
XPValue: 2900,
|
||||||
Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.",
|
Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.",
|
||||||
},
|
},
|
||||||
"mind_flayer": {
|
"mind_flayer": {
|
||||||
ID: "mind_flayer", Name: "Mind Flayer",
|
ID: "mind_flayer", Name: "Mind Flayer",
|
||||||
CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
|
CR: 7, HP: 90, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
|
||||||
BlockRate: 0.10,
|
BlockRate: 0.10,
|
||||||
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"},
|
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.30, Effect: "stun"},
|
||||||
XPValue: 2900,
|
XPValue: 2900,
|
||||||
Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.",
|
Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.",
|
||||||
},
|
},
|
||||||
"hook_horror": {
|
"hook_horror": {
|
||||||
ID: "hook_horror", Name: "Hook Horror",
|
ID: "hook_horror", Name: "Hook Horror",
|
||||||
CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10,
|
CR: 3, HP: 95, AC: 16, Attack: 6, AttackBonus: 6, Speed: 10,
|
||||||
BlockRate: 0.10,
|
BlockRate: 0.10,
|
||||||
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
|
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
|
||||||
XPValue: 700,
|
XPValue: 700,
|
||||||
@@ -495,15 +495,15 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"roper": {
|
"roper": {
|
||||||
ID: "roper", Name: "Roper",
|
ID: "roper", Name: "Roper",
|
||||||
CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
|
CR: 5, HP: 115, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
|
||||||
BlockRate: 0.20,
|
BlockRate: 0.20,
|
||||||
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"},
|
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.30, Effect: "stun"},
|
||||||
XPValue: 1800,
|
XPValue: 1800,
|
||||||
Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.",
|
Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.",
|
||||||
},
|
},
|
||||||
"boss_ilvaras_xunyl": {
|
"boss_ilvaras_xunyl": {
|
||||||
ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess",
|
ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess",
|
||||||
CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12,
|
CR: 12, HP: 210, AC: 18, Attack: 19, AttackBonus: 9, Speed: 12,
|
||||||
BlockRate: 0.15,
|
BlockRate: 0.15,
|
||||||
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
|
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
|
||||||
XPValue: 8400,
|
XPValue: 8400,
|
||||||
@@ -512,7 +512,7 @@ var _ = func() bool {
|
|||||||
// ── Feywild Crossing ─────────────────────────────────────────────
|
// ── Feywild Crossing ─────────────────────────────────────────────
|
||||||
"redcap": {
|
"redcap": {
|
||||||
ID: "redcap", Name: "Redcap",
|
ID: "redcap", Name: "Redcap",
|
||||||
CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14,
|
CR: 3, HP: 60, AC: 15, Attack: 6, AttackBonus: 6, Speed: 14,
|
||||||
BlockRate: 0.10,
|
BlockRate: 0.10,
|
||||||
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
|
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
|
||||||
XPValue: 700,
|
XPValue: 700,
|
||||||
@@ -520,7 +520,7 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"will_o_wisp": {
|
"will_o_wisp": {
|
||||||
ID: "will_o_wisp", Name: "Will-o'-Wisp",
|
ID: "will_o_wisp", Name: "Will-o'-Wisp",
|
||||||
CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
|
CR: 2, HP: 30, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
|
||||||
BlockRate: 0.0,
|
BlockRate: 0.0,
|
||||||
Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
|
Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
|
||||||
XPValue: 450,
|
XPValue: 450,
|
||||||
@@ -528,7 +528,7 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"quickling": {
|
"quickling": {
|
||||||
ID: "quickling", Name: "Quickling",
|
ID: "quickling", Name: "Quickling",
|
||||||
CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
|
CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
|
||||||
BlockRate: 0.05,
|
BlockRate: 0.05,
|
||||||
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
|
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
|
||||||
XPValue: 200,
|
XPValue: 200,
|
||||||
@@ -536,7 +536,7 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"night_hag": {
|
"night_hag": {
|
||||||
ID: "night_hag", Name: "Night Hag",
|
ID: "night_hag", Name: "Night Hag",
|
||||||
CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12,
|
CR: 5, HP: 135, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||||
BlockRate: 0.15,
|
BlockRate: 0.15,
|
||||||
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
|
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
|
||||||
XPValue: 1800,
|
XPValue: 1800,
|
||||||
@@ -544,7 +544,7 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"fomorian": {
|
"fomorian": {
|
||||||
ID: "fomorian", Name: "Fomorian",
|
ID: "fomorian", Name: "Fomorian",
|
||||||
CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12,
|
CR: 8, HP: 180, AC: 16, Attack: 13, AttackBonus: 9, Speed: 12,
|
||||||
BlockRate: 0.10,
|
BlockRate: 0.10,
|
||||||
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
|
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
|
||||||
XPValue: 3900,
|
XPValue: 3900,
|
||||||
@@ -552,7 +552,7 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"boss_thornmother": {
|
"boss_thornmother": {
|
||||||
ID: "boss_thornmother", Name: "The Thornmother",
|
ID: "boss_thornmother", Name: "The Thornmother",
|
||||||
CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12,
|
CR: 11, HP: 235, AC: 18, Attack: 18, AttackBonus: 8, Speed: 12,
|
||||||
BlockRate: 0.15,
|
BlockRate: 0.15,
|
||||||
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
|
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
|
||||||
XPValue: 7200,
|
XPValue: 7200,
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) {
|
|||||||
// Mage d6, CON +1
|
// Mage d6, CON +1
|
||||||
// L1: 6 + 1 = 7
|
// L1: 6 + 1 = 7
|
||||||
// L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20
|
// L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20
|
||||||
// Raw total: 27; Phase 5-B: 27 × 1.5 = 40.5 → 41 (round half-up).
|
// Raw total: 27; Phase 5-B × casterHPMult: 27 × 1.5 × 1.25 = 50.625 → 51.
|
||||||
got := computeMaxHP(ClassMage, 1, 5)
|
got := computeMaxHP(ClassMage, 1, 5)
|
||||||
if got != 41 {
|
if got != 51 {
|
||||||
t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got)
|
t.Errorf("Mage L5 (CON+1) = %d, want 51 (27 raw × phase5BHPMult × casterHPMult)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +94,12 @@ func TestComputeAC(t *testing.T) {
|
|||||||
{ClassFighter, 0, 16}, // 10 + 0 + 6
|
{ClassFighter, 0, 16}, // 10 + 0 + 6
|
||||||
{ClassFighter, 2, 18}, // 10 + 2 + 6
|
{ClassFighter, 2, 18}, // 10 + 2 + 6
|
||||||
{ClassRogue, 3, 14}, // 10 + 3 + 1
|
{ClassRogue, 3, 14}, // 10 + 3 + 1
|
||||||
{ClassMage, 0, 10}, // 10 + 0 + 0
|
{ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor)
|
||||||
{ClassCleric, 1, 14}, // 10 + 1 + 3
|
{ClassSorcerer, 1, 13}, // 10 + 1 + 2
|
||||||
|
{ClassCleric, 1, 16}, // 10 + 1 + 5 (D8-d-fix caster floor)
|
||||||
|
{ClassDruid, 0, 15}, // 10 + 0 + 5
|
||||||
|
{ClassBard, 2, 15}, // 10 + 2 + 3 (D8-d-fix caster floor)
|
||||||
|
{ClassWarlock, 0, 13}, // 10 + 0 + 3
|
||||||
{ClassRanger, 2, 15}, // 10 + 2 + 3
|
{ClassRanger, 2, 15}, // 10 + 2 + 3
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -380,16 +380,16 @@ func roomGlyph(rt RoomType) string {
|
|||||||
type stopReason int
|
type stopReason int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stopOK stopReason = iota // walked to next room; loop may continue
|
stopOK stopReason = iota // walked to next room; loop may continue
|
||||||
stopFork // advanceTransitionGraph returned a forkMsg
|
stopFork // advanceTransitionGraph returned a forkMsg
|
||||||
stopElite // standing at an Elite doorway; needs !fight
|
stopElite // standing at an Elite doorway; needs !fight
|
||||||
stopBoss // standing at a Boss doorway; needs !fight
|
stopBoss // standing at a Boss doorway; needs !fight
|
||||||
stopEnded // patrol or room resolution killed the player
|
stopEnded // patrol or room resolution killed the player
|
||||||
stopComplete // run cleared (boss down, no outgoing edges)
|
stopComplete // run cleared (boss down, no outgoing edges)
|
||||||
stopBlocked // an active CombatSession blocks the advance
|
stopBlocked // an active CombatSession blocks the advance
|
||||||
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
|
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
|
||||||
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
|
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
|
||||||
stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp
|
stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp
|
||||||
)
|
)
|
||||||
|
|
||||||
// bossSafetyHPPct — compact-autopilot won't engage a boss while current HP
|
// bossSafetyHPPct — compact-autopilot won't engage a boss while current HP
|
||||||
@@ -477,11 +477,13 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
|
|||||||
|
|
||||||
// inlineBossCombat (only consulted when compact==true) selects between the
|
// inlineBossCombat (only consulted when compact==true) selects between the
|
||||||
// two background combat paths at a boss/elite doorway. true keeps the
|
// two background combat paths at a boss/elite doorway. true keeps the
|
||||||
// long-expedition D3 inline auto-resolve (production autorun). false
|
// legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy
|
||||||
// returns stopBoss/stopElite after the safety gate so a turn-based driver
|
// multiattack). false returns stopBoss/stopElite after the safety gate so
|
||||||
// — currently only the headless sim's autoResolveCombat / simPickCombatAction
|
// a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles
|
||||||
// — handles the fight via the regular !fight / !attack engine. The sim
|
// the fight via the regular !fight / !attack engine. Both the headless sim
|
||||||
// uses false so simPickSpell actually fires; D8-prereq re-wired this seam.
|
// and the production autorun (long-expedition D8-f) now pass false so the
|
||||||
|
// real engine (with multiattack) resolves the encounter and simPickSpell
|
||||||
|
// actually fires; D8-prereq re-wired this seam, D8-f flipped prod onto it.
|
||||||
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
|
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
|
||||||
run, err := getActiveZoneRun(ctx.Sender)
|
run, err := getActiveZoneRun(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -568,10 +570,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !inlineBossCombat {
|
if !inlineBossCombat {
|
||||||
// Background caller wants to drive the fight itself (sim's
|
// Background caller wants to drive the fight itself via the
|
||||||
// autoResolveCombat / simPickCombatAction). Surface the
|
// turn engine (autoDriveCombat / pickAutoCombatAction).
|
||||||
// doorway like the foreground path does, after the safety
|
// Surface the doorway like the foreground path does, after
|
||||||
// gate has had a chance to defer the engagement.
|
// the safety gate has had a chance to defer the engagement.
|
||||||
kind := "Elite"
|
kind := "Elite"
|
||||||
r := stopElite
|
r := stopElite
|
||||||
if prev == RoomBoss {
|
if prev == RoomBoss {
|
||||||
@@ -953,6 +955,7 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
|||||||
// resolveCombatRoom spawns one roster enemy (elite filter optional),
|
// resolveCombatRoom spawns one roster enemy (elite filter optional),
|
||||||
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
|
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
|
||||||
// and renders the staged narration. Returns:
|
// and renders the staged narration. Returns:
|
||||||
|
//
|
||||||
// intro — pre-combat block (TwinBee combat-start + monster stat block)
|
// intro — pre-combat block (TwinBee combat-start + monster stat block)
|
||||||
// phases — RenderCombatLog output, streamed with delays by the caller
|
// phases — RenderCombatLog output, streamed with delays by the caller
|
||||||
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
|
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
|
||||||
@@ -1139,7 +1142,7 @@ type BossOutcomeInputs struct {
|
|||||||
Result CombatResult
|
Result CombatResult
|
||||||
PreHP, PostHP, MaxHP int
|
PreHP, PostHP, MaxHP int
|
||||||
PhaseTwoAt float64 // fraction of MaxHP; 0 disables phase-two narration
|
PhaseTwoAt float64 // fraction of MaxHP; 0 disables phase-two narration
|
||||||
Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat
|
Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat
|
||||||
|
|
||||||
// Caller-supplied headlines so arena and zone read in their own voice.
|
// Caller-supplied headlines so arena and zone read in their own voice.
|
||||||
// DefeatHeadline is the full sentence shown after the PlayerDeath
|
// DefeatHeadline is the full sentence shown after the PlayerDeath
|
||||||
@@ -1287,4 +1290,3 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
|
|||||||
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
|
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
|
||||||
zone.Display, run.CurrentRoom+1, run.TotalRooms))
|
zone.Display, run.CurrentRoom+1, run.TotalRooms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,84 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
|
|||||||
return ids, rows.Err()
|
return ids, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoRunCombatSegmentCap bounds the walk→fight→walk ping-pong inside a
|
||||||
|
// single background tick. With autoRunRoomCap == 3 rooms/tick the loop
|
||||||
|
// can realistically only hit a couple doorways; the cap is a backstop
|
||||||
|
// against a pathological state where a fight wins but the next walk
|
||||||
|
// re-presents the same doorway.
|
||||||
|
const autoRunCombatSegmentCap = 8
|
||||||
|
|
||||||
|
// runAutopilotWalkDriven runs the compact background walk and, when it
|
||||||
|
// halts at a boss/elite doorway, drives that fight through the real turn
|
||||||
|
// engine (manual `!fight` parity — long-expedition D8-f) before resuming
|
||||||
|
// the walk. It loops walk→fight→walk so one tick still covers up to
|
||||||
|
// maxRooms rooms, exactly as the old inline-boss path did, but bosses now
|
||||||
|
// face the player's full kit against the enemy's full multiattack profile
|
||||||
|
// instead of the rosier inline SimulateCombat path.
|
||||||
|
//
|
||||||
|
// Combat narration is suppressed via a silent ctx — the day digest
|
||||||
|
// summarizes the outcome, matching the rest of the compact autopilot
|
||||||
|
// surface. The returned result carries the cumulative room count and the
|
||||||
|
// reason of whichever non-combat stop ended the loop.
|
||||||
|
func (p *AdventurePlugin) runAutopilotWalkDriven(ctx MessageContext, maxRooms int) autopilotWalkResult {
|
||||||
|
silent := ctx
|
||||||
|
silent.Silent = true
|
||||||
|
total := 0
|
||||||
|
for seg := 0; seg < autoRunCombatSegmentCap; seg++ {
|
||||||
|
budget := maxRooms - total
|
||||||
|
if budget <= 0 {
|
||||||
|
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
|
||||||
|
}
|
||||||
|
r := p.runAutopilotWalk(ctx, budget, true, false)
|
||||||
|
if r.initErr != "" {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
total += r.rooms
|
||||||
|
r.rooms = total
|
||||||
|
if r.reason != stopBoss && r.reason != stopElite {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
// Standing at an elite/boss doorway — drive the fight on the turn
|
||||||
|
// engine. handleFightCmd opens the session at the current doorway;
|
||||||
|
// autoDriveCombat loops until it resolves.
|
||||||
|
won, err := p.autoDriveCombat(silent)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("expedition: autopilot turn-engine combat", "user", ctx.Sender, "err", err)
|
||||||
|
// Leave the doorway stop in place; the next tick retries the
|
||||||
|
// engagement after the cooldown.
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if won {
|
||||||
|
// The won session is recorded; the next walk advances the now-
|
||||||
|
// cleared room (a boss win surfaces as stopComplete, an elite as
|
||||||
|
// a normal continue). Loop.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Lost: the turn engine never voluntarily flees, so a non-win means
|
||||||
|
// the party fell. finishCombatSession (CombatStatusLost) already
|
||||||
|
// abandoned the run and force-extracted the expedition; surface it
|
||||||
|
// as a death so the digest + pet-emergence seam fire.
|
||||||
|
if c, _ := LoadDnDCharacter(ctx.Sender); c == nil || c.HPCurrent <= 0 {
|
||||||
|
r.reason = stopEnded
|
||||||
|
r.finalMsg = fmt.Sprintf("💀 The party fell in battle after %s. The expedition is over.", roomsWalkedPhrase(total))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
// Alive but the session didn't open / resolve to a win (rare —
|
||||||
|
// bestiary miss or stall). Leave the doorway stop; retry next tick.
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
// Segment cap hit — stop cleanly rather than spin.
|
||||||
|
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomsWalkedPhrase renders "N room(s)" for autopilot footers.
|
||||||
|
func roomsWalkedPhrase(rooms int) string {
|
||||||
|
if rooms == 1 {
|
||||||
|
return "1 room"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d rooms", rooms)
|
||||||
|
}
|
||||||
|
|
||||||
// tryAutoRun claims the slot for this expedition, runs one background
|
// tryAutoRun claims the slot for this expedition, runs one background
|
||||||
// walk, and DMs the result if the suppression rules say to. The CAS-
|
// walk, and DMs the result if the suppression rules say to. The CAS-
|
||||||
// update is the only persistent side effect on the autorun column —
|
// update is the only persistent side effect on the autorun column —
|
||||||
@@ -170,7 +248,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uid := id.UserID(e.UserID)
|
uid := id.UserID(e.UserID)
|
||||||
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true, true)
|
// D8-f — boss/elite encounters route through the real turn engine for
|
||||||
|
// manual `!fight` parity (enemy multiattack included). Trash mobs still
|
||||||
|
// auto-resolve on the fast inline path inside the walk.
|
||||||
|
r := p.runAutopilotWalkDriven(MessageContext{Sender: uid}, autoRunRoomCap)
|
||||||
if r.initErr != "" {
|
if r.initErr != "" {
|
||||||
// "no expedition" / "no run" — race with abandon/extract. Silent.
|
// "no expedition" / "no run" — race with abandon/extract. Silent.
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,6 +23,12 @@ import (
|
|||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// simInlineBossCombat is a D8-e DIAGNOSTIC toggle. When GOGOBEE_SIM_INLINE_BOSS=1
|
||||||
|
// the sim routes boss/elite doorways through the inline SimulateCombat path
|
||||||
|
// (production autopilot behavior) instead of the turn-based !fight engine.
|
||||||
|
// Used to A/B the martial T4/T5 regression. NOT for production.
|
||||||
|
func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") == "1" }
|
||||||
|
|
||||||
// SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes
|
// SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes
|
||||||
// just enough surface to drive synthetic players end-to-end.
|
// just enough surface to drive synthetic players end-to-end.
|
||||||
type SimRunner struct {
|
type SimRunner struct {
|
||||||
@@ -116,7 +123,7 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
|||||||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||||||
// + a couple offensive items into the synthetic player's inventory so
|
// + a couple offensive items into the synthetic player's inventory so
|
||||||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||||||
// during autoResolveCombat. Counts are deliberately modest — a real
|
// during autoDriveCombat. Counts are deliberately modest — a real
|
||||||
// L7+ player typically carries 3-6 heals plus a couple of buffs; we
|
// L7+ player typically carries 3-6 heals plus a couple of buffs; we
|
||||||
// mirror that band rather than max-stocking, which would mask class
|
// mirror that band rather than max-stocking, which would mask class
|
||||||
// power gaps.
|
// power gaps.
|
||||||
@@ -274,8 +281,8 @@ type SimResult struct {
|
|||||||
// resource). YieldsByName breaks that total down by resource name
|
// resource). YieldsByName breaks that total down by resource name
|
||||||
// for tier/per-resource calibration. Both are read from
|
// for tier/per-resource calibration. Both are read from
|
||||||
// adventure_inventory at end-of-run.
|
// adventure_inventory at end-of-run.
|
||||||
YieldCount int
|
YieldCount int
|
||||||
YieldsByName map[string]int
|
YieldsByName map[string]int
|
||||||
// Combats holds a per-combat trace for every fight the synthetic
|
// Combats holds a per-combat trace for every fight the synthetic
|
||||||
// player entered during the expedition (boss + elites + patrols).
|
// player entered during the expedition (boss + elites + patrols).
|
||||||
// Used by post-hoc analysis to dig into class-survival walls
|
// Used by post-hoc analysis to dig into class-survival walls
|
||||||
@@ -429,7 +436,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
|
|||||||
|
|
||||||
for i := 0; i < walkCap; i++ {
|
for i := 0; i < walkCap; i++ {
|
||||||
simNow = simNow.Add(simWalkInterval)
|
simNow = simNow.Add(simWalkInterval)
|
||||||
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, false)
|
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, simInlineBossCombat())
|
||||||
if walk.initErr != "" {
|
if walk.initErr != "" {
|
||||||
res.Outcome = "halted"
|
res.Outcome = "halted"
|
||||||
res.StopCode = "init:" + walk.initErr
|
res.StopCode = "init:" + walk.initErr
|
||||||
@@ -473,7 +480,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
|
|||||||
case stopBoss, stopElite:
|
case stopBoss, stopElite:
|
||||||
// Auto-resolve the encounter: !fight to open, then !attack
|
// Auto-resolve the encounter: !fight to open, then !attack
|
||||||
// per round until the session resolves (won / lost / fled).
|
// per round until the session resolves (won / lost / fled).
|
||||||
killed, err := s.autoResolveCombat(ctx)
|
killed, err := s.P.autoDriveCombat(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Outcome = "halted"
|
res.Outcome = "halted"
|
||||||
res.StopCode = "combat:" + err.Error()
|
res.StopCode = "combat:" + err.Error()
|
||||||
@@ -499,9 +506,22 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
|
|||||||
// Boss kill closes the run via combat resolution → continue
|
// Boss kill closes the run via combat resolution → continue
|
||||||
// the loop so the next walk picks up the cleared-run state.
|
// the loop so the next walk picks up the cleared-run state.
|
||||||
case stopFork:
|
case stopFork:
|
||||||
// Deterministic sim policy: always take path 1. Real players
|
// Deterministic sim policy: take the first UNLOCKED path. The
|
||||||
// pick based on intent; the sim just needs to make progress.
|
// old blind "go 1" stalled forever on all-skill-check forks
|
||||||
if err := s.P.handleDnDExpeditionCmd(ctx, "go 1"); err != nil {
|
// (feywild fork1) — resolveForkChoice rejects a locked edge but
|
||||||
|
// zoneCmdGo swallows it as a sent-DM with a nil return, so the
|
||||||
|
// run never advanced and burned every walk at the same node. A
|
||||||
|
// real player reads the menu and picks a passable path; mirror
|
||||||
|
// that. choice==0 means every edge is locked (a graph soft-lock
|
||||||
|
// the author must fix) — halt loudly rather than spin.
|
||||||
|
choice := s.firstUnlockedForkChoice(ctx.Sender)
|
||||||
|
if choice == 0 {
|
||||||
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "fork_all_locked"
|
||||||
|
i = walkCap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := s.P.handleDnDExpeditionCmd(ctx, fmt.Sprintf("go %d", choice)); err != nil {
|
||||||
res.Outcome = "halted"
|
res.Outcome = "halted"
|
||||||
res.StopCode = "fork:" + err.Error()
|
res.StopCode = "fork:" + err.Error()
|
||||||
i = walkCap
|
i = walkCap
|
||||||
@@ -619,6 +639,26 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// firstUnlockedForkChoice returns the 1-based index of the first
|
||||||
|
// traversable option at the pending fork, or 0 if every edge is locked
|
||||||
|
// (a graph soft-lock — see feywild fork1, which had no LockNone exit).
|
||||||
|
func (s *SimRunner) firstUnlockedForkChoice(uid id.UserID) int {
|
||||||
|
run, err := getActiveZoneRun(uid)
|
||||||
|
if err != nil || run == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
pf, err := decodePendingFork(run.NodeChoices)
|
||||||
|
if err != nil || pf == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for _, o := range pf.Options {
|
||||||
|
if o.Unlocked {
|
||||||
|
return o.Index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// captureDaySnapshot appends a SimDaySnapshot reflecting current state.
|
// captureDaySnapshot appends a SimDaySnapshot reflecting current state.
|
||||||
// HP is read from the live character row; SU/threat/day from the live
|
// HP is read from the live character row; SU/threat/day from the live
|
||||||
// expedition. Rooms is the running res.Rooms counter.
|
// expedition. Rooms is the running res.Rooms counter.
|
||||||
@@ -735,16 +775,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) {
|
|||||||
return total, out
|
return total, out
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoResolveCombat dispatches !fight at the current elite/boss gate,
|
// autoDriveCombat dispatches !fight at the current elite/boss gate,
|
||||||
// then loops !attack until the combat session resolves. Returns true
|
// then loops !attack/!cast/!consume until the combat session resolves.
|
||||||
// when the player won (enemy dead, room cleared), false when the
|
// Returns true when the player won (enemy dead, room cleared), false when
|
||||||
// player lost or fled. autoCombatRoundCap is a safety cap against
|
// the player lost or fled. autoCombatRoundCap is a safety cap against
|
||||||
// pathological stalemates (shouldn't trigger in practice — combat is
|
// pathological stalemates (shouldn't trigger in practice — combat is
|
||||||
// strictly monotone in HP).
|
// strictly monotone in HP).
|
||||||
|
//
|
||||||
|
// Shared by the headless sim and the production background autopilot
|
||||||
|
// (long-expedition D8-f): a silent ctx (ctx.Silent) suppresses the
|
||||||
|
// per-round DM narration so the autorun digest can summarize the fight
|
||||||
|
// without spamming the player a message per round. Driving the real
|
||||||
|
// !fight/!attack engine here is what gives autopilot bosses true parity
|
||||||
|
// with manual `!fight` — including enemy multiattack, which the old
|
||||||
|
// inline SimulateCombat path ignored.
|
||||||
const autoCombatRoundCap = 200
|
const autoCombatRoundCap = 200
|
||||||
|
|
||||||
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
|
func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
|
||||||
if err := s.P.handleFightCmd(ctx); err != nil {
|
if err := p.handleFightCmd(ctx); err != nil {
|
||||||
return false, fmt.Errorf("fight: %w", err)
|
return false, fmt.Errorf("fight: %w", err)
|
||||||
}
|
}
|
||||||
sess, err := getActiveCombatSession(ctx.Sender)
|
sess, err := getActiveCombatSession(ctx.Sender)
|
||||||
@@ -772,15 +820,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
|
|||||||
case CombatStatusLost, CombatStatusFled:
|
case CombatStatusLost, CombatStatusFled:
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
kind, arg := s.simPickCombatAction(ctx.Sender, cur)
|
kind, arg := p.pickAutoCombatAction(ctx.Sender, cur)
|
||||||
var dispatchErr error
|
var dispatchErr error
|
||||||
switch kind {
|
switch kind {
|
||||||
case "consume":
|
case "consume":
|
||||||
dispatchErr = s.P.handleConsumeCmd(ctx, arg)
|
dispatchErr = p.handleConsumeCmd(ctx, arg)
|
||||||
case "cast":
|
case "cast":
|
||||||
dispatchErr = s.P.handleCombatCastCmd(ctx, arg)
|
dispatchErr = p.handleCombatCastCmd(ctx, arg)
|
||||||
default:
|
default:
|
||||||
dispatchErr = s.P.handleAttackCmd(ctx)
|
dispatchErr = p.handleAttackCmd(ctx)
|
||||||
}
|
}
|
||||||
if dispatchErr != nil {
|
if dispatchErr != nil {
|
||||||
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr)
|
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr)
|
||||||
@@ -828,7 +876,7 @@ func (s *SimRunner) maybeShortRest(ctx MessageContext, uid id.UserID) {
|
|||||||
// one more big hit, not so early that a 1-HP scratch burns a potion.
|
// one more big hit, not so early that a 1-HP scratch burns a potion.
|
||||||
const simHealHPThresholdPct = 40
|
const simHealHPThresholdPct = 40
|
||||||
|
|
||||||
// simPickCombatAction is the sim's per-turn decision tree, mirroring
|
// pickAutoCombatAction is the per-turn decision tree, mirroring
|
||||||
// what a competent prod player would type:
|
// what a competent prod player would type:
|
||||||
//
|
//
|
||||||
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal
|
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal
|
||||||
@@ -847,14 +895,14 @@ const simHealHPThresholdPct = 40
|
|||||||
//
|
//
|
||||||
// Pre-J2a the sim looped !attack only, which underweighted every caster
|
// Pre-J2a the sim looped !attack only, which underweighted every caster
|
||||||
// class — see sim_results/j2_findings.md for the trace evidence.
|
// class — see sim_results/j2_findings.md for the trace evidence.
|
||||||
func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
|
func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
|
||||||
c, _ := LoadDnDCharacter(uid)
|
c, _ := LoadDnDCharacter(uid)
|
||||||
if c == nil || sess == nil {
|
if c == nil || sess == nil {
|
||||||
return "attack", ""
|
return "attack", ""
|
||||||
}
|
}
|
||||||
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
|
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
|
||||||
if lowHP {
|
if lowHP {
|
||||||
inv := s.P.loadConsumableInventory(uid)
|
inv := p.loadConsumableInventory(uid)
|
||||||
for _, it := range inv {
|
for _, it := range inv {
|
||||||
if it.Def.Effect == EffectHeal {
|
if it.Def.Effect == EffectHeal {
|
||||||
return "consume", it.Def.Name
|
return "consume", it.Def.Name
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ type MessageContext struct {
|
|||||||
// routes DM commands to the player's game room). When set, sender-private
|
// routes DM commands to the player's game room). When set, sender-private
|
||||||
// replies should go here instead of the rewritten RoomID.
|
// replies should go here instead of the rewritten RoomID.
|
||||||
OriginRoomID id.RoomID
|
OriginRoomID id.RoomID
|
||||||
|
// Silent suppresses player-facing replies for handlers that honor it
|
||||||
|
// (currently the turn-engine combat commands via replyDM). Set by the
|
||||||
|
// background autopilot when it drives a boss/elite fight through the
|
||||||
|
// real !fight/!attack engine for manual parity (long-expedition D8-f) —
|
||||||
|
// the day digest summarizes the outcome, so the per-round narration is
|
||||||
|
// dropped rather than DM'd round-by-round.
|
||||||
|
Silent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReactionContext holds the context for a reaction event.
|
// ReactionContext holds the context for a reaction event.
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ package plugin
|
|||||||
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
|
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
|
||||||
// bargain_walk → fey_market → fork1
|
// bargain_walk → fey_market → fork1
|
||||||
//
|
//
|
||||||
// Fork1 → both options locked (CHA vs Perception, no free choice):
|
// Fork1 → marsh (free default) | grove (CHA DC 14 bonus — the bargain):
|
||||||
//
|
//
|
||||||
// Grove approach (8 nodes, CHA DC 14):
|
// Grove approach (8 nodes, CHA DC 14 bonus):
|
||||||
// grove_threshold → starlight_path → singing_orchard → mirror_pond
|
// grove_threshold → starlight_path → singing_orchard → mirror_pond
|
||||||
// → prismatic_arbor → moonpetal_clearing → dawnglow_walk →
|
// → prismatic_arbor → moonpetal_clearing → dawnglow_walk →
|
||||||
// glamoured_grove (fork2a)
|
// glamoured_grove (fork2a)
|
||||||
@@ -203,13 +203,19 @@ func zoneFeywildCrossingGraph() ZoneGraph {
|
|||||||
{From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone},
|
{From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone},
|
||||||
{From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone},
|
{From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone},
|
||||||
|
|
||||||
// Fork1 — both options locked (CHA vs. Perception, no LockNone).
|
// Fork1 — marsh is the free default path; grove is a CHA-gated
|
||||||
|
// bonus route (the fey bargain). (Both were skill-locked originally,
|
||||||
|
// with no LockNone exit — a soft-lock: any character failing both
|
||||||
|
// the CHA and Perception checks was permanently stranded here, since
|
||||||
|
// fork rolls are deterministic with no retry. Every other zone fork
|
||||||
|
// has a free path; freeing marsh restores that invariant while
|
||||||
|
// keeping the signature CHA fey-bargain route as a bonus.)
|
||||||
{From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold",
|
{From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold",
|
||||||
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14},
|
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14},
|
||||||
Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1},
|
Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1},
|
||||||
{From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold",
|
{From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold",
|
||||||
Lock: LockPerception, LockData: map[string]any{"dc": 14},
|
Lock: LockNone,
|
||||||
Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1},
|
Hint: "wisp-light flickering between two trees — pick your way through", Weight: 1},
|
||||||
|
|
||||||
// Grove approach.
|
// Grove approach.
|
||||||
{From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone},
|
{From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone},
|
||||||
|
|||||||
@@ -55,16 +55,23 @@ func TestFeywildCrossingGraph_PartialOverlap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFeywildCrossingGraph_NoFreeChoiceAtFork1 captures the design
|
// TestFeywildCrossingGraph_Fork1HasFreePath guards the no-soft-lock
|
||||||
// intent: both fork1 outgoing edges are locked. The player must succeed
|
// invariant: fork1 must offer at least one LockNone exit. The original
|
||||||
// at CHA or Perception to enter; no LockNone fallback.
|
// design locked BOTH edges (CHA + Perception) with no fallback — fork
|
||||||
func TestFeywildCrossingGraph_NoFreeChoiceAtFork1(t *testing.T) {
|
// rolls are deterministic with no retry, so a character failing both was
|
||||||
|
// permanently stranded (D8-f part 2 found this stranded ~60% of runs).
|
||||||
|
// Every other zone fork has a free path; fork1 must too.
|
||||||
|
func TestFeywildCrossingGraph_Fork1HasFreePath(t *testing.T) {
|
||||||
g := zoneFeywildCrossingGraph()
|
g := zoneFeywildCrossingGraph()
|
||||||
|
free := 0
|
||||||
for _, e := range g.outgoingEdges("feywild_crossing.fork1") {
|
for _, e := range g.outgoingEdges("feywild_crossing.fork1") {
|
||||||
if e.Lock == LockNone {
|
if e.Lock == LockNone || e.Lock == "" {
|
||||||
t.Errorf("fork1 has unlocked edge to %s — expected all locked", e.To)
|
free++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if free == 0 {
|
||||||
|
t.Error("fork1 has no free (LockNone) exit — soft-lock: a player failing every skill check is permanently stranded")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFeywildCrossingGraph_FirstCHALock confirms this zone uses
|
// TestFeywildCrossingGraph_FirstCHALock confirms this zone uses
|
||||||
|
|||||||
@@ -190,6 +190,40 @@ func TestCompileLegacyZoneGraph_AllRegistered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestZoneGraphs_NoSoftLockedFork guards the invariant that every fork
|
||||||
|
// node offers at least one traversable (LockNone) exit. A fork whose
|
||||||
|
// every edge is skill-locked strands any player who fails all the checks
|
||||||
|
// — fork rolls are deterministic with no retry. D8-f part 2 found
|
||||||
|
// feywild fork1 violating this (it stranded ~60% of runs). Catch any
|
||||||
|
// future author who locks every exit of a fork.
|
||||||
|
func TestZoneGraphs_NoSoftLockedFork(t *testing.T) {
|
||||||
|
for _, z := range allZones() {
|
||||||
|
g, ok := loadZoneGraph(z.ID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for nodeID, node := range g.Nodes {
|
||||||
|
if node.Kind != NodeKindFork {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outs := g.outgoingEdges(nodeID)
|
||||||
|
if len(outs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
free := false
|
||||||
|
for _, e := range outs {
|
||||||
|
if e.Lock == LockNone || e.Lock == "" {
|
||||||
|
free = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !free {
|
||||||
|
t.Errorf("zone %q fork %q: every exit is locked — soft-lock (a player failing all checks is stranded; add a LockNone fallback)", z.ID, nodeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeriveLegacyNodeID_StableShape(t *testing.T) {
|
func TestDeriveLegacyNodeID_StableShape(t *testing.T) {
|
||||||
got := deriveLegacyNodeID("crypt_valdris", 0)
|
got := deriveLegacyNodeID("crypt_valdris", 0)
|
||||||
want := "crypt_valdris.r1"
|
want := "crypt_valdris.r1"
|
||||||
|
|||||||
Reference in New Issue
Block a user