small refactoring + autopotions

master
Denis Ranneft 1 month ago
parent 57312f176d
commit 9f2fc34316

@ -8,6 +8,7 @@ import (
"sort"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
)
@ -96,10 +97,10 @@ func runOneGridScenario(tmpl model.Enemy, base *model.Hero, enemyLv int, n int,
if maxH <= 0 {
maxH = 1
}
e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv)
e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv, nil)
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
if survived {
wins++

@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
@ -626,7 +627,7 @@ func runSeries(base model.Enemy, level int, hpScale, atkScale float64, n int, se
e := enemy
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
allDur = append(allDur, elapsed)
if survived {

@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
@ -156,11 +157,11 @@ func main() {
if hero.HP <= 0 {
hero.HP = hero.MaxHP
}
enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv)
enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv, nil)
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
WallClockDelay: time.Duration(*delayMs) * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
if survived {
wins++

@ -0,0 +1,11 @@
package constants
const (
OfflineAutoPotionChance = 0.10
OfflineAutoPotionHPThresh = 0.60
// CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests).
CombatSimMaxStepsDefault = 200_000
// CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early.
CombatSimMaxStepsLong = 3_000_000
)

@ -5,6 +5,7 @@ import (
"sort"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/model"
)
@ -63,7 +64,7 @@ func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile Ref
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
if survived {
wins++

@ -13,6 +13,7 @@ const (
attackOutcomeDodge = "dodge"
attackOutcomeBlock = "block"
attackOutcomeStun = "stun"
attackOutcomeHeal = "heal"
)
type DamageBreakdown struct {

@ -11,6 +11,8 @@ func combatLogPhraseKey(source, outcome string) string {
return model.LogPhraseCombatHeroStun
case attackOutcomeDodge:
return model.LogPhraseCombatHeroDodge
case attackOutcomeHeal:
return model.LogPhraseCombatHeroHeal
default:
return model.LogPhraseCombatHeroHit
}

@ -1,22 +1,12 @@
package game
import (
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/constants"
)
const (
offlineAutoPotionChance = 0.02
offlineAutoPotionHPThresh = 0.40
// CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests).
CombatSimMaxStepsDefault = 200_000
// CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early.
CombatSimMaxStepsLong = 3_000_000
)
// CombatSimDeterministicStart is the fixed combat timeline origin for balance tools and admin sim parity (avoids wall-clock drift in tests).
var CombatSimDeterministicStart = time.Unix(1_700_000_000, 0)
@ -27,7 +17,7 @@ type CombatSimOptions struct {
TickRate time.Duration
// AutoUsePotion decides whether to consume a potion after damage ticks/attacks.
// It should return true when a potion was used.
AutoUsePotion func(hero *model.Hero, now time.Time) bool
AutoUsePotion func(hero *model.Hero, force bool) int
// WallClockDelay adds optional real-time delay between simulation steps.
// 0 means instant simulation (default).
WallClockDelay time.Duration
@ -69,7 +59,7 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
step := 0
maxSteps := opts.MaxSteps
if maxSteps <= 0 {
maxSteps = CombatSimMaxStepsDefault
maxSteps = constants.CombatSimMaxStepsDefault
}
for step < maxSteps {
@ -144,7 +134,7 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
return false, now.Sub(start)
}
if opts.AutoUsePotion != nil {
_ = opts.AutoUsePotion(hero, now)
_ = opts.AutoUsePotion(hero, false)
}
enemyNext = now.Add(attackIntervalEnemy(enemy.Speed))
}
@ -166,26 +156,4 @@ func simStepDelay(opts CombatSimOptions) {
}
}
// OfflineAutoPotionHook is a low-probability offline-only potion usage policy.
func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool {
if hero == nil || hero.Potions <= 0 || hero.HP <= 0 {
return false
}
hpThresh := int(float64(hero.MaxHP) * offlineAutoPotionHPThresh)
if hero.HP >= hpThresh {
return false
}
if rand.Float64() >= offlineAutoPotionChance {
return false
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
return true
}

@ -9,6 +9,7 @@ import (
"sync"
"time"
hero_actions "github.com/denisovdennis/autohero/internal/hero"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
@ -614,11 +615,6 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
hero := hm.Hero
// Validate: hero is in combat, has potions, is alive.
if hm.State != model.StateFighting {
e.sendError(msg.HeroID, "not_fighting", "hero is not in combat")
return
}
if hero.Potions <= 0 {
e.sendError(msg.HeroID, "no_potions", "no potions available")
return
@ -628,15 +624,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
return
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
healAmount := hero_actions.UsePotionOnHero(hero, true)
hm.SyncToHero()
@ -653,7 +641,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
}
if e.adventureLog != nil {
if e.adventureLog != nil && healAmount > 0 {
e.adventureLog(msg.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseUsedHealingPotion,
@ -676,8 +664,6 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
HeroHP: hero.HP,
EnemyHP: enemyHP,
})
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now())
e.sender.SendToHero(msg.HeroID, "hero_state", hero)
}
}
@ -1619,6 +1605,8 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
dotDmg := ProcessDebuffDamage(cs.Hero, tickDur, now)
regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder)
summonDmg := ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
cs.LastTickAt = now
if e.sender != nil {
if dotDmg > 0 {
@ -1643,6 +1631,7 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
EnemyHP: cs.Enemy.HP,
})
}
}
if CheckDeath(cs.Hero, now) {
@ -1667,7 +1656,6 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
}
}
// Process all attacks that are due.
for e.queue.Len() > 0 {
next := e.queue[0]
if next.NextAttackAt.After(now) {
@ -1766,11 +1754,36 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
}
combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now)
healAmount := hero_actions.UsePotionOnHero(cs.Hero, false)
// Process all attacks that are due.
e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
if healAmount > 0 {
e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
Source: "potion",
Damage: -healAmount, // negative = heal
HeroHP: cs.Hero.HP,
EnemyHP: cs.Enemy.HP,
})
usePotionEvent := model.CombatEvent{
Type: "attack",
HeroID: cs.HeroID,
Damage: healAmount,
Source: "potion",
Outcome: attackOutcomeHeal,
HeroHP: cs.Hero.HP,
EnemyHP: cs.Enemy.HP,
Timestamp: now,
}
e.emitEvent(usePotionEvent)
e.logUsePotion(cs, usePotionEvent)
}
e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
Source: combatEvt.Source,
Damage: combatEvt.Damage,
@ -1883,6 +1896,20 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
})
}
func (e *Engine) logUsePotion(cs *model.CombatState, evt model.CombatEvent) {
if e.adventureLog == nil || cs == nil {
return
}
e.adventureLog(cs.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseUsedHealingPotion,
Args: map[string]any{"amount": evt.Damage}},
})
}
func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
hero := cs.Hero
enemy := &cs.Enemy

@ -8,6 +8,7 @@ import (
"math/rand"
"time"
hero_actions "github.com/denisovdennis/autohero/internal/hero"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
@ -456,7 +457,7 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{
TickRate: tickRate,
AutoUsePotion: OfflineAutoPotionHook,
AutoUsePotion: hero_actions.UsePotionOnHero,
})
if !survived || hero.HP <= 0 {

@ -124,7 +124,7 @@ func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) {
HP: 100,
Potions: 3,
}
if used := OfflineAutoPotionHook(hero, time.Now()); used {
if used := OfflineAutoPotionHook(hero); used {
t.Fatal("expected no potion usage when hero is above threshold")
}
if hero.Potions != 3 {

@ -7,6 +7,7 @@ import (
"sort"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/model"
)
@ -368,7 +369,7 @@ func estimateLevelUpSeconds(heroLevel int, p ProgressionSimParams) (seconds floa
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
cycle := elapsed.Seconds() + p.RestAfterCombat.Seconds()
xp := float64(enemy.XPReward)

@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@ -2976,7 +2977,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
opts := game.CombatSimOptions{
TickRate: tickRate,
WallClockDelay: wallClockDelay,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
OnEvent: func(evt model.CombatEvent) {
if len(events) < maxEvents {
events = append(events, evt)

@ -13,6 +13,7 @@ import (
"time"
"unicode/utf8"
hero_actions "github.com/denisovdennis/autohero/internal/hero"
"github.com/go-chi/chi/v5"
"golang.org/x/text/unicode/norm"
@ -1465,15 +1466,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
}
// Heal 30% of maxHP, capped at maxHP.
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
hero.Potions--
healAmount := hero_actions.UsePotionOnHero(hero,true)
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)

@ -0,0 +1,35 @@
package hero
import (
"math/rand"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
func UsePotionOnHero(hero *model.Hero, force bool) int {
if force != true {
if hero == nil || hero.Potions <= 0 || hero.HP <= 0 {
return 0
}
hpThresh := int(float64(hero.MaxHP) * constants.OfflineAutoPotionHPThresh)
if hero.HP >= hpThresh {
return 0
}
if rand.Float64() >= (1.0 - (1.0 - float64(hero.HP / hpThresh))) / 1.2 + constants.OfflineAutoPotionChance {
return 0
}
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
return healAmount;
}

@ -37,6 +37,7 @@ const (
LogPhraseQuestAccepted = "log.quest_accepted"
LogPhraseCombatHeroHit = "log.combat.hero_hit"
LogPhraseCombatHeroDodge = "log.combat.hero_dodge"
LogPhraseCombatHeroHeal = "log.combat.hero_heal"
LogPhraseCombatHeroStun = "log.combat.hero_stun"
LogPhraseCombatEnemyHit = "log.combat.enemy_hit"
LogPhraseCombatEnemyBlock = "log.combat.enemy_block"

@ -75,7 +75,7 @@ func (q *AttackQueue) Pop() any {
// CombatEvent is broadcast over WebSocket to clients observing combat.
type CombatEvent struct {
Type string `json:"type"` // "attack", "death", "buff_applied", "debuff_applied", "combat_start", "combat_end"
Type string `json:"type"` // "heal", "attack", "death", "buff_applied", "debuff_applied", "combat_start", "combat_end"
HeroID int64 `json:"heroId"`
Damage int `json:"damage,omitempty"`
Source string `json:"source"` // "hero" or "enemy"

@ -11,6 +11,7 @@
"pixi.js": "^8.6.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"ts-pattern": "^5.9.0",
"yaml": "^2.7.0"
},
"devDependencies": {
@ -2808,6 +2809,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ts-pattern": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz",
"integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==",
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

@ -14,6 +14,7 @@
"pixi.js": "^8.6.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"ts-pattern": "^5.9.0",
"yaml": "^2.7.0"
},
"devDependencies": {

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { match } from 'ts-pattern';
import { GameEngine } from './game/engine';
import {
GamePhase,
@ -83,7 +84,7 @@ import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels';
import type { AdventureLogLinePayload } from './game/types';
import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen';
import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay';
import {CombatOverlay, type CombatOverlayEvent, CombatOverlayTarget} from './ui/CombatOverlay';
import { CombatLogPanel } from './ui/CombatLogPanel';
import { GameToast } from './ui/GameToast';
import { OfflineReport } from './ui/OfflineReport';
@ -569,15 +570,19 @@ export function App() {
}, []);
const handleCombatAttack = useCallback((p: AttackPayload) => {
if (p.source === 'potion') {
return;
}
const defender: CombatOverlayEvent['target'] = match(p.source)
.returnType<CombatOverlayTarget>()
.with('enemy', () => 'hero')
.with('hero', () => 'enemy')
.with('potion', () => 'hero')
.with('dot', () => 'hero')
.with('summon', () => 'hero')
.exhaustive()
const defender: CombatOverlayEvent['target'] =
p.source === 'enemy' ? 'hero' : 'enemy';
const isBlocked = p.outcome === 'block';
const isEvaded = p.outcome === 'dodge';
const isStunned = p.outcome === 'stun';
const isHealed = p.outcome === 'heal';
const isCrit = Boolean(p.isCrit);
if (isStunned) {
@ -596,6 +601,15 @@ export function App() {
value: 0,
createdAt: performance.now(),
});
} else if (isHealed) {
appendCombatEvent({
id: Date.now() + Math.random(),
kind: 'heal',
target: defender,
value: p.damage,
isCrit,
createdAt: performance.now(),
});
} else {
appendCombatEvent({
id: Date.now() + Math.random(),

@ -578,7 +578,7 @@ export class GameEngine {
isCrit: boolean,
heroHp: number,
enemyHp: number,
outcome?: 'hit' | 'dodge' | 'block' | 'stun',
outcome?: 'hit' | 'dodge' | 'block' | 'stun' | 'heal',
): void {
void source;
void damage;

@ -570,7 +570,7 @@ export interface AttackPayload {
source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon';
damage: number;
isCrit?: boolean;
outcome?: 'hit' | 'dodge' | 'block' | 'stun';
outcome?: 'hit' | 'dodge' | 'block' | 'stun' | 'heal';
heroHp: number;
enemyHp: number;
debuffApplied?: string;

@ -5,7 +5,7 @@ const MAX_VISIBLE_LINES = 5;
const panelBase: CSSProperties = {
position: 'absolute',
top: '26%',
bottom: '26%',
zIndex: 50,
maxWidth: 200,
padding: '8px 10px',

@ -1,7 +1,7 @@
import { useMemo, type CSSProperties } from 'react';
import { getViewport } from '../shared/telegram';
export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned';
export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned' | 'heal';
export type CombatOverlayTarget = 'hero' | 'enemy';
export interface CombatOverlayEvent {
@ -13,9 +13,10 @@ export interface CombatOverlayEvent {
createdAt: number;
}
const FLOAT_DURATION_MS = 2600;
const FLOAT_DURATION_MS = 6000;
const FEEDBACK_DURATION_MS = 4800;
const CRIT_DURATION_MS = 6000;
const HEAL_DURATION_MS = 7000;
const FLOAT_RISE_PX = 96;
const FLOAT_DRIFT_PX = 44;
@ -44,6 +45,9 @@ function durationMs(evt: CombatOverlayEvent): number {
if (evt.kind === 'damage' && evt.isCrit) {
return CRIT_DURATION_MS;
}
if (evt.kind === 'heal') {
return HEAL_DURATION_MS;
}
return FLOAT_DURATION_MS;
}
@ -57,11 +61,17 @@ function overlayText(evt: CombatOverlayEvent): string {
}
if (evt.kind === 'blocked') return 'BLOCKED';
if (evt.kind === 'evaded') return 'EVADED';
if (evt.kind === 'heal') return `HEAL ${Math.round(evt.value)}`;
return 'STUNNED';
}
function overlayColor(evt: CombatOverlayEvent): string {
if (evt.kind === 'regen') return '#44dd66';
if (evt.kind === 'regen') {
return evt.target === 'hero' ? '#44dd66' : '#ff5566';
}
if (evt.kind === 'heal') {
return '#44dd66';
}
if (evt.kind === 'stunned') return '#ffaa44';
if (evt.kind === 'blocked' || evt.kind === 'evaded') {
return evt.target === 'hero' ? '#44dd66' : '#ff5566';
@ -86,6 +96,7 @@ export function CombatOverlay({ events, onExpire }: CombatOverlayProps) {
const yMid = viewport.height / 2 - 42;
return {
hero: { x: xHeroSide, y: yMid, driftDir: -1 },
potion: { x: xHeroSide, y: yMid, driftDir: -1 },
enemy: { x: xEnemySide, y: yMid, driftDir: 1 },
};
}, [viewport.height, viewport.width]);

Loading…
Cancel
Save