aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-19 02:08:55 -0400
committerhistoria <[not public]>2026-06-19 02:08:55 -0400
commit458916fb78dcef3ff77cb29b7ba98d9f646819fc (patch)
tree19cd8b8eef41a1aa00d155bf7953ad2770f91a4c /internal
parent97a1a908b9e6e6d3516628c139c9b64262b4fe08 (diff)
downloadthehouseoficarus-458916fb78dcef3ff77cb29b7ba98d9f646819fc.tar.gz
feat: rough pharmacy skill added
Diffstat (limited to 'internal')
-rw-r--r--internal/combat/formulas.go83
-rw-r--r--internal/game/action.go2
-rw-r--r--internal/game/action_clean.go123
-rw-r--r--internal/game/action_production.go1
-rw-r--r--internal/game/action_state.go6
-rw-r--r--internal/game/cmd_attack.go143
-rw-r--r--internal/game/cmd_clean.go78
-rw-r--r--internal/game/cmd_look.go60
-rw-r--r--internal/game/cmd_mix.go113
-rw-r--r--internal/game/cmd_stats.go70
-rw-r--r--internal/game/equip_stats.go31
-rw-r--r--internal/game/game.go14
-rw-r--r--internal/game/utils.go13
-rw-r--r--internal/object/item.go20
-rw-r--r--internal/player/player.go7
-rw-r--r--internal/player/store.go4
-rw-r--r--internal/world/mob.go39
17 files changed, 746 insertions, 61 deletions
diff --git a/internal/combat/formulas.go b/internal/combat/formulas.go
index 0b3248b..963ee9c 100644
--- a/internal/combat/formulas.go
+++ b/internal/combat/formulas.go
@@ -2,27 +2,33 @@ package combat
import "math/rand"
-func AttackRoll(attackLevel int, styleBonus int, equipBonus int) int {
- return (attackLevel + styleBonus + 8) * (equipBonus + 64)
+func AttackRoll(level int, styleBonus int, equipBonus int) int {
+ effective := level + styleBonus + 8
+ return effective * (equipBonus + 64)
}
-func DefenseRoll(defenseLevel int, styleBonus int, equipBonus int) int {
- return (defenseLevel + styleBonus + 8) * (equipBonus + 64)
+func DefenseRoll(level int, styleBonus int, equipBonus int) int {
+ effective := level + styleBonus + 8
+ return effective * (equipBonus + 64)
}
-func HitCheck(attackRoll, defenseRoll int) bool {
- if attackRoll > defenseRoll {
- return true
- }
- if attackRoll < defenseRoll {
- return false
+func HitChance(attackRoll, defenseRoll int) float64 {
+ a := float64(attackRoll)
+ d := float64(defenseRoll)
+ if a > d {
+ return 1.0 - (d+2.0)/(2.0*(a+1.0))
}
- return rand.Intn(2) == 1
+ return a / (2.0 * (d + 1.0))
+}
+
+func HitCheck(attackRoll, defenseRoll int) bool {
+ chance := HitChance(attackRoll, defenseRoll)
+ return rand.Float64() < chance
}
-func MaxHit(strengthLevel int, styleBonus int, equipBonus int) int {
- effective := (strengthLevel + styleBonus + 8) * (equipBonus + 64)
- hit := effective / 512
+func MaxHit(level int, styleBonus int, equipBonus int) int {
+ effective := level + styleBonus + 8
+ hit := (effective * (equipBonus + 64)) / 512
if hit < 1 {
hit = 1
}
@@ -50,3 +56,52 @@ func AttackStyleBonus(style string) (attack, strength, defense int) {
return 0, 0, 0
}
}
+
+func RangedStyleBonus(style string) (ranged, defense int) {
+ switch style {
+ case "accurate":
+ return 3, 0
+ case "aggressive":
+ return 3, 0
+ case "defensive":
+ return 0, 3
+ case "balanced":
+ return 1, 1
+ default:
+ return 0, 0
+ }
+}
+
+func SelectAttackBonus(attackType string, stab, slash, crush, science, ranged int) int {
+ switch attackType {
+ case "stab":
+ return stab
+ case "slash":
+ return slash
+ case "crush":
+ return crush
+ case "science":
+ return science
+ case "ranged":
+ return ranged
+ default:
+ return crush
+ }
+}
+
+func SelectDefenseBonus(attackType string, stab, slash, crush, science, ranged int) int {
+ switch attackType {
+ case "stab":
+ return stab
+ case "slash":
+ return slash
+ case "crush":
+ return crush
+ case "science":
+ return science
+ case "ranged":
+ return ranged
+ default:
+ return crush
+ }
+}
diff --git a/internal/game/action.go b/internal/game/action.go
index ca8506a..641f4dd 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -254,6 +254,8 @@ func (g *Game) AdvanceActions() {
switch p.BackgroundAction.Type {
case "fletch":
g.advanceFletch(sess, p)
+ case "clean":
+ g.advanceClean(sess, p)
}
if p.BackgroundAction == nil {
g.writePrompt(sess)
diff --git a/internal/game/action_clean.go b/internal/game/action_clean.go
new file mode 100644
index 0000000..dab3619
--- /dev/null
+++ b/internal/game/action_clean.go
@@ -0,0 +1,123 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) advanceClean(sess *net.Session, p *player.Player) {
+ phase, _ := p.BackgroundAction.Data["phase"].(int)
+ filter, _ := p.BackgroundAction.Data["filter"].(string)
+
+ if phase == 0 {
+ p.BackgroundAction.Data["phase"] = 1
+ p.BackgroundAction.WaitLeft = engine.ToTicks(2)
+ return
+ }
+
+ allRecipes, err := g.RecipeStore.LoadAll()
+ if err != nil {
+ g.CancelBackgroundAction(p)
+ return
+ }
+
+ var recipe *cleanMatch
+ for _, r := range allRecipes {
+ if r.Type != "clean" {
+ continue
+ }
+ if filter != "" {
+ if len(r.Consume) == 0 || len(r.Consume[0].Items) == 0 {
+ continue
+ }
+ if !strings.Contains(r.Consume[0].Items[0], filter) &&
+ !strings.Contains(r.Output, filter) {
+ continue
+ }
+ }
+ if p.Level(player.Pharmacy) < r.Level {
+ continue
+ }
+ if !r.HasAllItems(p.HasItem) {
+ continue
+ }
+ recipe = &cleanMatch{r.Consume[0].Items[0], r.Output, r.XP, r.Message}
+ break
+ }
+
+ if recipe == nil {
+ sess.WriteLine("\nYou've finished cleaning herbs.")
+ g.CancelBackgroundAction(p)
+ return
+ }
+
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == recipe.consumeID {
+ slot.ItemID = recipe.outputID
+ break
+ }
+ }
+
+ if recipe.xp > 0 {
+ if newLevel := p.AddSkillXP(player.Pharmacy, recipe.xp); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up",
+ fmt.Sprintf("*** You are now level %d pharmacy! ***", newLevel)))
+ }
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ msg := recipe.message
+ if msg == "" {
+ outDef, _ := g.ItemStore.Load(recipe.outputID)
+ outputName := recipe.outputID
+ if outDef != nil {
+ outputName = outDef.Name
+ }
+ msg = fmt.Sprintf("You clean a %s.", outputName)
+ }
+ if p.OptionBool("xp_drops") && recipe.xp > 0 {
+ abbr := player.SkillAbbr[player.Pharmacy]
+ msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.xp, abbr))
+ }
+ sess.WriteLine(msg)
+
+ hasMore := false
+ for _, r := range allRecipes {
+ if r.Type != "clean" {
+ continue
+ }
+ if filter != "" {
+ if len(r.Consume) == 0 || len(r.Consume[0].Items) == 0 {
+ continue
+ }
+ if !strings.Contains(r.Consume[0].Items[0], filter) &&
+ !strings.Contains(r.Output, filter) {
+ continue
+ }
+ }
+ if p.Level(player.Pharmacy) >= r.Level && r.HasAllItems(p.HasItem) {
+ hasMore = true
+ break
+ }
+ }
+
+ if !hasMore {
+ sess.WriteLine("\nYou've finished cleaning herbs.")
+ g.CancelBackgroundAction(p)
+ return
+ }
+
+ p.BackgroundAction.WaitLeft = engine.ToTicks(2)
+}
+
+type cleanMatch struct {
+ consumeID string
+ outputID string
+ xp int
+ message string
+}
diff --git a/internal/game/action_production.go b/internal/game/action_production.go
index df23198..c44f3d6 100644
--- a/internal/game/action_production.go
+++ b/internal/game/action_production.go
@@ -33,6 +33,7 @@ var productionTypes = map[string]productionTypeInfo{
"crafting": {"craft", "crafting"},
"combine": {"combine", "combining"},
"fletching": {"fletch", "fletching"},
+ "pharmacy": {"mix", "mixing"},
}
var productionActionTypes map[string]bool
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index 4510afd..1935703 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -22,6 +22,8 @@ const (
ActionProducing ActionType = "producing"
ActionFletching ActionType = "fletching"
ActionEating ActionType = "eating"
+ ActionCleaning ActionType = "cleaning"
+ ActionMixing ActionType = "mixing"
)
type ActionState struct {
@@ -73,6 +75,10 @@ func (a *ActionState) Description() string {
return "fletching " + a.TargetName
case ActionEating:
return "eating " + a.TargetName
+ case ActionCleaning:
+ return "cleaning herbs"
+ case ActionMixing:
+ return "mixing " + a.TargetName
}
return ""
}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 4c6941a..4d2d19f 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -46,6 +46,15 @@ func (g *Game) doAttack(sess *net.Session, input string) {
return
}
+ if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
+ if def, err := g.ItemStore.Load(itemID); err == nil && def.WeaponType == object.WeaponRanged {
+ if _, hasAmmo := p.Equipment[object.SlotAmmo]; !hasAmmo || p.AmmoQty <= 0 {
+ sess.WriteLine("You don't have any ammo equipped.")
+ return
+ }
+ }
+ }
+
g.startCombat(sess, p, mob)
}
@@ -177,23 +186,51 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
return
}
- attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
-
- equipAtt := 0
- equipStr := 0
+ attackType := "crush"
+ var weaponType object.WeaponType
if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
- def, err := g.ItemStore.Load(itemID)
- if err == nil {
- equipAtt = def.Stats.AttackBonus
- equipStr = def.Stats.StrengthBonus
+ if def, err := g.ItemStore.Load(itemID); err == nil {
+ if def.AttackType != "" {
+ attackType = def.AttackType
+ } else if def.WeaponType == object.WeaponRanged {
+ attackType = "ranged"
+ } else if def.WeaponType == object.WeaponScience {
+ attackType = "science"
+ }
+ weaponType = def.WeaponType
}
}
- attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
- defRoll := combat.DefenseRoll(mob.Defense, 0, 0)
+ totals := g.playerEquipBonuses(p)
+ isRanged := weaponType == object.WeaponRanged
+
+ var attRoll, defRoll, maxHit int
+
+ if isRanged {
+ rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
+ equipAttack := totals.RangedAttack
+ attRoll = combat.AttackRoll(p.Level(player.Ranged), rangedBonus, equipAttack)
+
+ mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
+ mob.ScienceDefense, mob.RangedDefense)
+ defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ maxHit = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength)
+ } else {
+ attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
+ equipAttack := combat.SelectAttackBonus(attackType,
+ totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
+ totals.ScienceAttack, totals.RangedAttack)
+ attRoll = combat.AttackRoll(p.Level(player.Attack), attBonus, equipAttack)
+
+ mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
+ mob.ScienceDefense, mob.RangedDefense)
+ defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ maxHit = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
+ }
if combat.HitCheck(attRoll, defRoll) {
- maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr)
dmg := combat.RollDamage(maxHit)
mob.HP -= dmg
@@ -203,7 +240,16 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
if mob.HP < mob.MaxHP && mob.HP > 0 {
mob.StartRegen()
}
- gains, leveled := g.awardCombatXP(p, dmg)
+ gains, leveled := g.awardCombatXP(p, dmg, isRanged)
+
+ if isRanged {
+ p.AmmoQty--
+ if p.AmmoQty <= 0 {
+ delete(p.Equipment, object.SlotAmmo)
+ p.AmmoQty = 0
+ }
+ g.AccountStore.SaveCharacter(p)
+ }
for _, skill := range leveled {
sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill)))
@@ -230,27 +276,45 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")")
}
sess.WriteLine(line)
+
+ if isRanged && p.AmmoQty <= 0 {
+ sess.WriteLine("You've run out of ammo!")
+ combat.LeaveCombat(p.Name)
+ }
} else {
sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))))
+
+ if isRanged {
+ p.AmmoQty--
+ if p.AmmoQty <= 0 {
+ delete(p.Equipment, object.SlotAmmo)
+ p.AmmoQty = 0
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine("You've run out of ammo!")
+ combat.LeaveCombat(p.Name)
+ }
+ }
}
}
func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
+ _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
- equipDef := 0
- for _, itemID := range p.Equipment {
- def, err := g.ItemStore.Load(itemID)
- if err == nil {
- equipDef += def.Stats.DefenseBonus
- }
+ mobAttackType := mob.AttackType
+ if mobAttackType == "" {
+ mobAttackType = "crush"
}
- attRoll := combat.AttackRoll(mob.Attack, 0, 0)
- defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef)
+ attRoll := combat.AttackRoll(mob.Attack, 0, mob.AttackBonus)
+
+ totals := g.playerEquipBonuses(p)
+ equipDef := combat.SelectDefenseBonus(mobAttackType,
+ totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
+ totals.ScienceDefense, totals.RangedDefense)
+ defRoll := combat.DefenseRoll(p.Level(player.Defense), defStyleBonus, equipDef)
if combat.HitCheck(attRoll, defRoll) {
- maxHit := combat.MaxHit(mob.Strength, 0, 0)
+ maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus)
dmg := combat.RollDamage(maxHit)
p.HP -= dmg
@@ -375,25 +439,32 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
}
}
-func (g *Game) awardCombatXP(p *player.Player, dmg int) ([]xpGain, []player.SkillName) {
+func (g *Game) awardCombatXP(p *player.Player, dmg int, isRanged bool) ([]xpGain, []player.SkillName) {
baseXP := dmg * 4
var gains []xpGain
var leveledUp []player.SkillName
- switch p.AttackStyle {
- case player.Accurate:
- gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
- case player.Aggressive:
- gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
- case player.Defensive:
- gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
- case player.Balanced:
- quarter := baseXP / 4
+ if isRanged {
gains = []xpGain{
- {string(player.Attack), quarter},
- {string(player.Strength), quarter},
- {string(player.Defense), quarter},
- {string(player.Hitpoints), quarter},
+ {string(player.Ranged), baseXP * 3 / 4},
+ {string(player.Hitpoints), baseXP / 4},
+ }
+ } else {
+ switch p.AttackStyle {
+ case player.Accurate:
+ gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ case player.Aggressive:
+ gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ case player.Defensive:
+ gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ case player.Balanced:
+ quarter := baseXP / 4
+ gains = []xpGain{
+ {string(player.Attack), quarter},
+ {string(player.Strength), quarter},
+ {string(player.Defense), quarter},
+ {string(player.Hitpoints), quarter},
+ }
}
}
diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go
new file mode 100644
index 0000000..b3d8408
--- /dev/null
+++ b/internal/game/cmd_clean.go
@@ -0,0 +1,78 @@
+package game
+
+import (
+ "strings"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doClean(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ allRecipes, err := g.RecipeStore.LoadAll()
+ if err != nil {
+ sess.WriteLine("Error loading recipes.")
+ return
+ }
+
+ filter := strings.TrimSpace(input)
+
+ found := false
+ for _, r := range allRecipes {
+ if r.Type != "clean" {
+ continue
+ }
+ if filter != "" && !matchesCleanFilter(r, filter) {
+ continue
+ }
+ if p.Level(player.Pharmacy) < r.Level {
+ continue
+ }
+ if !r.HasAllItems(p.HasItem) {
+ continue
+ }
+ found = true
+ break
+ }
+
+ if !found {
+ sess.WriteLine("You don't have any herbs to clean.")
+ return
+ }
+
+ g.CancelBackgroundAction(p)
+
+ p.BackgroundAction = &action.Action{
+ Type: "clean",
+ TargetID: "clean_herbs",
+ Data: map[string]any{
+ "phase": 0,
+ "filter": filter,
+ },
+ WaitLeft: engine.ToTicks(1),
+ }
+ p.BackgroundActionState = &ActionState{Type: ActionCleaning}
+
+ sess.WriteLine("\nYou begin cleaning herbs.")
+}
+
+func matchesCleanFilter(r action.RecipeDef, filter string) bool {
+ if len(r.Consume) > 0 && len(r.Consume[0].Items) > 0 {
+ if strings.Contains(r.Consume[0].Items[0], filter) {
+ return true
+ }
+ }
+ if strings.Contains(r.Output, filter) {
+ return true
+ }
+ return false
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 9809e9b..1c9eb9c 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -455,6 +455,18 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
fmt.Sprintf(" Defense: %d", best.Defense),
fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP),
)
+ if best.StabDefense != 0 || best.SlashDefense != 0 || best.CrushDefense != 0 ||
+ best.ScienceDefense != 0 || best.RangedDefense != 0 {
+ sess.WriteLines(
+ "",
+ " Defense bonuses:",
+ fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense),
+ fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense),
+ )
+ }
+ if best.Weakness != "" {
+ sess.WriteLine(fmt.Sprintf(" Weakness: %s", best.Weakness))
+ }
return
}
@@ -511,6 +523,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
fmt.Sprintf(" %s", def.Description),
fmt.Sprintf(" Value: %d credits", def.Value),
)
+ g.showItemStats(sess, def)
return
}
@@ -533,6 +546,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
lines = append(lines, fmt.Sprintf(" Has %d units of butane left.", slot.Quality))
}
sess.WriteLines(lines...)
+ g.showItemStats(sess, def)
return
}
@@ -711,3 +725,49 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine
}
}
}
+
+func (g *Game) showItemStats(sess *net.Session, def *object.ItemDef) {
+ s := def.Stats
+ hasAttack := s.StabAttack != 0 || s.SlashAttack != 0 || s.CrushAttack != 0 ||
+ s.ScienceAttack != 0 || s.RangedAttack != 0
+ hasDefense := s.StabDefense != 0 || s.SlashDefense != 0 || s.CrushDefense != 0 ||
+ s.ScienceDefense != 0 || s.RangedDefense != 0
+ hasOther := s.StrengthBonus != 0 || s.RangedStrength != 0 ||
+ s.ScienceDamage != 0 || s.TechnologyBonus != 0
+
+ if !hasAttack && !hasDefense && !hasOther {
+ return
+ }
+
+ sess.WriteLine("")
+ if hasAttack || hasDefense {
+ sess.WriteLine(" Attack bonuses: Defense bonuses:")
+ sess.WriteLine(fmt.Sprintf(" Stab: %+4d Stab: %+4d", s.StabAttack, s.StabDefense))
+ sess.WriteLine(fmt.Sprintf(" Slash: %+4d Slash: %+4d", s.SlashAttack, s.SlashDefense))
+ sess.WriteLine(fmt.Sprintf(" Crush: %+4d Crush: %+4d", s.CrushAttack, s.CrushDefense))
+ sess.WriteLine(fmt.Sprintf(" Science:%+4d Science:%+4d", s.ScienceAttack, s.ScienceDefense))
+ sess.WriteLine(fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", s.RangedAttack, s.RangedDefense))
+ }
+ if hasOther {
+ sess.WriteLine("")
+ sess.WriteLine(" Other bonuses:")
+ if s.StrengthBonus != 0 {
+ sess.WriteLine(fmt.Sprintf(" Melee strength: %+d", s.StrengthBonus))
+ }
+ if s.RangedStrength != 0 {
+ sess.WriteLine(fmt.Sprintf(" Ranged strength: %+d", s.RangedStrength))
+ }
+ if s.ScienceDamage != 0 {
+ sess.WriteLine(fmt.Sprintf(" Science damage: %+d", s.ScienceDamage))
+ }
+ if s.TechnologyBonus != 0 {
+ sess.WriteLine(fmt.Sprintf(" Technology: %+d", s.TechnologyBonus))
+ }
+ }
+ if def.AttackType != "" {
+ sess.WriteLine(fmt.Sprintf(" Attack type: %s", def.AttackType))
+ }
+ if def.Speed > 0 {
+ sess.WriteLine(fmt.Sprintf(" Speed: %.0f", def.Speed))
+ }
+}
diff --git a/internal/game/cmd_mix.go b/internal/game/cmd_mix.go
new file mode 100644
index 0000000..3082870
--- /dev/null
+++ b/internal/game/cmd_mix.go
@@ -0,0 +1,113 @@
+package game
+
+import (
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) doMix(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ g.CancelAction(p)
+
+ allRecipes, err := g.RecipeStore.LoadAll()
+ if err != nil {
+ sess.WriteLine("Error loading recipes.")
+ return
+ }
+
+ if input == "" {
+ g.showMixMenu(sess, p, allRecipes)
+ return
+ }
+
+ qty, itemName := parseQty(input)
+ _ = qty
+
+ var matched []action.RecipeDef
+ for _, r := range allRecipes {
+ if r.Type != "pharmacy" {
+ continue
+ }
+ outDef, _ := g.ItemStore.Load(r.Output)
+ name := r.Output
+ if outDef != nil {
+ name = outDef.Name
+ }
+ if world.WordPrefixMatch(itemName, name) {
+ matched = append(matched, r)
+ }
+ }
+
+ if len(matched) == 0 {
+ sess.WriteLine("You can't mix that.")
+ return
+ }
+
+ var available []action.RecipeDef
+ for _, r := range matched {
+ if r.HasAllItems(p.HasItem) && p.Level(player.Pharmacy) >= r.Level {
+ available = append(available, r)
+ }
+ }
+
+ if len(available) == 0 {
+ sess.WriteLine("You don't have the materials for that.")
+ return
+ }
+
+ if len(available) == 1 {
+ g.promptHowMany(sess, available[0].ID)
+ return
+ }
+
+ sess.State = net.StateRecipeChoice
+ var names []string
+ for _, r := range available {
+ names = append(names, g.recipeName(sess, &r))
+ }
+ g.showMenuTable(sess, "What would you like to mix?", names)
+ sess.PendingMenu = recipeMenuData(available)
+}
+
+func (g *Game) showMixMenu(sess *net.Session, p *player.Player, allRecipes []action.RecipeDef) {
+ seen := make(map[string]bool)
+ var entries []recipeEntry
+
+ for _, r := range allRecipes {
+ if r.Type != "pharmacy" {
+ continue
+ }
+ if seen[r.ID] {
+ continue
+ }
+ if !r.HasAllItems(p.HasItem) {
+ continue
+ }
+ seen[r.ID] = true
+ entries = append(entries, recipeEntry{g.recipeName(sess, &r), r})
+ }
+
+ if len(entries) == 0 {
+ sess.WriteLine("You don't have anything you can mix.")
+ return
+ }
+
+ if len(entries) == 1 {
+ if p.OptionBool("mix_all") {
+ g.startProductionFromRecipe(sess, p, &entries[0].Recipe, 0)
+ return
+ }
+ g.promptHowMany(sess, entries[0].Recipe.ID)
+ return
+ }
+
+ sess.State = net.StateRecipeChoice
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.ItemName)
+ }
+ g.showMenuTable(sess, "What would you like to mix?", names)
+ sess.PendingMenu = entryMenuData(entries)
+}
diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go
new file mode 100644
index 0000000..0dc963e
--- /dev/null
+++ b/internal/game/cmd_stats.go
@@ -0,0 +1,70 @@
+package game
+
+import (
+ "fmt"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doStats(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ totals := g.playerEquipBonuses(p)
+
+ attackType := "crush"
+ weaponName := "unarmed"
+ var weaponType object.WeaponType
+ if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
+ if def, err := g.ItemStore.Load(itemID); err == nil {
+ weaponName = def.Name
+ weaponType = def.WeaponType
+ if def.AttackType != "" {
+ attackType = def.AttackType
+ } else if def.WeaponType == object.WeaponRanged {
+ attackType = "ranged"
+ } else if def.WeaponType == object.WeaponScience {
+ attackType = "science"
+ }
+ }
+ }
+
+ sess.WriteLines(
+ "",
+ fmt.Sprintf("Weapon: %s (attack type: %s)", weaponName, attackType),
+ "",
+ "Attack bonuses: Defense bonuses:",
+ fmt.Sprintf(" Stab: %+4d Stab: %+4d", totals.StabAttack, totals.StabDefense),
+ fmt.Sprintf(" Slash: %+4d Slash: %+4d", totals.SlashAttack, totals.SlashDefense),
+ fmt.Sprintf(" Crush: %+4d Crush: %+4d", totals.CrushAttack, totals.CrushDefense),
+ fmt.Sprintf(" Science:%+4d Science:%+4d", totals.ScienceAttack, totals.ScienceDefense),
+ fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", totals.RangedAttack, totals.RangedDefense),
+ "",
+ "Other bonuses:",
+ fmt.Sprintf(" Melee strength: %+d", totals.StrengthBonus),
+ fmt.Sprintf(" Ranged strength: %+d", totals.RangedStrength),
+ fmt.Sprintf(" Science damage: %+d", totals.ScienceDamage),
+ fmt.Sprintf(" Technology: %+d", totals.TechnologyBonus),
+ )
+
+ var attRoll, maxHitVal int
+ if weaponType == object.WeaponRanged {
+ rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
+ attRoll = combat.AttackRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack)
+ maxHitVal = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength)
+ } else {
+ attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
+ equipAtt := combat.SelectAttackBonus(attackType,
+ totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
+ totals.ScienceAttack, totals.RangedAttack)
+ attRoll = combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
+ maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
+ }
+
+ sess.WriteLines(
+ "",
+ fmt.Sprintf("Style: %s Attack roll: %d Max hit: %d",
+ p.AttackStyle, attRoll, maxHitVal),
+ )
+}
diff --git a/internal/game/equip_stats.go b/internal/game/equip_stats.go
new file mode 100644
index 0000000..559671f
--- /dev/null
+++ b/internal/game/equip_stats.go
@@ -0,0 +1,31 @@
+package game
+
+import (
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats {
+ var totals object.ItemStats
+ for _, itemID := range p.Equipment {
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ continue
+ }
+ totals.StabAttack += def.Stats.StabAttack
+ totals.SlashAttack += def.Stats.SlashAttack
+ totals.CrushAttack += def.Stats.CrushAttack
+ totals.ScienceAttack += def.Stats.ScienceAttack
+ totals.RangedAttack += def.Stats.RangedAttack
+ totals.StabDefense += def.Stats.StabDefense
+ totals.SlashDefense += def.Stats.SlashDefense
+ totals.CrushDefense += def.Stats.CrushDefense
+ totals.ScienceDefense += def.Stats.ScienceDefense
+ totals.RangedDefense += def.Stats.RangedDefense
+ totals.StrengthBonus += def.Stats.StrengthBonus
+ totals.RangedStrength += def.Stats.RangedStrength
+ totals.ScienceDamage += def.Stats.ScienceDamage
+ totals.TechnologyBonus += def.Stats.TechnologyBonus
+ }
+ return totals
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index a1cba83..8b5f924 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -139,7 +139,7 @@ func classifyCommand(cmd string) CommandClass {
"look", "l", "exits", "help",
"map", "option", "options", "alias", "unalias",
"description", "desc", "queued", "color", "colors",
- "colortable", "prompt", "style":
+ "colortable", "prompt", "style", "stats":
return ClassInstant
case "equip", "eq", "equipment", "wear", "wield", "remove", "unwear", "unwield":
return ClassFree
@@ -147,9 +147,9 @@ func classifyCommand(cmd string) CommandClass {
"attack", "kill",
"north", "n", "south", "s", "east", "e",
"west", "w", "up", "u", "down", "d",
- "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft":
+ "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "mix":
return ClassActive
- case "eat", "fletch":
+ case "eat", "fletch", "clean":
return ClassFree
}
if _, ok := verbAliases[cmd]; ok {
@@ -314,6 +314,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
}
case "sc", "score":
g.doScore(sess)
+ case "stats":
+ g.doStats(sess)
case "i", "inv", "inventory":
g.doInventory(sess)
case "quit":
@@ -385,6 +387,12 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
case "fletch":
g.doFletch(sess, strings.Join(args, " "))
return
+ case "clean":
+ g.doClean(sess, strings.Join(args, " "))
+ return
+ case "mix":
+ g.doMix(sess, strings.Join(args, " "))
+ return
case "talk", "speak", "ask":
g.CancelAction(p)
if len(args) == 0 {
diff --git a/internal/game/utils.go b/internal/game/utils.go
index 55e48c6..6a294c5 100644
--- a/internal/game/utils.go
+++ b/internal/game/utils.go
@@ -45,7 +45,18 @@ func mobDisplayName(m *world.MobInstance, definite bool) string {
}
func mobCombatLevel(m *world.MobInstance) int {
- return int(0.25*float64(m.Attack+m.Strength+m.Defense+m.MaxHP) + 0.5)
+ base := float64(m.Defense+m.MaxHP) / 4.0
+ melee := float64(m.Attack+m.Strength) / 4.0
+ ranged := float64(m.Ranged) * 3.0 / 8.0
+ science := float64(m.Science) * 3.0 / 8.0
+ best := melee
+ if ranged > best {
+ best = ranged
+ }
+ if science > best {
+ best = science
+ }
+ return int(base + best + 0.5)
}
func uniqueItemNames(matches []itemMatch) []string {
diff --git a/internal/object/item.go b/internal/object/item.go
index a6a06e1..14cb65c 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -39,6 +39,7 @@ type ItemDef struct {
Stackable bool `yaml:"stackable"`
EquipSlot EquipSlot `yaml:"equip_slot"`
WeaponType WeaponType `yaml:"weapon_type"`
+ AttackType string `yaml:"attack_type"`
Stats ItemStats `yaml:"stats"`
Speed float64 `yaml:"speed"`
ToolType string `yaml:"tool_type"`
@@ -65,10 +66,21 @@ type MadeFromEntry struct {
}
type ItemStats struct {
- AttackBonus int `yaml:"attack_bonus"`
- StrengthBonus int `yaml:"strength_bonus"`
- DefenseBonus int `yaml:"defense_bonus"`
- ScienceBonus int `yaml:"science_bonus"`
+ StabAttack int `yaml:"stab_attack"`
+ SlashAttack int `yaml:"slash_attack"`
+ CrushAttack int `yaml:"crush_attack"`
+ ScienceAttack int `yaml:"science_attack"`
+ RangedAttack int `yaml:"ranged_attack"`
+
+ StabDefense int `yaml:"stab_defense"`
+ SlashDefense int `yaml:"slash_defense"`
+ CrushDefense int `yaml:"crush_defense"`
+ ScienceDefense int `yaml:"science_defense"`
+ RangedDefense int `yaml:"ranged_defense"`
+
+ StrengthBonus int `yaml:"strength_bonus"`
+ RangedStrength int `yaml:"ranged_strength"`
+ ScienceDamage int `yaml:"science_damage"`
TechnologyBonus int `yaml:"technology_bonus"`
}
diff --git a/internal/player/player.go b/internal/player/player.go
index edfe5dd..4b26a84 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -21,7 +21,7 @@ const (
Smithing SkillName = "smithing"
Crafting SkillName = "crafting"
Fletching SkillName = "fletching"
- Alchemy SkillName = "alchemy"
+ Pharmacy SkillName = "pharmacy"
Thieving SkillName = "thieving"
Agility SkillName = "agility"
Construction SkillName = "construction"
@@ -34,7 +34,7 @@ const (
var AllSkills = []SkillName{
Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology,
Fishing, Cooking, Woodcutting, Firemaking, Mining, Smithing, Crafting, Fletching,
- Alchemy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming,
+ Pharmacy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming,
}
var SkillAbbr = map[SkillName]string{
@@ -53,7 +53,7 @@ var SkillAbbr = map[SkillName]string{
Smithing: "smt",
Crafting: "cft",
Fletching: "flt",
- Alchemy: "alc",
+ Pharmacy: "pha",
Thieving: "thv",
Agility: "agl",
Construction: "con",
@@ -131,6 +131,7 @@ var OptionDefs = []OptionDef{
{"smith_all", OptBool, false, nil, "Auto-start smithing when only one product is possible"},
{"cook_all", OptBool, false, nil, "Auto-start cooking when only one product is possible"},
{"smelt_all", OptBool, false, nil, "Auto-start smelting when only one product is possible"},
+ {"mix_all", OptBool, false, nil, "Auto-start mixing when only one product is possible"},
}
var optionByName map[string]*OptionDef
diff --git a/internal/player/store.go b/internal/player/store.go
index 04af594..c05b29d 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -89,6 +89,10 @@ func (s *AccountStore) LoadCharacter(name string) (*Player, error) {
if p.Skills == nil {
p.Skills = make(map[SkillName]int)
}
+ if xp, ok := p.Skills["alchemy"]; ok {
+ p.Skills[Pharmacy] = xp
+ delete(p.Skills, "alchemy")
+ }
if p.Equipment == nil {
p.Equipment = make(map[object.EquipSlot]string)
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index d801b59..daf73dc 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -28,12 +28,26 @@ type MobDef struct {
Strength int `yaml:"strength"`
Defense int `yaml:"defense"`
HP int `yaml:"hp"`
+ Ranged int `yaml:"ranged"`
+ Science int `yaml:"science"`
Speed float64 `yaml:"speed"`
Aggressive bool `yaml:"aggressive"`
Protected bool `yaml:"protected"`
Unique bool `yaml:"unique"`
RespawnTicks float64 `yaml:"respawn_ticks"`
Drops DropTable `yaml:"drops"`
+
+ AttackBonus int `yaml:"attack_bonus"`
+ StrengthBonus int `yaml:"strength_bonus"`
+ AttackType string `yaml:"attack_type"`
+
+ StabDefense int `yaml:"stab_defense"`
+ SlashDefense int `yaml:"slash_defense"`
+ CrushDefense int `yaml:"crush_defense"`
+ ScienceDefense int `yaml:"science_defense"`
+ RangedDefense int `yaml:"ranged_defense"`
+
+ Weakness string `yaml:"weakness"`
}
type MobInstance struct {
@@ -46,6 +60,8 @@ type MobInstance struct {
Attack int
Strength int
Defense int
+ Ranged int
+ Science int
Speed float64
Aggressive bool
Protected bool
@@ -59,6 +75,18 @@ type MobInstance struct {
WanderInterval float64
WanderTickCounter int
regenerateTick int
+
+ AttackBonus int
+ StrengthBonus int
+ AttackType string
+
+ StabDefense int
+ SlashDefense int
+ CrushDefense int
+ ScienceDefense int
+ RangedDefense int
+
+ Weakness string
}
const (
@@ -243,6 +271,8 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
Attack: dw.def.Attack,
Strength: dw.def.Strength,
Defense: dw.def.Defense,
+ Ranged: dw.def.Ranged,
+ Science: dw.def.Science,
Speed: dw.def.Speed,
Aggressive: dw.def.Aggressive,
Protected: dw.def.Protected,
@@ -253,6 +283,15 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
RoomID: roomID,
HomeRoomID: roomID,
Drops: dw.def.Drops,
+ AttackBonus: dw.def.AttackBonus,
+ StrengthBonus: dw.def.StrengthBonus,
+ AttackType: dw.def.AttackType,
+ StabDefense: dw.def.StabDefense,
+ SlashDefense: dw.def.SlashDefense,
+ CrushDefense: dw.def.CrushDefense,
+ ScienceDefense: dw.def.ScienceDefense,
+ RangedDefense: dw.def.RangedDefense,
+ Weakness: dw.def.Weakness,
}
inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions)
s.instances[instID] = inst