diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 6f9e0a5..a8e043f 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -486,6 +486,11 @@ func (p *AdventurePlugin) midnightReset() error { if char.CurrentStreak > char.BestStreak { char.BestStreak = char.CurrentStreak } + // Restamp to today (mirrors the busy branch above). A purely-legacy + // actor whose action path bumps CombatActionsUsed/HarvestActionsUsed + // but never stamps LastActionDate lands here with a stale date; without + // this its streak would reset to 1 every night even with continuous play. + char.LastActionDate = today _ = saveAdvCharacter(&char) } } diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 8b71a86..c210e65 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -348,11 +348,12 @@ func (te *turnEngine) stepEnemyTurn() { } if !abilityDealtDamage { - // Pet defensive procs are a per-round concept (mirrors the auto-resolve - // engine): roll once for the whole enemy turn, then apply to every swing - // in a multiattack profile. Whiff makes the enemy's attack this round a - // guaranteed miss; deflect halves the incoming damage. The shared - // resolveEnemyAttack primitive consumes both flags. + // Pet defensive procs are a single proc per enemy turn: roll once, then + // spend it on the first swing only. Whiff makes that one swing a + // guaranteed miss; deflect halves its damage. Against a multiattack the + // remaining swings resolve normally — a single proc shouldn't nullify a + // boss's whole multiattack round. (This deliberately diverges from the + // auto-resolve engine's apply-to-all model.) petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc if petDeflect { @@ -365,11 +366,14 @@ func (te *turnEngine) stepEnemyTurn() { // resolveEnemyAttack returns true when the fight is decided — either the // player went down without a death save, or a reflect consumable killed // the enemy. Disambiguate by inspecting HP. - for _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { + for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { swing := *te.enemy swing.Stats.Attack = atk.Damage swing.Stats.AttackBonus = atk.AttackBonus - decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, petWhiff, petDeflect, false) + // Spend the proc on the first swing only; later swings see false. + swingWhiff := petWhiff && i == 0 + swingDeflect := petDeflect && i == 0 + decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) if te.st.playerHP <= 0 { te.finish(CombatStatusLost) return diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 80f4836..e6cbbf8 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -361,19 +361,20 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) { return true, "" } } - // Not yet advanced-past, but if there's no live combat encounter in - // this room, it's still safe to rest in. Forward-only navigation means - // players naturally pause right after a kill before advancing — the - // "cleared" flag would otherwise force-downgrade their camp to rough. + // Not yet advanced-past, but the only thing that bars a rest is a live + // fight. Forward-only navigation means players pause right after a kill + // before advancing, and peaceful/exploration/loot rooms never spawn an + // encounter at all — both are safe to rest in. The "cleared" flag would + // otherwise reject standard camp here with a misleading "clear it first". encID := encounterIDForRoom(run.CurrentRoom) sess, err := getCombatSessionForEncounter(run.RunID, encID) if err != nil { return false, "" } - if sess != nil && sess.Status != CombatStatusActive { - return true, "" + if sess != nil && sess.Status == CombatStatusActive { + return false, "You can't camp mid-fight — finish the encounter first." } - return false, "" + return true, "" } // campCurrentRoomIndex returns 0 (entry) when no room context exists. diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 5feed25..63e6348 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -655,6 +655,18 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com IsMultiRegionZone(fresh.ZoneID) { if cur, ok := CurrentRegion(fresh); ok { if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok { + // A region crossing burns a transit day + supplies and + // draws unprotected wandering damage. On the background + // walk, don't cross while the player is weak — preflight + // HP/SU and hand the crossing back to a foreground + // `!region travel` / `!expedition run` if either is low. + if compact { + if msg, stop := autopilotPreflight(ctx.Sender, fresh); stop { + finalMsg = res.final + "\n\n" + msg + reason = stopPreflight + break + } + } stream = append(stream, res.final) transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next) if terr != nil { diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index f9e15c4..90c4a60 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -458,23 +458,25 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) reason: stopBlocked, }, nil } - // compact==true is the background auto-walk path; only credit - // player-initiated advances toward the daily streak. - if !compact { - markActedToday(ctx.Sender) - } _ = applyMoodDecayIfStale(run) zone := zoneOrFallback(run.ZoneID) // A pending fork means advanceTransitionGraph already cleared the // current room and stopped — re-running resolveRoom would re-fire // combat and re-drop loot on the same room. Re-emit the fork prompt // and let the caller surface it; the player commits via !zone go . + // This returns *before* crediting the daily streak: spamming `!zone + // advance` at a fork resolves no room, so it must not keep the streak alive. if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { return advanceResult{ final: renderForkPrompt(zone, *pf), reason: stopFork, }, nil } + // compact==true is the background auto-walk path; only credit + // player-initiated advances toward the daily streak. + if !compact { + markActedToday(ctx.Sender) + } prev := run.CurrentRoomType() prevIdx := run.CurrentRoom