Phase-H review fixes: camp, fork streak, pet proc, BG transit, legacy streak

- Camp: campLocationCheck rejects only on live combat; no-encounter and
  post-kill rooms are campable (kills the misleading "clear it first").
- Fork: markActedToday moved after the pending-fork early-return so spamming
  !zone advance at a fork no longer keeps the daily streak alive.
- Pet whiff/deflect: single proc spent on the first multiattack swing only
  (was applied to every swing); also dedups the per-swing event spam.
- Autopilot: background region crossing now runs an HP/SU preflight and
  pauses if low, instead of burning a transit day while the player is idle.
- Legacy streak: acted-branch restamps LastActionDate=today so a purely-legacy
  actor's streak no longer resets to 1 every night.
This commit is contained in:
prosolis
2026-05-22 01:15:23 -07:00
parent bcd4a873a5
commit ef8fbe5496
5 changed files with 43 additions and 19 deletions

View File

@@ -486,6 +486,11 @@ func (p *AdventurePlugin) midnightReset() error {
if char.CurrentStreak > char.BestStreak { if char.CurrentStreak > char.BestStreak {
char.BestStreak = char.CurrentStreak 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) _ = saveAdvCharacter(&char)
} }
} }

View File

@@ -348,11 +348,12 @@ func (te *turnEngine) stepEnemyTurn() {
} }
if !abilityDealtDamage { if !abilityDealtDamage {
// Pet defensive procs are a per-round concept (mirrors the auto-resolve // Pet defensive procs are a single proc per enemy turn: roll once, then
// engine): roll once for the whole enemy turn, then apply to every swing // spend it on the first swing only. Whiff makes that one swing a
// in a multiattack profile. Whiff makes the enemy's attack this round a // guaranteed miss; deflect halves its damage. Against a multiattack the
// guaranteed miss; deflect halves the incoming damage. The shared // remaining swings resolve normally — a single proc shouldn't nullify a
// resolveEnemyAttack primitive consumes both flags. // 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 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 petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc
if petDeflect { if petDeflect {
@@ -365,11 +366,14 @@ func (te *turnEngine) stepEnemyTurn() {
// resolveEnemyAttack returns true when the fight is decided — either the // resolveEnemyAttack returns true when the fight is decided — either the
// player went down without a death save, or a reflect consumable killed // player went down without a death save, or a reflect consumable killed
// the enemy. Disambiguate by inspecting HP. // 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 := *te.enemy
swing.Stats.Attack = atk.Damage swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus 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 { if te.st.playerHP <= 0 {
te.finish(CombatStatusLost) te.finish(CombatStatusLost)
return return

View File

@@ -361,19 +361,20 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
return true, "" return true, ""
} }
} }
// Not yet advanced-past, but if there's no live combat encounter in // Not yet advanced-past, but the only thing that bars a rest is a live
// this room, it's still safe to rest in. Forward-only navigation means // fight. Forward-only navigation means players pause right after a kill
// players naturally pause right after a kill before advancing — the // before advancing, and peaceful/exploration/loot rooms never spawn an
// "cleared" flag would otherwise force-downgrade their camp to rough. // 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) encID := encounterIDForRoom(run.CurrentRoom)
sess, err := getCombatSessionForEncounter(run.RunID, encID) sess, err := getCombatSessionForEncounter(run.RunID, encID)
if err != nil { if err != nil {
return false, "" return false, ""
} }
if sess != nil && sess.Status != CombatStatusActive { if sess != nil && sess.Status == CombatStatusActive {
return true, "" 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. // campCurrentRoomIndex returns 0 (entry) when no room context exists.

View File

@@ -655,6 +655,18 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
IsMultiRegionZone(fresh.ZoneID) { IsMultiRegionZone(fresh.ZoneID) {
if cur, ok := CurrentRegion(fresh); ok { if cur, ok := CurrentRegion(fresh); ok {
if next, ok := nextRegion(fresh.ZoneID, cur.ID); 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) stream = append(stream, res.final)
transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next) transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next)
if terr != nil { if terr != nil {

View File

@@ -458,23 +458,25 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
reason: stopBlocked, reason: stopBlocked,
}, nil }, 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) _ = applyMoodDecayIfStale(run)
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
// A pending fork means advanceTransitionGraph already cleared the // A pending fork means advanceTransitionGraph already cleared the
// current room and stopped — re-running resolveRoom would re-fire // current room and stopped — re-running resolveRoom would re-fire
// combat and re-drop loot on the same room. Re-emit the fork prompt // 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 <n>. // and let the caller surface it; the player commits via !zone go <n>.
// 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 { if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
return advanceResult{ return advanceResult{
final: renderForkPrompt(zone, *pf), final: renderForkPrompt(zone, *pf),
reason: stopFork, reason: stopFork,
}, nil }, 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() prev := run.CurrentRoomType()
prevIdx := run.CurrentRoom prevIdx := run.CurrentRoom