new spell system

This commit is contained in:
Sky Johnson 2025-08-26 22:19:43 -05:00
parent 63dabb9e54
commit 2e3a977530
12 changed files with 255 additions and 97 deletions

Binary file not shown.

2
go.mod
View File

@ -3,7 +3,7 @@ module dk
go 1.25.0 go 1.25.0
require ( require (
git.sharkk.net/Sharkk/Sashimi v1.1.3 git.sharkk.net/Sharkk/Sashimi v1.1.4
git.sharkk.net/Sharkk/Sushi v1.2.0 git.sharkk.net/Sharkk/Sushi v1.2.0
github.com/valyala/fasthttp v1.65.0 github.com/valyala/fasthttp v1.65.0
) )

4
go.sum
View File

@ -1,5 +1,5 @@
git.sharkk.net/Sharkk/Sashimi v1.1.3 h1:fY63Zn//A1EffFkoKjCQseRmLFNRibNDZYPUur5SF1s= git.sharkk.net/Sharkk/Sashimi v1.1.4 h1:aULzzz4Qqpl69Vtpbi7zYYvay4J/HzButYXLwPzB/xw=
git.sharkk.net/Sharkk/Sashimi v1.1.3/go.mod h1:wTMnO6jo34LIjpDJ0qToq14RbwP6Uf4HtdWDmqxrdAM= git.sharkk.net/Sharkk/Sashimi v1.1.4/go.mod h1:wTMnO6jo34LIjpDJ0qToq14RbwP6Uf4HtdWDmqxrdAM=
git.sharkk.net/Sharkk/Sushi v1.2.0 h1:RwOCZmgaOqtkmuK2Z7/esdLbhSXJZphsOsWEHni4Sss= git.sharkk.net/Sharkk/Sushi v1.2.0 h1:RwOCZmgaOqtkmuK2Z7/esdLbhSXJZphsOsWEHni4Sss=
git.sharkk.net/Sharkk/Sushi v1.2.0/go.mod h1:S84ACGkuZ+BKzBO4lb5WQnm5aw9+l7VSO2T1bjzxL3o= git.sharkk.net/Sharkk/Sushi v1.2.0/go.mod h1:S84ACGkuZ+BKzBO4lb5WQnm5aw9+l7VSO2T1bjzxL3o=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=

View File

@ -2,7 +2,6 @@ package actions
import ( import (
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/exp"
"dk/internal/models/fightlogs" "dk/internal/models/fightlogs"
"dk/internal/models/fights" "dk/internal/models/fights"
"dk/internal/models/monsters" "dk/internal/models/monsters"
@ -147,22 +146,24 @@ func HandleSpell(fight *fights.Fight, user *users.User, spellID int) *FightResul
} }
} }
fmt.Printf("new MP is %d\n", user.MP-spell.MP)
result := &FightResult{ result := &FightResult{
UserUpdates: map[string]any{"mp": user.MP - spell.MP}, UserUpdates: map[string]any{"mp": user.MP - spell.MP},
} }
switch spell.Type { switch spell.Type {
case spells.TypeHealing: case spells.TypeHeal:
newHP := min(user.HP+spell.Attribute, user.MaxHP) newHP := min(user.HP+spell.Power, user.MaxHP)
result.UserUpdates["hp"] = newHP result.UserUpdates["hp"] = newHP
result.ActionText = fmt.Sprintf("You cast %s and healed %d HP!", spell.Name, spell.Attribute) result.ActionText = fmt.Sprintf("You cast %s and healed %d HP!", spell.Name, spell.Power)
result.LogAction = func() error { return fightlogs.AddSpellHeal(fight.ID, spell.Name, spell.Attribute) } result.LogAction = func() error { return fightlogs.AddSpellHeal(fight.ID, spell.Name, spell.Power) }
case spells.TypeHurt: case spells.TypeDamage:
newMonsterHP := max(fight.MonsterHP-spell.Attribute, 0) newMonsterHP := max(fight.MonsterHP-spell.Power, 0)
result.FightUpdates = map[string]any{"monster_hp": newMonsterHP} result.FightUpdates = map[string]any{"monster_hp": newMonsterHP}
result.ActionText = fmt.Sprintf("You cast %s and dealt %d damage!", spell.Name, spell.Attribute) result.ActionText = fmt.Sprintf("You cast %s and dealt %d damage!", spell.Name, spell.Power)
result.LogAction = func() error { return fightlogs.AddSpellHurt(fight.ID, spell.Name, spell.Attribute) } result.LogAction = func() error { return fightlogs.AddSpellHurt(fight.ID, spell.Name, spell.Power) }
if newMonsterHP <= 0 { if newMonsterHP <= 0 {
monster, err := monsters.Find(fight.MonsterID) monster, err := monsters.Find(fight.MonsterID)
@ -172,6 +173,33 @@ func HandleSpell(fight *fights.Fight, user *users.User, spellID int) *FightResul
} }
} }
case spells.TypeSleep:
// For now, sleep spells do damage like the old system
newMonsterHP := max(fight.MonsterHP-spell.Power, 0)
result.FightUpdates = map[string]any{"monster_hp": newMonsterHP}
result.ActionText = fmt.Sprintf("You cast %s and dealt %d damage!", spell.Name, spell.Power)
result.LogAction = func() error { return fightlogs.AddSpellHurt(fight.ID, spell.Name, spell.Power) }
if newMonsterHP <= 0 {
monster, err := monsters.Find(fight.MonsterID)
if err == nil {
result.EndFightWithVictory(monster, user)
result.ActionText += " " + fmt.Sprintf("%s has been defeated!", monster.Name)
}
}
case spells.TypeUberAttack:
// Apply attack buff for next attack
result.FightUpdates = map[string]any{"uber_damage": spell.Power}
result.ActionText = fmt.Sprintf("You cast %s! Your next attack will be %d%% stronger!", spell.Name, spell.Power)
result.LogAction = func() error { return fightlogs.AddAction(fight.ID, result.ActionText) }
case spells.TypeUberDefense:
// Apply defense buff
result.FightUpdates = map[string]any{"uber_defense": spell.Power}
result.ActionText = fmt.Sprintf("You cast %s! You will take %d%% less damage!", spell.Name, spell.Power)
result.LogAction = func() error { return fightlogs.AddAction(fight.ID, result.ActionText) }
default: default:
result.ActionText = "You cast " + spell.Name + " but nothing happened!" result.ActionText = "You cast " + spell.Name + " but nothing happened!"
result.LogAction = func() error { return fightlogs.AddAction(fight.ID, result.ActionText) } result.LogAction = func() error { return fightlogs.AddAction(fight.ID, result.ActionText) }
@ -266,31 +294,6 @@ func HandleMonsterAttack(fight *fights.Fight, user *users.User) *FightResult {
return result return result
} }
type LevelStats struct {
Strength int
Dexterity int
}
func calculateLevelUp(currentLevel, newExp, currentStr, currentDex int) (int, LevelStats) {
level := currentLevel
str := currentStr
dex := currentDex
nexp := newExp
for {
expNeeded := exp.Calc(level + 1)
if nexp < expNeeded {
break
}
level++
str++
dex++
nexp -= expNeeded
}
return level, LevelStats{Strength: str, Dexterity: dex}
}
func findClosestTown(x, y int) *towns.Town { func findClosestTown(x, y int) *towns.Town {
allTowns, err := towns.All() allTowns, err := towns.All()
if err != nil || len(allTowns) == 0 { if err != nil || len(allTowns) == 0 {

View File

@ -2,7 +2,6 @@ package components
import ( import (
"dk/internal/helpers" "dk/internal/helpers"
"dk/internal/models/spells"
"dk/internal/models/towns" "dk/internal/models/towns"
"dk/internal/models/users" "dk/internal/models/users"
"fmt" "fmt"
@ -66,11 +65,12 @@ func RightAside(ctx sushi.Ctx) map[string]any {
} }
// Build known healing spells list // Build known healing spells list
if user.Spells != "" { userSpells, err := user.GetSpells()
if err == nil {
spellMap := helpers.NewOrderedMap[int, string]() spellMap := helpers.NewOrderedMap[int, string]()
for _, id := range user.GetSpellIDs() { for _, spell := range userSpells {
if spell, err := spells.Find(id); err == nil { if spell.IsHeal() {
spellMap.Set(id, spell.Name) spellMap.Set(spell.ID, spell.Name)
} }
} }
data["_spells"] = spellMap.ToSlice() data["_spells"] = spellMap.ToSlice()

View File

@ -8,29 +8,33 @@ import (
// Spell represents a spell in the game // Spell represents a spell in the game
type Spell struct { type Spell struct {
ID int ID int
Name string Type int
MP int Name string
Attribute int Lore string
Type int Icon string
MP int
Power int
} }
// SpellType constants for spell types // SpellType constants for spell types
const ( const (
TypeHealing = 1 TypeHeal = 0
TypeHurt = 2 TypeDamage = 1
TypeSleep = 3 TypeSleep = 2
TypeAttackBoost = 4 TypeUberAttack = 3
TypeDefenseBoost = 5 TypeUberDefense = 4
) )
// New creates a new Spell with sensible defaults // New creates a new Spell with sensible defaults
func New() *Spell { func New() *Spell {
return &Spell{ return &Spell{
Name: "", Type: TypeHeal,
MP: 5, Name: "",
Attribute: 10, Lore: "",
Type: TypeHealing, Icon: "",
MP: 5,
Power: 10,
} }
} }
@ -42,10 +46,10 @@ func (s *Spell) Validate() error {
if s.MP < 0 { if s.MP < 0 {
return fmt.Errorf("spell MP cannot be negative") return fmt.Errorf("spell MP cannot be negative")
} }
if s.Attribute < 0 { if s.Power < 0 {
return fmt.Errorf("spell Attribute cannot be negative") return fmt.Errorf("spell Power cannot be negative")
} }
if s.Type < TypeHealing || s.Type > TypeDefenseBoost { if s.Type < TypeHeal || s.Type > TypeUberDefense {
return fmt.Errorf("invalid spell type: %d", s.Type) return fmt.Errorf("invalid spell type: %d", s.Type)
} }
return nil return nil
@ -108,39 +112,83 @@ func ByName(name string) (*Spell, error) {
return &spell, nil return &spell, nil
} }
// Helper methods // Spell unlock functions
func (s *Spell) IsHealing() bool { func UnlocksForClassAtLevel(classID, level int) ([]*Spell, error) {
return s.Type == TypeHealing var spells []*Spell
query := `
SELECT s.* FROM spells s
JOIN spell_unlocks u ON s.id = u.spell_id
WHERE u.class_id = %d AND u.level = %d
ORDER BY s.type ASC, s.mp ASC, s.id ASC`
err := database.Select(&spells, query, classID, level)
return spells, err
} }
func (s *Spell) IsHurt() bool { func UserSpells(userID int) ([]*Spell, error) {
return s.Type == TypeHurt var spells []*Spell
query := `
SELECT s.* FROM spells s
JOIN user_spells us ON s.id = us.spell_id
WHERE us.user_id = %d
ORDER BY s.type ASC, s.mp ASC, s.id ASC`
err := database.Select(&spells, query, userID)
return spells, err
}
func GrantSpell(userID, spellID int) error {
return database.Exec("INSERT OR IGNORE INTO user_spells (user_id, spell_id) VALUES (%d, %d)", userID, spellID)
}
func GrantSpells(userID int, spellIDs []int) error {
return database.Transaction(func() error {
for _, spellID := range spellIDs {
if err := GrantSpell(userID, spellID); err != nil {
return err
}
}
return nil
})
}
func HasSpell(userID, spellID int) bool {
var count int
err := database.Get(&count, "SELECT COUNT(*) FROM user_spells WHERE user_id = %d AND spell_id = %d", userID, spellID)
return err == nil && count > 0
}
// Helper methods
func (s *Spell) IsHeal() bool {
return s.Type == TypeHeal
}
func (s *Spell) IsDamage() bool {
return s.Type == TypeDamage
} }
func (s *Spell) IsSleep() bool { func (s *Spell) IsSleep() bool {
return s.Type == TypeSleep return s.Type == TypeSleep
} }
func (s *Spell) IsAttackBoost() bool { func (s *Spell) IsUberAttack() bool {
return s.Type == TypeAttackBoost return s.Type == TypeUberAttack
} }
func (s *Spell) IsDefenseBoost() bool { func (s *Spell) IsUberDefense() bool {
return s.Type == TypeDefenseBoost return s.Type == TypeUberDefense
} }
func (s *Spell) TypeName() string { func (s *Spell) TypeName() string {
switch s.Type { switch s.Type {
case TypeHealing: case TypeHeal:
return "Healing" return "Heal"
case TypeHurt: case TypeDamage:
return "Hurt" return "Damage"
case TypeSleep: case TypeSleep:
return "Sleep" return "Sleep"
case TypeAttackBoost: case TypeUberAttack:
return "Attack Boost" return "Uber Attack"
case TypeDefenseBoost: case TypeUberDefense:
return "Defense Boost" return "Uber Defense"
default: default:
return "Unknown" return "Unknown"
} }
@ -154,13 +202,13 @@ func (s *Spell) Efficiency() float64 {
if s.MP == 0 { if s.MP == 0 {
return 0 return 0
} }
return float64(s.Attribute) / float64(s.MP) return float64(s.Power) / float64(s.MP)
} }
func (s *Spell) IsOffensive() bool { func (s *Spell) IsOffensive() bool {
return s.Type == TypeHurt || s.Type == TypeSleep return s.Type == TypeDamage || s.Type == TypeSleep
} }
func (s *Spell) IsSupport() bool { func (s *Spell) IsSupport() bool {
return s.Type == TypeHealing || s.Type == TypeAttackBoost || s.Type == TypeDefenseBoost return s.Type == TypeHeal || s.Type == TypeUberAttack || s.Type == TypeUberDefense
} }

View File

@ -10,6 +10,7 @@ import (
"dk/internal/helpers" "dk/internal/helpers"
"dk/internal/helpers/exp" "dk/internal/helpers/exp"
"dk/internal/models/classes" "dk/internal/models/classes"
"dk/internal/models/spells"
) )
// User represents a user in the game // User represents a user in the game
@ -208,16 +209,34 @@ func (u *User) IsAlive() bool {
return u.HP > 0 return u.HP > 0
} }
func (u *User) GetSpellIDs() []int { func (u *User) GetSpells() ([]*spells.Spell, error) {
return helpers.StringToInts(u.Spells) return spells.UserSpells(u.ID)
}
func (u *User) SetSpellIDs(spells []int) {
u.Spells = helpers.IntsToString(spells)
} }
func (u *User) HasSpell(spellID int) bool { func (u *User) HasSpell(spellID int) bool {
return slices.Contains(u.GetSpellIDs(), spellID) return spells.HasSpell(u.ID, spellID)
}
func (u *User) GrantSpell(spellID int) error {
return spells.GrantSpell(u.ID, spellID)
}
func (u *User) GrantSpells(spellIDs []int) error {
return spells.GrantSpells(u.ID, spellIDs)
}
func (u *User) LearnNewSpells() error {
newSpells, err := spells.UnlocksForClassAtLevel(u.ClassID, u.Level)
if err != nil {
return err
}
var spellIDs []int
for _, spell := range newSpells {
spellIDs = append(spellIDs, spell.ID)
}
return u.GrantSpells(spellIDs)
} }
func (u *User) GetTownIDs() []int { func (u *User) GetTownIDs() []int {
@ -273,6 +292,7 @@ func (u *User) ExpNeededForNextLevel() int {
} }
func (u *User) GrantExp(expAmount int) map[string]any { func (u *User) GrantExp(expAmount int) map[string]any {
oldLevel := u.Level
newLevel, newStr, newDex, newExp := u.CalculateLevelUp(expAmount) newLevel, newStr, newDex, newExp := u.CalculateLevelUp(expAmount)
updates := map[string]any{ updates := map[string]any{
@ -280,10 +300,18 @@ func (u *User) GrantExp(expAmount int) map[string]any {
} }
// Only include level/stats if they actually changed // Only include level/stats if they actually changed
if newLevel > u.Level { if newLevel > oldLevel {
updates["level"] = newLevel updates["level"] = newLevel
updates["strength"] = newStr updates["strength"] = newStr
updates["dexterity"] = newDex updates["dexterity"] = newDex
// Learn new spells for each level gained
for level := oldLevel + 1; level <= newLevel; level++ {
if err := u.learnSpellsForLevel(level); err != nil {
// Don't fail the level up if spells fail
fmt.Printf("Failed to grant spells for level %d to user %d: %v\n", level, u.ID, err)
}
}
} }
return updates return updates
@ -333,3 +361,17 @@ func (u *User) Class() *classes.Class {
} }
return class return class
} }
func (u *User) learnSpellsForLevel(level int) error {
newSpells, err := spells.UnlocksForClassAtLevel(u.ClassID, level)
if err != nil {
return err
}
var spellIDs []int
for _, spell := range newSpells {
spellIDs = append(spellIDs, spell.ID)
}
return u.GrantSpells(spellIDs)
}

View File

@ -165,6 +165,12 @@ func processRegister(ctx sushi.Ctx) {
return return
} }
// Grant level 1 spells for their class
if err := user.LearnNewSpells(); err != nil {
// Don't fail registration if spells fail, just log it
fmt.Printf("Failed to grant initial spells to user %d: %v\n", user.ID, err)
}
// Auto-login after registration // Auto-login after registration
ctx.Login(user.ID, user) ctx.Login(user.ID, user)

View File

@ -89,13 +89,10 @@ func showFight(ctx sushi.Ctx) {
monHpColor = "warning" monHpColor = "warning"
} }
spellMap := helpers.NewOrderedMap[int, *spells.Spell]() var userSpells []*spells.Spell
if user.Spells != "" { spellList, err := user.GetSpells()
for _, id := range user.GetSpellIDs() { if err == nil {
if spell, err := spells.Find(id); err == nil { userSpells = spellList
spellMap.Set(id, spell)
}
}
} }
// Get recent fight actions // Get recent fight actions
@ -107,7 +104,7 @@ func showFight(ctx sushi.Ctx) {
"monster": monster, "monster": monster,
"mon_hppct": monHpPct, "mon_hppct": monHpPct,
"mon_hpcol": monHpColor, "mon_hpcol": monHpColor,
"spells": spellMap.ToSlice(), "spells": userSpells,
"action": sess.GetFlashMessage("action"), "action": sess.GetFlashMessage("action"),
"mon_action": sess.GetFlashMessage("mon_action"), "mon_action": sess.GetFlashMessage("mon_action"),
"last_action": lastAction, "last_action": lastAction,

View File

@ -0,0 +1,57 @@
-- Migration 3: new spell system
-- Created: 2025-08-25 22:13:03
DROP TABLE IF EXISTS spells;
CREATE TABLE spells (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`type` INTEGER NOT NULL DEFAULT 0,
`name` TEXT NOT NULL,
`lore` TEXT DEFAULT '',
`icon` TEXT DEFAULT '',
`mp` INTEGER NOT NULL DEFAULT 0,
`power` INTEGER NOT NULL DEFAULT 0
);
-- Types: 0 (Heal), 1 (Damage), 2 (Sleep), 3 (Uber Attack), 4 (Uber Defense)
INSERT INTO spells VALUES
(1, 0, 'Heal', '', '', 5, 10),
(2, 0, 'Revive', '', '', 10, 25),
(3, 0, 'Life', '', '', 25, 50),
(4, 0, 'Breath', '', '', 50, 100),
(5, 0, 'Gaia', '', '', 75, 150),
(6, 1, 'Hurt', '', '', 5, 15),
(7, 1, 'Pain', '', '', 12, 35),
(8, 1, 'Maim', '', '', 25, 70),
(9, 1, 'Rend', '', '', 40, 100),
(10, 1, 'Chaos', '', '', 50, 130),
(11, 2, 'Sleep', '', '', 10, 5),
(12, 2, 'Dream', '', '', 30, 9),
(13, 2, 'Nightmare', '', '', 60, 13),
(14, 3, 'Craze', '', '', 10, 10),
(15, 3, 'Rage', '', '', 20, 25),
(16, 3, 'Fury', '', '', 30, 50),
(17, 4, 'Ward', '', '', 10, 10),
(18, 4, 'Fend', '', '', 20, 25),
(19, 4, 'Barrier', '', '', 30, 50),
(20, 2, 'Spark', 'Small jolt of electric energy.', '', 5, 10),
(21, 2, 'Firebolt', 'Blast of concentrated fire.', '', 10, 30),
(22, 2, 'Geyser', 'Explosion of high-pressure water.', '', 15, 60),
(23, 2, 'Magic Missile', 'Fast, tracking bolt of arcane force.', '', 20, 85);
CREATE TABLE spell_unlocks (
`spell_id` INTEGER NOT NULL,
`class_id` INTEGER NOT NULL,
`level` INTEGER NOT NULL
);
-- Classes: 1 (Adventurer), 2 (Mage), 3 (Warrior), 4 (Paladin)
INSERT INTO spell_unlocks VALUES
(1, 1, 3), (6, 1, 3), (11, 1, 7), (14, 1, 7), (17, 1, 7),
(20, 2, 1), (21, 2, 5), (22, 2, 12), (23, 2, 22), (11, 2, 7), (17, 2, 10), (19, 2, 24),
(1, 4, 1), (2, 4, 5), (3, 4, 10), (4, 4, 20);
CREATE TABLE user_spells (
`user_id` INTEGER NOT NULL,
`spell_id` INTEGER NOT NULL
);

View File

@ -22,7 +22,7 @@
<div class="mb-05"> <div class="mb-05">
<button type="submit" name="action" value="attack" class="btn btn-primary">Attack</button> <button type="submit" name="action" value="attack" class="btn btn-primary">Attack</button>
</div> </div>
{if user.Spells != ""} {if spells}
<div class="mb-05"> <div class="mb-05">
<select id="spell-select" class="styled-select" name="spell_id"> <select id="spell-select" class="styled-select" name="spell_id">
{for spell in spells} {for spell in spells}
@ -37,4 +37,4 @@
<button type="submit" name="action" value="run" class="btn">Run</button> <button type="submit" name="action" value="run" class="btn">Run</button>
</div> </div>
</form> </form>
{/block} {/block}

View File

@ -0,0 +1,5 @@
{include "layout.html"}
{block "content"}
<h1>Victory!</h1>
{/block}