implement monster moves, update log tracking to "action codes" for storage, add notices for actions taken

This commit is contained in:
Sky Johnson 2025-08-15 10:11:24 -05:00
parent 0d3afffb1e
commit 9e17ab9bea
8 changed files with 622 additions and 116 deletions

252
data/fights.json Normal file
View File

@ -0,0 +1,252 @@
[
{
"id": 1,
"user_id": 1,
"monster_id": 2,
"monster_hp": 0,
"monster_max_hp": 6,
"monster_sleep": 0,
"monster_immune": 0,
"uber_damage": 0,
"uber_defense": 0,
"first_strike": true,
"turn": 7,
"ran_away": false,
"victory": true,
"won": true,
"reward_gold": 0,
"reward_exp": 0,
"actions": [
{
"t": 2
},
{
"t": 1,
"d": 1
},
{
"t": 1,
"d": 1
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 11,
"n": "Red Slime"
}
],
"created": 1755264713,
"updated": 1755270326
},
{
"id": 2,
"user_id": 1,
"monster_id": 5,
"monster_hp": 7,
"monster_max_hp": 10,
"monster_sleep": 0,
"monster_immune": 1,
"uber_damage": 0,
"uber_defense": 0,
"first_strike": true,
"turn": 3,
"ran_away": false,
"victory": true,
"won": false,
"reward_gold": 0,
"reward_exp": 0,
"actions": [
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 3,
"n": "Shadow"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 2,
"n": "Shadow"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 3,
"n": "Shadow"
}
],
"created": 1755270342,
"updated": 1755270352
},
{
"id": 3,
"user_id": 1,
"monster_id": 5,
"monster_hp": 6,
"monster_max_hp": 10,
"monster_sleep": 0,
"monster_immune": 1,
"uber_damage": 0,
"uber_defense": 0,
"first_strike": true,
"turn": 4,
"ran_away": false,
"victory": true,
"won": false,
"reward_gold": 0,
"reward_exp": 0,
"actions": [
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 2,
"n": "Shadow"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 3,
"n": "Shadow"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 3,
"n": "Shadow"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 3,
"n": "Shadow"
}
],
"created": 1755270581,
"updated": 1755270585
},
{
"id": 4,
"user_id": 1,
"monster_id": 2,
"monster_hp": 0,
"monster_max_hp": 6,
"monster_sleep": 0,
"monster_immune": 0,
"uber_damage": 0,
"uber_defense": 0,
"first_strike": false,
"turn": 6,
"ran_away": false,
"victory": true,
"won": true,
"reward_gold": 0,
"reward_exp": 0,
"actions": [
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 8,
"d": 1,
"n": "Red Slime"
},
{
"t": 1,
"d": 1
},
{
"t": 11,
"n": "Red Slime"
}
],
"created": 1755270614,
"updated": 1755270620
}
]

View File

@ -2,24 +2,64 @@ package actions
import (
"dk/internal/models/fights"
"dk/internal/models/monsters"
"dk/internal/models/spells"
"dk/internal/models/towns"
"dk/internal/models/users"
"math"
"math/rand"
"strconv"
)
func HandleAttack(fight *fights.Fight, user *users.User) {
// 20% chance to miss
if rand.Float32() < 0.2 {
fight.AddActionAttackMiss()
// Load monster data to get armor
monster, err := monsters.Find(fight.MonsterID)
if err != nil {
fight.AddAction("Monster not found!")
return
}
fight.DamageMonster(1)
fight.AddActionAttackHit(1)
// Player attack damage calculation
attackPower := float64(user.Attack)
minAttack := attackPower * 0.75
maxAttack := attackPower
tohit := math.Ceil(rand.Float64()*(maxAttack-minAttack)+minAttack) / 3
// Critical hit chance based on strength
criticalRoll := rand.Intn(150) + 1
if float64(criticalRoll) <= math.Sqrt(float64(user.Strength)) {
tohit *= 2 // Critical hit
}
// Monster defense calculation
armor := float64(monster.Armor)
minBlock := armor * 0.75
maxBlock := armor
toblock := math.Ceil(rand.Float64()*(maxBlock-minBlock)+minBlock) / 3
// Calculate final damage
damage := tohit - toblock
if damage < 1 {
damage = 1 // Minimum damage
}
// Apply uber damage bonus
if fight.UberDamage > 0 {
bonus := math.Ceil(damage * float64(fight.UberDamage) / 100)
damage += bonus
}
finalDamage := int(damage)
// Apply damage and add action
fight.DamageMonster(finalDamage)
fight.AddActionAttackHit(finalDamage)
// Check if monster is defeated
if fight.MonsterHP <= 0 {
fight.WinFight(10, 5)
fight.AddActionMonsterDeath(monster.Name)
fight.WinFight(fight.RewardGold, fight.RewardExp)
HandleFightWin(fight, user)
}
}
@ -81,3 +121,108 @@ func HandleRun(fight *fights.Fight, user *users.User) {
fight.AddAction("You failed to run away!")
}
}
func HandleMonsterAttack(fight *fights.Fight, user *users.User) {
// Load monster data
monster, err := monsters.Find(fight.MonsterID)
if err != nil {
return
}
// Monster attack damage calculation
attackPower := float64(monster.MaxDmg)
minAttack := attackPower * 0.75
maxAttack := attackPower
tohit := math.Ceil(rand.Float64()*(maxAttack-minAttack)+minAttack) / 3
// User defense calculation
defense := float64(user.Defense)
minBlock := defense * 0.75
maxBlock := defense
toblock := math.Ceil(rand.Float64()*(maxBlock-minBlock)+minBlock) / 3
// Calculate final damage
damage := tohit - toblock
if damage < 1 {
damage = 1 // Minimum damage
}
// Apply uber defense bonus (reduces damage taken)
if fight.UberDefense > 0 {
reduction := math.Ceil(damage * float64(fight.UberDefense) / 100)
damage -= reduction
if damage < 1 {
damage = 1 // Still minimum 1 damage
}
}
finalDamage := int(damage)
// Apply damage to user
user.HP -= finalDamage
if user.HP < 0 {
user.HP = 0
}
// Add monster attack action using memory-optimized format
fight.AddActionMonsterAttack(monster.Name, finalDamage)
// Check if user is defeated
if user.HP <= 0 {
fight.LoseFight()
HandleFightLoss(fight, user)
}
}
func HandleFightWin(fight *fights.Fight, user *users.User) {
// Add rewards to user
user.Exp += fight.RewardExp
user.Gold += fight.RewardGold
// Reset fight state
user.FightID = 0
user.Currently = "Exploring"
fight.Save()
user.Save()
}
func HandleFightLoss(fight *fights.Fight, user *users.User) {
// Find closest town to user's position
closestTown := findClosestTown(user.X, user.Y)
if closestTown != nil {
user.X = closestTown.X
user.Y = closestTown.Y
}
// Apply death penalties
user.HP = user.MaxHP / 4 // 25% of max health
user.Gold = (user.Gold * 3) / 4 // 75% of gold
// Reset fight state
user.FightID = 0
user.Currently = "In Town"
fight.Save()
user.Save()
}
func findClosestTown(x, y int) *towns.Town {
allTowns, err := towns.All()
if err != nil || len(allTowns) == 0 {
return nil
}
var closest *towns.Town
var minDistance float64
for _, town := range allTowns {
distance := town.DistanceFromSquared(x, y)
if closest == nil || distance < minDistance {
closest = town
minDistance = distance
}
}
return closest
}

View File

@ -0,0 +1,140 @@
package fights
import (
"fmt"
"strings"
"time"
)
// ActionEntry represents a compacted fight action log. This allows us to store more logs
// in the same space as a single string.
type ActionEntry struct {
Type int `json:"t"`
Data int `json:"d,omitempty"`
Name string `json:"n,omitempty"` // For spell names
}
// Action type constants
const (
ActionAttackHit = 1
ActionAttackMiss = 2
ActionSpellHeal = 3
ActionSpellHurt = 4
ActionRunSuccess = 5
ActionRunFail = 6
ActionGeneric = 7
ActionMonsterAttack = 8
ActionMonsterMiss = 9
ActionMonsterSpell = 10
ActionMonsterDeath = 11
)
func (f *Fight) AddAction(action string) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionGeneric, Name: action})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionAttackHit(damage int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionAttackHit, Data: damage})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionAttackMiss() {
f.Actions = append(f.Actions, ActionEntry{Type: ActionAttackMiss})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionSpellHeal(spellName string, healAmount int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionSpellHeal, Data: healAmount, Name: spellName})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionSpellHurt(spellName string, damage int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionSpellHurt, Data: damage, Name: spellName})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionRunSuccess() {
f.Actions = append(f.Actions, ActionEntry{Type: ActionRunSuccess})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionRunFail() {
f.Actions = append(f.Actions, ActionEntry{Type: ActionRunFail})
f.Updated = time.Now().Unix()
}
// Convert actions to human-readable strings
func (f *Fight) GetActions() []string {
result := make([]string, len(f.Actions))
for i, action := range f.Actions {
result[i] = f.actionToString(action)
}
return result
}
func (f *Fight) GetLastAction() string {
if len(f.Actions) == 0 {
return ""
}
return f.actionToString(f.Actions[len(f.Actions)-1])
}
func (f *Fight) ClearActions() {
f.Actions = make([]ActionEntry, 0)
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionMonsterAttack(monsterName string, damage int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionMonsterAttack, Data: damage, Name: monsterName})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionMonsterMiss(monsterName string) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionMonsterMiss, Name: monsterName})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionMonsterSpell(monsterName, spellName string, damage int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionMonsterSpell, Data: damage, Name: monsterName + "|" + spellName})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionMonsterDeath(monsterName string) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionMonsterDeath, Name: monsterName})
f.Updated = time.Now().Unix()
}
// Update actionToString method - add these cases
func (f *Fight) actionToString(action ActionEntry) string {
switch action.Type {
case ActionAttackHit:
return fmt.Sprintf("You attacked for %d damage!", action.Data)
case ActionAttackMiss:
return "You missed your attack!"
case ActionSpellHeal:
return fmt.Sprintf("You cast %s and healed %d HP!", action.Name, action.Data)
case ActionSpellHurt:
return fmt.Sprintf("You cast %s and dealt %d damage!", action.Name, action.Data)
case ActionRunSuccess:
return "You successfully ran away!"
case ActionRunFail:
return "You failed to run away!"
case ActionGeneric:
return action.Name
case ActionMonsterAttack:
return fmt.Sprintf("%s attacks for %d damage!", action.Name, action.Data)
case ActionMonsterMiss:
return fmt.Sprintf("%s missed its attack!", action.Name)
case ActionMonsterSpell:
parts := strings.Split(action.Name, "|")
if len(parts) == 2 {
return fmt.Sprintf("%s casts %s for %d damage!", parts[0], parts[1], action.Data)
}
return fmt.Sprintf("%s casts a spell for %d damage!", action.Name, action.Data)
case ActionMonsterDeath:
return fmt.Sprintf("%s has been defeated!", action.Name)
default:
return "Unknown action"
}
}

View File

@ -6,24 +6,6 @@ import (
"time"
)
// ActionEntry represents a compact fight action
type ActionEntry struct {
Type int `json:"t"`
Data int `json:"d,omitempty"`
Name string `json:"n,omitempty"` // For spell names
}
// Action type constants
const (
ActionAttackHit = 1
ActionAttackMiss = 2
ActionSpellHeal = 3
ActionSpellHurt = 4
ActionRunSuccess = 5
ActionRunFail = 6
ActionGeneric = 7
)
// Fight represents a fight, past or present
type Fight struct {
ID int `json:"id"`
@ -104,84 +86,6 @@ func (f *Fight) Validate() error {
return nil
}
// Action methods for backward compatibility
func (f *Fight) AddAction(action string) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionGeneric, Name: action})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionAttackHit(damage int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionAttackHit, Data: damage})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionAttackMiss() {
f.Actions = append(f.Actions, ActionEntry{Type: ActionAttackMiss})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionSpellHeal(spellName string, healAmount int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionSpellHeal, Data: healAmount, Name: spellName})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionSpellHurt(spellName string, damage int) {
f.Actions = append(f.Actions, ActionEntry{Type: ActionSpellHurt, Data: damage, Name: spellName})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionRunSuccess() {
f.Actions = append(f.Actions, ActionEntry{Type: ActionRunSuccess})
f.Updated = time.Now().Unix()
}
func (f *Fight) AddActionRunFail() {
f.Actions = append(f.Actions, ActionEntry{Type: ActionRunFail})
f.Updated = time.Now().Unix()
}
// Convert actions to human-readable strings
func (f *Fight) GetActions() []string {
result := make([]string, len(f.Actions))
for i, action := range f.Actions {
result[i] = f.actionToString(action)
}
return result
}
func (f *Fight) actionToString(action ActionEntry) string {
switch action.Type {
case ActionAttackHit:
return fmt.Sprintf("You attacked for %d damage!", action.Data)
case ActionAttackMiss:
return "You missed your attack!"
case ActionSpellHeal:
return fmt.Sprintf("You cast %s and healed %d HP!", action.Name, action.Data)
case ActionSpellHurt:
return fmt.Sprintf("You cast %s and dealt %d damage!", action.Name, action.Data)
case ActionRunSuccess:
return "You successfully ran away!"
case ActionRunFail:
return "You failed to run away!"
case ActionGeneric:
return action.Name
default:
return "Unknown action"
}
}
func (f *Fight) GetLastAction() string {
if len(f.Actions) == 0 {
return ""
}
return f.actionToString(f.Actions[len(f.Actions)-1])
}
func (f *Fight) ClearActions() {
f.Actions = make([]ActionEntry, 0)
f.Updated = time.Now().Unix()
}
// FightStore with enhanced BaseStore
type FightStore struct {
*store.BaseStore[Fight]

View File

@ -76,15 +76,8 @@ func processLogin(ctx router.Ctx, _ []string) {
// showRegister displays the registration form
func showRegister(ctx router.Ctx, _ []string) {
sess := ctx.UserValue("session").(*session.Session)
var errorHTML string
var username, email string
if flash, exists := sess.GetFlash("error"); exists {
if msg, ok := flash.(string); ok {
errorHTML = fmt.Sprintf(`<div style="color: red; margin-bottom: 1rem;">%s</div>`, msg)
}
}
if formData, exists := sess.Get("form_data"); exists {
if data, ok := formData.(map[string]string); ok {
username = data["username"]
@ -95,7 +88,6 @@ func showRegister(ctx router.Ctx, _ []string) {
session.Store(sess)
components.RenderPage(ctx, "Register", "auth/register.html", map[string]any{
"error_message": errorHTML,
"username": username,
"email": email,
})

View File

@ -11,6 +11,8 @@ import (
"dk/internal/models/spells"
"dk/internal/models/users"
"dk/internal/router"
"dk/internal/session"
"fmt"
"math/rand"
"strconv"
)
@ -25,6 +27,7 @@ func RegisterFightRoutes(r *router.Router) {
}
func showFight(ctx router.Ctx, _ []string) {
sess := ctx.UserValue("session").(*session.Session)
user := ctx.UserValue("user").(*users.User)
fight, err := fights.Find(user.FightID)
@ -68,17 +71,20 @@ func showFight(ctx router.Ctx, _ []string) {
}
components.RenderPage(ctx, "Fighting", "fight/fight.html", map[string]any{
"fight": fight,
"user": user,
"monster": monster,
"mon_hppct": monHpPct,
"mon_hpcol": monHpColor,
"spells": spellMap.ToSlice(),
"fight": fight,
"user": user,
"monster": monster,
"mon_hppct": monHpPct,
"mon_hpcol": monHpColor,
"spells": spellMap.ToSlice(),
"action": sess.GetFlashMessage("action"),
"mon_action": sess.GetFlashMessage("mon_action"),
})
}
func handleFightAction(ctx router.Ctx, _ []string) {
user := ctx.UserValue("user").(*users.User)
sess := ctx.UserValue("session").(*session.Session)
fight, err := fights.Find(user.FightID)
if err != nil {
@ -88,19 +94,78 @@ func handleFightAction(ctx router.Ctx, _ []string) {
}
action := string(ctx.FormValue("action"))
var userAction string
switch action {
case "attack":
actions.HandleAttack(fight, user)
userAction = fight.GetLastAction()
case "spell":
spellIDStr := string(ctx.FormValue("spell_id"))
if spellID, err := strconv.Atoi(spellIDStr); err == nil {
actions.HandleSpell(fight, user, spellID)
userAction = fight.GetLastAction()
}
case "run":
actions.HandleRun(fight, user)
userAction = fight.GetLastAction()
// If successfully ran away, redirect to explore
if fight.RanAway {
user.Currently = "Exploring"
user.Save()
sess.SetFlash("success", "You successfully escaped!")
ctx.Redirect("/explore", 302)
return
}
default:
fight.AddAction("Invalid action!")
userAction = "Invalid action!"
}
// Flash user action
sess.SetFlash("action", userAction)
// Check if fight ended due to user action
if fight.Victory {
if fight.Won {
// Player won
sess.SetFlash("success", fmt.Sprintf("Victory! You gained %d gold and %d experience!", fight.RewardGold, fight.RewardExp))
sess.DeleteFlash("action")
sess.DeleteFlash("mon_action")
ctx.Redirect("/explore", 302)
} else {
// Player lost
sess.SetFlash("error", "You have been defeated! You lost some gold and were sent to the nearest town.")
sess.DeleteFlash("action")
sess.DeleteFlash("mon_action")
ctx.Redirect("/town", 302)
}
return
}
// Monster attacks back if fight is still active
if fight.IsActive() && user.HP > 0 {
actions.HandleMonsterAttack(fight, user)
// Check if fight ended due to monster attack
if fight.Victory {
if fight.Won {
sess.SetFlash("success", fmt.Sprintf("Victory! You gained %d gold and %d experience!", fight.RewardGold, fight.RewardExp))
sess.DeleteFlash("action")
sess.DeleteFlash("mon_action")
ctx.Redirect("/explore", 302)
} else {
sess.SetFlash("error", "You have been defeated! You lost some gold and were sent to the nearest town.")
sess.DeleteFlash("action")
sess.DeleteFlash("mon_action")
ctx.Redirect("/town", 302)
}
return
}
monsterAction := fight.GetLastAction()
sess.SetFlash("mon_action", monsterAction)
}
fight.IncrementTurn()

View File

@ -81,6 +81,11 @@ func (s *Session) GetFlashMessage(key string) string {
return ""
}
// DeleteFlash removes a flash from the session.
func (s *Session) DeleteFlash(key string) {
s.GetFlash(key)
}
// RegenerateID creates a new session ID and updates storage
func (s *Session) RegenerateID() {
oldID := s.ID

View File

@ -8,6 +8,9 @@
<img id="monster-image" src="/assets/images/monsters/{monster.Name}.png" alt="{monster.Name}" title="{monster.Name}">
</div>
<div>{action}</div>
<div>{mon_action}</div>
<span>{fight.MonsterHP}/{fight.MonsterMaxHP}</span>
<div id="monster-health" class="mb-1">
<div class="bar {mon_hpcol}" style="width: {mon_hppct}%;"></div>