aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act.go14
-rw-r--r--internal/game/act_combine.go12
-rw-r--r--internal/game/act_farm.go22
-rw-r--r--internal/game/act_gather.go28
-rw-r--r--internal/game/act_state.go10
-rw-r--r--internal/game/act_steal.go12
-rw-r--r--internal/game/act_use.go4
-rw-r--r--internal/game/cmd_attack.go2
-rw-r--r--internal/game/cmd_dig.go1
-rw-r--r--internal/game/cmd_room_insert.go2
-rw-r--r--internal/game/cmd_smelt.go2
-rw-r--r--internal/game/cmd_stats.go29
-rw-r--r--internal/game/cmd_trigger_combat.go34
-rw-r--r--internal/game/cmd_trigger_enchant.go5
-rw-r--r--internal/game/cmd_trigger_utility.go2
-rw-r--r--internal/game/cmd_use.go2
-rw-r--r--internal/game/combat_attack.go79
-rw-r--r--internal/game/combat_mob.go317
-rw-r--r--internal/game/combat_mob_test.go61
-rw-r--r--internal/game/core_equip.go2
-rw-r--r--internal/game/core_login_char.go23
-rw-r--r--internal/game/core_utils.go21
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/map_test.go8
-rw-r--r--internal/game/render_map.go19
-rw-r--r--internal/game/sys_hazard.go8
-rw-r--r--internal/game/sys_safespot.go20
-rw-r--r--internal/game/sys_science.go25
-rw-r--r--internal/game/sys_technology.go23
-rw-r--r--internal/game/sys_triggers.go8
-rw-r--r--internal/game/tick.go6
31 files changed, 492 insertions, 311 deletions
diff --git a/internal/game/act.go b/internal/game/act.go
index 6d4a208..9e07886 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -236,13 +236,13 @@ func (g *Game) AdvanceActions() {
g.advanceObstacle(sess, p)
case behavior.TypeTalk:
g.advanceTalk(sess, p)
- case behavior.TypeTriggerModule:
- g.advanceTriggerModule(sess, p)
- case behavior.TypeUseInteraction:
- g.advanceUseInteraction(sess, p)
- case behavior.TypeCombine:
- g.advanceCombine(sess, p)
- default:
+ case behavior.TypeTriggerModule:
+ g.advanceTriggerModule(sess, p)
+ case behavior.TypeUseInteraction:
+ g.advanceUseInteraction(sess, p)
+ case behavior.TypeCombine:
+ g.advanceCombine(sess, p)
+ default:
if productionActionTypes[string(p.Action.Type)] {
g.advanceProduction(sess, p)
}
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index db7fee1..3d744db 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -32,12 +32,12 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item
TargetID: item.ID,
TargetName: outputName,
Data: &behavior.CombineData{
- ItemID: item.ID,
- Phase: 0,
- StepIndex: 0,
- Remaining: count,
- StartMessage: startMsg,
- EndMessage: endMsg,
+ ItemID: item.ID,
+ Phase: 0,
+ StepIndex: 0,
+ Remaining: count,
+ StartMessage: startMsg,
+ EndMessage: endMsg,
TicksPerCycle: craft.TicksPerCycle,
},
WaitLeft: engine.ToTicks(1),
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index 6060dca..8ed0d1b 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -113,8 +113,8 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
if diseased {
g.setPlayerFlag(p, prefix+"_dead", true)
g.setPlayerFlag(p, prefix+"_diseased", false)
- sess.WriteLine(g.colorize(sess, "farm_disease",
- fmt.Sprintf("Your %s has died from disease!", g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_disease",
+ fmt.Sprintf("Your %s has died from disease!", g.seedDisplayName(sess, seedID))))
g.AccountStore.SaveCharacter(p)
continue
}
@@ -138,23 +138,23 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_ready", true)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_grow",
- fmt.Sprintf("Your %s is fully grown and ready to harvest!",
- g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_grow",
+ fmt.Sprintf("Your %s is fully grown and ready to harvest!",
+ g.seedDisplayName(sess, seedID))))
} else {
if !watered && rand.Float64() < 0.10 {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_diseased", true)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_disease",
- fmt.Sprintf("Your %s has become diseased!",
- g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_disease",
+ fmt.Sprintf("Your %s has become diseased!",
+ g.seedDisplayName(sess, seedID))))
} else {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_grow",
- fmt.Sprintf("Your %s has grown to stage %d/%d.",
- g.seedDisplayName(sess, seedID), stage, maxStages)))
+ sess.WriteLine(g.colorize(sess, "farm_grow",
+ fmt.Sprintf("Your %s has grown to stage %d/%d.",
+ g.seedDisplayName(sess, seedID), stage, maxStages)))
}
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index c34c6ae..f7a63f6 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -114,12 +114,12 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
}
d := &behavior.GatherData{
- ObjDefID: obj.ID,
- InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
- InstanceIdx: st.Index + 1,
- Wait: wait,
- Verb: verb,
- ToolName: toolName,
+ ObjDefID: obj.ID,
+ InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
+ InstanceIdx: st.Index + 1,
+ Wait: wait,
+ Verb: verb,
+ ToolName: toolName,
}
if cfg.DepleteTimer > 0 {
d.DepleteTimer = true
@@ -225,14 +225,14 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
g.AccountStore.SaveCharacter(p)
- msg := drop.SuccessMessage
- coloredName := g.itemColorize(sess, itemDef, itemName)
- if msg == "" {
- msg = fmt.Sprintf("You manage to get some %s.", coloredName)
- } else {
- msg = strings.ReplaceAll(msg, "%n", coloredName)
- msg = color.ExpandTags(g.colorMode(sess), msg)
- }
+ msg := drop.SuccessMessage
+ coloredName := g.itemColorize(sess, itemDef, itemName)
+ if msg == "" {
+ msg = fmt.Sprintf("You manage to get some %s.", coloredName)
+ } else {
+ msg = strings.ReplaceAll(msg, "%n", coloredName)
+ msg = color.ExpandTags(g.colorMode(sess), msg)
+ }
if xp > 0 && p.OptionBool("xp_drops") {
msg += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), xp)
}
diff --git a/internal/game/act_state.go b/internal/game/act_state.go
index c4ccaba..efc5f7d 100644
--- a/internal/game/act_state.go
+++ b/internal/game/act_state.go
@@ -81,11 +81,11 @@ func (g *Game) playerActionDisplay(p *player.Player) string {
return "mixing some " + a.TargetName
case behavior.TypeConstruct:
return "constructing some " + a.TargetName
- case behavior.TypeTriggerModule:
- return "triggering " + a.TargetName
- case behavior.TypeUseInteraction:
- return "using " + a.TargetName
- }
+ case behavior.TypeTriggerModule:
+ return "triggering " + a.TargetName
+ case behavior.TypeUseInteraction:
+ return "using " + a.TargetName
+ }
}
if a := p.BackgroundAction; a != nil {
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index f5d3dbf..a64bc96 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -37,18 +37,18 @@ var stallGuardTalk = &behavior.TalkConfig{
},
"bribe": {
Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."},
- Action: &behavior.NodeAction{Credits: -500},
- Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
+ Action: &behavior.NodeAction{Credits: -500},
+ Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
},
"jail": {
Messages: []string{"Off to the detention cell with you!"},
- Action: &behavior.NodeAction{Teleport: 162},
- Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
+ Action: &behavior.NodeAction{Teleport: 162},
+ Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
Messages: []string{"Then defend yourself!"},
- Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}},
- Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
+ Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}},
+ Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
},
},
}
diff --git a/internal/game/act_use.go b/internal/game/act_use.go
index 46ca304..dcddd52 100644
--- a/internal/game/act_use.go
+++ b/internal/game/act_use.go
@@ -81,8 +81,8 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
skillLevel := p.Level(player.SkillName(cfg.Skill))
chance := behavior.SuccessChance(*cfg.Success, skillLevel, cfg.Level)
if rand.Float64() >= chance {
- if cfg.FailMessage != "" {
- sess.WriteLine(cfg.FailMessage)
+ if cfg.FailMessage != "" {
+ sess.WriteLine(cfg.FailMessage)
}
p.Action.WaitLeft = engine.ToTicks(cfg.TicksPerCycle)
return
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index d60bb05..16e7587 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -43,7 +43,7 @@ func (g *Game) respawnMob(instanceID string) {
return
}
homeRoom := inst.HomeRoomID
- inst.RoomID = homeRoom
+ g.MobStore.SetInstanceRoom(instanceID, homeRoom)
inst.HP = inst.MaxHP
g.MobStore.RollIdleDescription(inst)
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index 0691b90..96a5421 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -263,7 +263,6 @@ func findNextRoomID(currentRoomPath string) (int, error) {
}
}
-
func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][3]int, roomAt map[[3]int]int) {
roomIndex := g.World.RoomIndex()
rg := world.BuildGrid(fromRoomID,
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 6164e50..5c24d60 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -124,7 +124,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
{Text: "A featureless room."},
},
Exits: map[world.ExitDir]world.ExitDef{
- dir: {Room: targetID},
+ dir: {Room: targetID},
oppositeDir: {Room: p.RoomID},
},
}
diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go
index 4500c64..5f7a7b3 100644
--- a/internal/game/cmd_smelt.go
+++ b/internal/game/cmd_smelt.go
@@ -23,7 +23,7 @@ func (g *Game) doSmelt(sess *net.Session, input string) {
return
}
- items := g.CraftIndex.BySubtype("smelting")
+ items := g.CraftIndex.BySubtype("smelting")
if input == "" {
g.showSmeltMenu(sess, p, items, stationDefID, stationName)
diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go
index 0430c6e..6942272 100644
--- a/internal/game/cmd_stats.go
+++ b/internal/game/cmd_stats.go
@@ -17,27 +17,10 @@ func (g *Game) doStats(sess *net.Session) {
p := sess.Player
totals := g.playerEquipBonuses(p)
- attackType := "crush"
weaponName := "unarmed"
- var weaponType item.EquipmentType
- var weaponDef *item.ItemDef
- if itemID, ok := p.Equipment[item.SlotMainHand]; ok {
- if def, err := g.ItemStore.Load(itemID); err == nil {
- weaponDef = def
- weaponName = def.Name
- weaponType = def.EquipmentType()
- if def.AttackType() != "" {
- attackType = def.AttackType()
- } else if def.EquipmentType() == item.EquipRangedWeapon {
- attackType = "ranged"
- } else if def.EquipmentType() == item.EquipScienceWeapon {
- attackType = "science"
- }
- }
- }
-
+ attackType, weaponType, weaponDef := g.resolvePlayerAttackType(p)
if weaponDef != nil {
- weaponName = g.itemColorize(sess, weaponDef, weaponName)
+ weaponName = g.itemColorize(sess, weaponDef, weaponDef.Name)
}
sess.WriteLines(
"",
@@ -60,15 +43,15 @@ func (g *Game) doStats(sess *net.Session) {
var attRoll, maxHitVal int
if weaponType == item.EquipRangedWeapon {
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
- attRoll = combat.EffectiveRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack)
- maxHitVal = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Ranged), rangedBonus), totals.RangedAttack)
+ maxHitVal = combat.MaxHit(combat.PlayerEffective(p.Level(player.Ranged), rangedBonus), totals.RangedStrength)
} else {
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
equipAtt := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- attRoll = combat.EffectiveRoll(p.Level(player.Accuracy), attBonus, equipAtt)
- maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Accuracy), attBonus), equipAtt)
+ maxHitVal = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength), strBonus), totals.StrengthBonus)
}
sess.WriteLines(
diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go
index cce9781..ecb4fbb 100644
--- a/internal/game/cmd_trigger_combat.go
+++ b/internal/game/cmd_trigger_combat.go
@@ -10,6 +10,14 @@ import (
"thehouseoficarus/internal/world"
)
+// scienceAttack resolves a player science-mod attack against a mob.
+//
+// Unlike melee/ranged (which roll the player's accuracy vs the mob's Defense),
+// science uses a symmetric science-vs-science model: both the attack roll and
+// the mob's defense roll use ScienceEffective, keyed off the player's Science
+// level and the mob's Science stat (plus ScienceDefense) respectively — the
+// mob's Defense stat is not involved. Science has no attack styles, so no style
+// bonus applies.
func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.MobInstance, mod *ModDef) bool {
if p.Level(player.Science) < mod.Level {
sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name))
@@ -24,17 +32,31 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob
equipSciBonus := g.totalScienceAttack(p)
effectiveScience := p.Level(player.Science) + g.techLevelBonus(p, "science") + g.buffLevelBonus(p, "science")
- attRoll := combat.EffectiveRoll(effectiveScience, 0, equipSciBonus)
+ attRoll := combat.AttackRoll(combat.ScienceEffective(effectiveScience), equipSciBonus)
- mobSciDef := mob.ScienceDefense
- defRoll := combat.EffectiveRoll(mob.Defense, 0, mobSciDef)
+ defRoll := combat.AttackRoll(combat.ScienceEffective(mob.Science), mob.ScienceDefense)
- if mob.Weakness == mod.Element {
- attRoll = attRoll * 13 / 10
+ if mob.Weakness == mod.Element && mob.WeaknessPercent > 0 {
+ attRoll = int(float64(attRoll) * (1.0 + float64(mob.WeaknessPercent)/100.0))
}
if combat.HitCheck(attRoll, defRoll) {
- dmg := combat.RollDamage(mod.MaxHit)
+ totals := g.playerEquipBonuses(p)
+ maxHit := mod.MaxHit
+ if totals.ScienceDamage > 0 {
+ maxHit = int(float64(maxHit) * (1.0 + float64(totals.ScienceDamage)/100.0))
+ }
+ // Weakness is an additive bonus computed off the mod's base MaxHit (not
+ // the science-damage-boosted maxHit), so equipment and weakness stack
+ // additively. With no science-damage gear this equals a ×(1+pct) boost,
+ // matching the multiplicative weakness applied to the attack roll above.
+ if mob.Weakness == mod.Element && mob.WeaknessPercent > 0 {
+ maxHit += int(float64(mod.MaxHit) * float64(mob.WeaknessPercent) / 100.0)
+ }
+ if maxHit < 1 {
+ maxHit = 1
+ }
+ dmg := combat.RollDamage(maxHit)
if dmg < 0 {
dmg = 0
}
diff --git a/internal/game/cmd_trigger_enchant.go b/internal/game/cmd_trigger_enchant.go
index 0279b4e..62a3c48 100644
--- a/internal/game/cmd_trigger_enchant.go
+++ b/internal/game/cmd_trigger_enchant.go
@@ -41,6 +41,7 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef,
g.cancelAction(p)
}
+ inputID := inv.ItemID
inv.ItemID = outputID
g.triggerModReward(sess, p, mod, 1.0)
@@ -50,8 +51,8 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef,
if outputDef != nil {
outputName = outputDef.Name
}
- inputDef, _ := g.ItemStore.Load(inv.ItemID)
- inputName := inv.ItemID
+ inputDef, _ := g.ItemStore.Load(inputID)
+ inputName := inputID
if inputDef != nil {
inputName = inputDef.Name
}
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index 5a4dcc7..d06551a 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -170,7 +170,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
}
var match *item.ItemDef
- for _, item := range g.CraftIndex.BySubtype("smelting") {
+ for _, item := range g.CraftIndex.BySubtype("smelting") {
if craftMatchesEntry(item.FirstCraft(), inv.ItemID) {
match = item
break
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 72de3d6..8540074 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -417,7 +417,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
}
func (g *Game) isGrimyHerb(itemID string) bool {
- items := g.CraftIndex.BySubtype("cleaning")
+ items := g.CraftIndex.BySubtype("cleaning")
for _, item := range items {
for _, e := range item.FirstCraft().Ingredients {
for _, id := range e.Items {
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index ddc3b8f..6c314ff 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -8,7 +8,6 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/item"
@@ -213,13 +212,13 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
if mod != nil && g.hasJunkCost(p, mod) {
g.scienceAttack(sess, p, currentMob, mod)
} else if mod != nil {
- sess.WriteLine(g.colorize(sess, "error",
- fmt.Sprintf("You're out of junk for %s!! You flail with your fists in desperation!", mod.Name)))
+ sess.WriteLine(g.colorize(sess, "error",
+ fmt.Sprintf("You're out of junk for %s!! You flail with your fists in desperation!", mod.Name)))
g.playerAttackUnarmed(sess, p, currentMob)
} else {
p.AutotriggerMod = ""
- sess.WriteLine(g.colorize(sess, "error",
- "No autotrigger set! Set a module with the 'autotrigger' command."))
+ sess.WriteLine(g.colorize(sess, "error",
+ "No autotrigger set! Set a module with the 'autotrigger' command."))
g.playerAttackUnarmed(sess, p, currentMob)
}
} else {
@@ -289,20 +288,29 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
}
}
-func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.EquipmentType, attRoll int, defRoll int, maxHit int) {
- attackType = "crush"
+// resolvePlayerAttackType determines the player's attack type and main-hand
+// weapon type (and the weapon def, if any) from their equipped weapon,
+// defaulting to crush when unarmed or when the weapon specifies no type.
+func (g *Game) resolvePlayerAttackType(p *player.Player) (attackType string, weaponType item.EquipmentType, def *item.ItemDef) {
+ attackType = combat.DefaultAttackType
if itemID, ok := p.Equipment[item.SlotMainHand]; ok {
- if def, err := g.ItemStore.Load(itemID); err == nil {
- if def.AttackType() != "" {
- attackType = def.AttackType()
- } else if def.EquipmentType() == item.EquipRangedWeapon {
- attackType = "ranged"
- } else if def.EquipmentType() == item.EquipScienceWeapon {
- attackType = "science"
+ if d, err := g.ItemStore.Load(itemID); err == nil {
+ def = d
+ weaponType = d.EquipmentType()
+ if at := d.AttackType(); at != "" {
+ attackType = at
+ } else if weaponType == item.EquipRangedWeapon {
+ attackType = combat.AttackRanged
+ } else if weaponType == item.EquipScienceWeapon {
+ attackType = combat.AttackScience
}
- weaponType = def.EquipmentType()
}
}
+ return
+}
+
+func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.EquipmentType, attRoll int, defRoll int, maxHit int) {
+ attackType, weaponType, _ = g.resolvePlayerAttackType(p)
totals := g.playerEquipBonuses(p)
isRanged := weaponType == item.EquipRangedWeapon
@@ -311,26 +319,26 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
equipAttack := totals.RangedAttack
effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged")
- attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveRanged, rangedBonus), equipAttack)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
- maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength)
+ defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus)
+ maxHit = combat.MaxHit(combat.PlayerEffective(effectiveRanged, rangedBonus), totals.RangedStrength)
} else {
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
equipAttack := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
- attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveAccuracy, attBonus), equipAttack)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
- maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus)
+ defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus)
+ maxHit = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus), totals.StrengthBonus)
}
return
}
@@ -355,13 +363,13 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
- attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveAccuracy, attBonus), 0)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
- maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0)
+ defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus)
+ maxHit = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus), 0)
return
}
@@ -380,14 +388,9 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
}
if mob.FinishingBlow != "" && mob.HP == 1 {
- fbDef, _ := g.ItemStore.Load(mob.FinishingBlow)
- fbName := mob.FinishingBlow
- if fbDef != nil {
- fbName = fbDef.Name
- }
sess.WriteLine(g.colorize(sess, "warning",
fmt.Sprintf("%s resists death! Use %s on it to finish it off.",
- mobDisplayName(mob, false), fbName)))
+ mobDisplayName(mob, false), g.itemDisplayName(mob.FinishingBlow))))
}
isRanged := weaponType == item.EquipRangedWeapon
@@ -401,19 +404,11 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
g.writeTaskProgress(sess, p, mob, dmg, gains)
} else {
mobName := mobDisplayName(mob, true)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
- if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w {
- w = w2
- }
+ attacker := mobDisplayNameCap(mob, true)
prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg)))
- visLen := color.VisibleLen(prefix)
- if visLen < w {
- prefix += strings.Repeat(" ", w-visLen+1)
- }
+ prefix = padCombatPrefix(prefix,
+ fmt.Sprintf("You hit %s for %d damage.", mobName, 999),
+ fmt.Sprintf("%s hits you for %d damage.", attacker, 999))
hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP)
line := prefix + hpSuffix + g.formatXpDrop(sess, p, gains)
sess.WriteLine(line)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index c64e393..a3fc040 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -13,42 +13,114 @@ import (
"thehouseoficarus/internal/world"
)
+// mobMeleeType returns the mob's single melee attack type (stab/slash/crush),
+// defaulting to crush if the mob has none configured.
+func mobMeleeType(mob *world.MobInstance) string {
+ for _, t := range mob.AttackTypes {
+ if combat.IsMeleeType(t) {
+ return t
+ }
+ }
+ return combat.DefaultAttackType
+}
+
+// mobEffectiveMaxScienceHit returns the mob's max science hit with its science
+// percent bonus applied, matching the calculation used in calculateMobAttack.
+func mobEffectiveMaxScienceHit(mob *world.MobInstance) int {
+ base := mob.MaxScienceHit
+ if mob.SciencePercentBonus > 0 {
+ base = int(float64(base) * (1.0 + float64(mob.SciencePercentBonus)/100.0))
+ }
+ if base < 1 {
+ base = 1
+ }
+ return base
+}
+
+// mobStrongestRangedScience returns whichever of "ranged"/"science" the mob
+// possesses with the higher max hit, or "" if the mob has neither.
+func mobStrongestRangedScience(mob *world.MobInstance) string {
+ hasRanged, hasScience := false, false
+ for _, t := range mob.AttackTypes {
+ switch t {
+ case combat.AttackRanged:
+ hasRanged = true
+ case combat.AttackScience:
+ hasScience = true
+ }
+ }
+ switch {
+ case hasRanged && hasScience:
+ if mobEffectiveMaxScienceHit(mob) > mob.MaxRangedHit {
+ return combat.AttackScience
+ }
+ return combat.AttackRanged
+ case hasRanged:
+ return combat.AttackRanged
+ case hasScience:
+ return combat.AttackScience
+ default:
+ return ""
+ }
+}
+
+// effectiveMobAttackType resolves which single attack type the mob uses for an
+// attack against this player right now. Normally the mob uses its melee type;
+// when the player is safespotted from melee the mob switches to its strongest
+// ranged/science type. Returns "" if the mob cannot attack (melee blocked and no
+// ranged/science fallback).
+func (g *Game) effectiveMobAttackType(p *player.Player, mob *world.MobInstance) string {
+ if g.isSafespotted(p.Name) && g.safespotBlocksMelee(p, mob) {
+ return mobStrongestRangedScience(mob)
+ }
+ return mobMeleeType(mob)
+}
+
func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) {
+ attackType := g.effectiveMobAttackType(p, mob)
+ if attackType == "" {
return
}
- attRoll, defRoll := g.calculateMobAttack(p, mob)
+ attRoll, defRoll, maxHit := g.calculateMobAttack(p, mob, attackType)
if combat.HitCheck(attRoll, defRoll) {
- g.applyMobHit(sess, p, mob)
+ g.applyMobHit(sess, p, mob, attackType, maxHit)
} else {
g.applyMobMiss(sess, mob)
}
}
-func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) {
+func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance, mobAttackType string) (attRoll, defRoll, maxHit int) {
_, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
- mobAttackType := mob.AttackType
if mobAttackType == "" {
- mobAttackType = "crush"
+ mobAttackType = combat.DefaultAttackType
}
- attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus)
+ switch mobAttackType {
+ case combat.AttackRanged:
+ attRoll = combat.AttackRoll(combat.NPCEffective(mob.Ranged), mob.RangedBonus)
+ maxHit = mob.MaxRangedHit
+ case combat.AttackScience:
+ attRoll = combat.AttackRoll(combat.ScienceEffective(mob.Science), mob.ScienceBonus)
+ maxHit = mobEffectiveMaxScienceHit(mob)
+ default:
+ attRoll = combat.AttackRoll(combat.NPCEffective(mob.Attack), mob.AttackBonus)
+ maxHit = mob.MaxMeleeHit
+ }
totals := g.playerEquipBonuses(p)
equipDef := combat.SelectBonus(mobAttackType,
totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
totals.ScienceDefense, totals.RangedDefense)
- defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef)
+ defRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus), equipDef)
return
}
-func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus)
+func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, maxHit int) {
dmg := combat.RollDamage(maxHit)
- dmg = g.applyTechProtection(p, mob, dmg)
+ dmg = g.applyTechProtection(p, attackType, dmg)
if mob.DamageWithout != "" {
hasProtection := false
@@ -66,15 +138,8 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
cs := g.Combat.Get(p.Name)
if cs != nil && !cs.DamageWarningShown {
cs.DamageWarningShown = true
- fbDef, _ := g.ItemStore.Load(mob.DamageWithout)
- fbName := mob.DamageWithout
- if fbDef != nil {
- fbName = fbDef.Name
- }
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
+ fbName := g.itemDisplayName(mob.DamageWithout)
+ attacker := mobDisplayNameCap(mob, true)
sess.WriteLine(g.colorize(sess, "warning",
fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.",
attacker, fbName)))
@@ -89,20 +154,12 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
p.StartRegen()
g.AccountStore.SaveCharacter(p)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
+ attacker := mobDisplayNameCap(mob, true)
mobName := mobDisplayName(mob, true)
- w := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999))
- if w2 := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)); w2 > w {
- w = w2
- }
prefix := fmt.Sprintf("%s hits you for %s damage.", g.colorize(sess, "mob", attacker), g.colorize(sess, "damage_taken", fmt.Sprint(dmg)))
- visLen := color.VisibleLen(prefix)
- if visLen < w {
- prefix += strings.Repeat(" ", w-visLen+1)
- }
+ prefix = padCombatPrefix(prefix,
+ fmt.Sprintf("%s hits you for %d damage.", attacker, 999),
+ fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP())
sess.WriteLine(prefix + hpSuffix)
@@ -123,11 +180,23 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
}
}
-func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) {
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
+// padCombatPrefix right-pads a colored damage line so a trailing HP bar aligns
+// with the mirror-image line (player-hit vs mob-hit). plainSelf/plainOther are
+// the uncolored versions of both lines (using a 999 damage placeholder) and are
+// used only to compute the alignment width.
+func padCombatPrefix(prefix, plainSelf, plainOther string) string {
+ w := len(plainSelf)
+ if len(plainOther) > w {
+ w = len(plainOther)
+ }
+ if visLen := color.VisibleLen(prefix); visLen < w {
+ prefix += strings.Repeat(" ", w-visLen+1)
}
+ return prefix
+}
+
+func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) {
+ attacker := mobDisplayNameCap(mob, true)
sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker)))
}
@@ -139,102 +208,108 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
return
}
- if mob != nil && mob.HP <= 0 {
- isTask := mob.IsTask()
- p.Stats.RecordMobKill(mob.DefID)
+ if mob == nil || mob.HP > 0 {
+ return
+ }
- if isTask {
- complete := mob.CompleteMessage
- if complete == "" {
- complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true))
- }
+ isTask := mob.IsTask()
+ p.Stats.RecordMobKill(mob.DefID)
+
+ g.announceKill(sess, p, mob, isTask)
+ g.awardKillDrops(sess, p, mob, isTask)
+ g.scheduleMobRespawn(mob)
+ g.writePrompt(sess)
+}
+
+// announceKill prints the victory line to the killer and broadcasts to the room.
+func (g *Game) announceKill(sess *net.Session, p *player.Player, mob *world.MobInstance, isTask bool) {
+ if isTask {
+ complete := mob.CompleteMessage
+ if complete == "" {
+ complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true))
+ }
sess.WriteLine(g.colorize(sess, "victory", complete))
} else {
sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("You have defeated %s!", mobDisplayName(mob, true))))
- g.onAssassinKill(sess, p, mob)
- }
+ g.onAssassinKill(sess, p, mob)
+ }
- if g.Hub != nil {
- for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
- if other != sess && other.Player != nil {
- if isTask {
- other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s finishes working on %s.", p.Name, mobDisplayName(mob, false))))
- } else {
- op := other.Player
- mobLvl := mobCombatLevel(mob)
- levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
- other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
- }
- }
- }
+ if g.Hub == nil {
+ return
+ }
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other == sess || other.Player == nil {
+ continue
+ }
+ if isTask {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s finishes working on %s.", p.Name, mobDisplayName(mob, false))))
+ } else {
+ mobLvl := mobCombatLevel(mob)
+ levelStr := g.levelColorize(other, other.Player.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
}
+ }
+}
- dropLabel := func() string {
- if isTask {
- return "You receive:"
- }
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
- return dropper + " drops:"
- }()
-
- if mob.Drops.Remains != "" {
- g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name)
- def, _ := g.ItemStore.Load(mob.Drops.Remains)
- name := mob.Drops.Remains
- if def != nil {
- name = def.Name
- }
- coloredName := g.itemColorize(sess, def, name)
+// awardKillDrops resolves the mob's remains and loot table onto the ground,
+// reserved for the killer, and reports each drop.
+func (g *Game) awardKillDrops(sess *net.Session, p *player.Player, mob *world.MobInstance, isTask bool) {
+ dropLabel := "You receive:"
+ if !isTask {
+ dropLabel = mobDisplayNameCap(mob, true) + " drops:"
+ }
+
+ writeDrop := func(itemID string, qty int) {
+ g.World.AddReservedItem(p.RoomID, itemID, qty, p.Name)
+ def, _ := g.ItemStore.Load(itemID)
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ coloredName := g.itemColorize(sess, def, name)
+ if qty > 1 {
+ sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName))
+ } else {
sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
}
+ }
- if len(mob.Drops.Loot) > 0 {
- for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) {
- if entry.ItemID == "" {
- continue
- }
- qty := entry.Quantity
- if qty <= 0 {
- qty = 1
- }
- g.World.AddReservedItem(p.RoomID, entry.ItemID, qty, p.Name)
- def, _ := g.ItemStore.Load(entry.ItemID)
- name := entry.ItemID
- if def != nil {
- name = def.Name
- }
- coloredName := g.itemColorize(sess, def, name)
- if qty > 1 {
- sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName))
- } else {
- sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
- }
- }
- }
+ if mob.Drops.Remains != "" {
+ writeDrop(mob.Drops.Remains, 1)
+ }
- respawnTicks := engine.ToTicks(mob.RespawnTicks)
- if respawnTicks <= 0 {
- respawnTicks = engine.ToTicks(30)
+ for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) {
+ if entry.ItemID == "" {
+ continue
}
- instanceID := mob.InstanceID
- if mob.SpawnedByTrigger {
- g.Ticks.Subscribe(10, func() bool {
- g.MobStore.RemoveInstance(instanceID)
- return false
- })
- } else {
- g.Ticks.Subscribe(respawnTicks, func() bool {
- g.respawnMob(instanceID)
- return false
- })
+ qty := entry.Quantity
+ if qty <= 0 {
+ qty = 1
}
- g.writePrompt(sess)
+ writeDrop(entry.ItemID, qty)
}
}
+// scheduleMobRespawn removes trigger-spawned mobs or schedules a normal respawn.
+func (g *Game) scheduleMobRespawn(mob *world.MobInstance) {
+ instanceID := mob.InstanceID
+ if mob.SpawnedByTrigger {
+ g.Ticks.Subscribe(10, func() bool {
+ g.MobStore.RemoveInstance(instanceID)
+ return false
+ })
+ return
+ }
+ respawnTicks := engine.ToTicks(mob.RespawnTicks)
+ if respawnTicks <= 0 {
+ respawnTicks = engine.ToTicks(30)
+ }
+ g.Ticks.Subscribe(respawnTicks, func() bool {
+ g.respawnMob(instanceID)
+ return false
+ })
+}
+
// killPlayer handles a player death from any source (combat or a room hazard).
// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch
// retribution.
@@ -256,8 +331,8 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns
if mob.HP < 0 {
mob.HP = 0
}
- sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %d damage!",
- mobDisplayName(mob, true), retDmg))
+ sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %d damage!",
+ mobDisplayName(mob, true), retDmg))
}
}
}
@@ -316,8 +391,8 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan
return gains
}
+// stopCombat ends the player's combat if any. Combat.Leave no-ops when the
+// player is not engaged.
func (g *Game) stopCombat(playerName string) {
- if cs := g.Combat.Get(playerName); cs != nil {
- g.Combat.Leave(playerName)
- }
+ g.Combat.Leave(playerName)
}
diff --git a/internal/game/combat_mob_test.go b/internal/game/combat_mob_test.go
new file mode 100644
index 0000000..28427b7
--- /dev/null
+++ b/internal/game/combat_mob_test.go
@@ -0,0 +1,61 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+func TestMobMeleeType(t *testing.T) {
+ cases := []struct {
+ name string
+ types []string
+ want string
+ }{
+ {"single crush", []string{"crush"}, "crush"},
+ {"melee plus ranged", []string{"stab", "ranged"}, "stab"},
+ {"ranged first", []string{"ranged", "slash"}, "slash"},
+ {"no melee defaults crush", []string{"ranged"}, "crush"},
+ {"empty defaults crush", nil, "crush"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ mob := &world.MobInstance{AttackTypes: c.types}
+ if got := mobMeleeType(mob); got != c.want {
+ t.Errorf("mobMeleeType(%v) = %q, want %q", c.types, got, c.want)
+ }
+ })
+ }
+}
+
+func TestMobStrongestRangedScience(t *testing.T) {
+ cases := []struct {
+ name string
+ types []string
+ maxRanged int
+ maxScience int
+ sciencePct int
+ want string
+ }{
+ {"melee only", []string{"crush"}, 0, 0, 0, ""},
+ {"ranged only", []string{"crush", "ranged"}, 5, 0, 0, "ranged"},
+ {"science only", []string{"crush", "science"}, 0, 7, 0, "science"},
+ {"both science higher", []string{"crush", "ranged", "science"}, 5, 8, 0, "science"},
+ {"both ranged higher", []string{"crush", "ranged", "science"}, 10, 8, 0, "ranged"},
+ {"both tie prefers ranged", []string{"crush", "ranged", "science"}, 8, 8, 0, "ranged"},
+ {"science percent tips it", []string{"crush", "ranged", "science"}, 10, 8, 50, "science"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ mob := &world.MobInstance{
+ AttackTypes: c.types,
+ MaxRangedHit: c.maxRanged,
+ MaxScienceHit: c.maxScience,
+ SciencePercentBonus: c.sciencePct,
+ }
+ if got := mobStrongestRangedScience(mob); got != c.want {
+ t.Errorf("mobStrongestRangedScience() = %q, want %q", got, c.want)
+ }
+ })
+ }
+}
diff --git a/internal/game/core_equip.go b/internal/game/core_equip.go
index a297466..7a1c1bc 100644
--- a/internal/game/core_equip.go
+++ b/internal/game/core_equip.go
@@ -33,7 +33,7 @@ func (g *Game) playerEquipBonuses(p *player.Player) item.ItemStats {
func (g *Game) playerWeaponSpeed(p *player.Player) float64 {
if itemID, ok := p.Equipment[item.SlotMainHand]; ok {
def, err := g.ItemStore.Load(itemID)
- if err == nil && def.Speed()> 0 {
+ if err == nil && def.Speed() > 0 {
return def.Speed()
}
}
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index b1d3d26..90c06be 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -51,7 +51,13 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
return
}
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
+ sess.State = net.StateMenu
+ g.showMenu(sess)
+ return
+ }
acc.Characters = append(acc.Characters, name)
g.AccountStore.SaveAccount(acc)
sess.Account.Characters = acc.Characters
@@ -188,7 +194,12 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) {
g.AccountStore.SaveCharacter(p)
}
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error renaming: %v", err))
+ g.showMenu(sess)
+ return
+ }
for i, c := range acc.Characters {
if c == oldName {
acc.Characters[i] = newName
@@ -250,7 +261,13 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) {
return
}
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error deleting: %v", err))
+ sess.PendingChar = ""
+ g.showMenu(sess)
+ return
+ }
var newChars []string
for _, c := range acc.Characters {
if c != sess.PendingChar {
diff --git a/internal/game/core_utils.go b/internal/game/core_utils.go
index 4c087e8..2855bcf 100644
--- a/internal/game/core_utils.go
+++ b/internal/game/core_utils.go
@@ -44,6 +44,27 @@ func mobDisplayName(m *world.MobInstance, definite bool) string {
return "a " + m.Name
}
+// mobDisplayNameCap is like mobDisplayName but capitalizes the article for use
+// at the start of a sentence (e.g. "The goblin hits you.").
+func mobDisplayNameCap(m *world.MobInstance, definite bool) string {
+ if m.Unique {
+ return m.Name
+ }
+ if definite {
+ return "The " + m.Name
+ }
+ return "A " + m.Name
+}
+
+// itemDisplayName returns an item's display name for the given id, falling back
+// to the id itself if the item cannot be loaded.
+func (g *Game) itemDisplayName(itemID string) string {
+ if def, err := g.ItemStore.Load(itemID); err == nil && def != nil {
+ return def.Name
+ }
+ return itemID
+}
+
func mobCombatLevel(m *world.MobInstance) int {
base := float64(m.Defense+m.MaxHP) / 4.0
melee := float64(m.Attack+m.Strength) / 4.0
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index a93d969..bf130be 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -351,7 +351,7 @@ func (g *Game) showItemStats(sess *net.Session, def *item.ItemDef) {
if def.AttackType() != "" {
sess.WriteLine(fmt.Sprintf("Attack type: %s", def.AttackType()))
}
- if def.Speed()> 0 {
+ if def.Speed() > 0 {
sess.WriteLine(fmt.Sprintf("Speed: %.0f", def.Speed()))
}
if len(def.Requirements) > 0 {
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index 712202b..159214c 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -83,10 +83,10 @@ func TestMapConnectorGlyphs(t *testing.T) {
wantAbsent: []string{"X", "<", ">"},
},
{
- name: "outward open, inward blocked -> outward arrow (dist rules)",
- room1: "name: One\nexits:\n east: 2\n",
- room2: "name: Two\nexits:\n west: 1\n north: 3\n",
- room3: condSouth,
+ name: "outward open, inward blocked -> outward arrow (dist rules)",
+ room1: "name: One\nexits:\n east: 2\n",
+ room2: "name: Two\nexits:\n west: 1\n north: 3\n",
+ room3: condSouth,
flagOpen: false,
// dist[room1]=0, dist[room2]=1, dist[room3]=2.
// Link 2↔3: near=2 (dist1), far=3 (dist2).
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index b4b8c93..179277a 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -10,15 +10,15 @@ import (
)
type mapGlyphs struct {
- topLeft, topRight rune
- bottomLeft, bottomRight rune
- side rune
- topFill rune
- connectorH, connectorV rune
- upArrow, downArrow rune
- leftArrow, rightArrow rune
- upRight, upLeft rune
- downRight, downLeft rune
+ topLeft, topRight rune
+ bottomLeft, bottomRight rune
+ side rune
+ topFill rune
+ connectorH, connectorV rune
+ upArrow, downArrow rune
+ leftArrow, rightArrow rune
+ upRight, upLeft rune
+ downRight, downLeft rune
connectorNE, connectorNW rune
}
@@ -203,7 +203,6 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
}
}
-
cur, _ := loadRoom(g, roomID)
if cur != nil {
_, hasUp := exitTarget(cur, world.Up)
diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go
index 971da99..6e9c40d 100644
--- a/internal/game/sys_hazard.go
+++ b/internal/game/sys_hazard.go
@@ -75,19 +75,19 @@ func (g *Game) hasEquipped(p *player.Player, itemID string) bool {
func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardDef) {
attackType := hz.AttackType
if attackType == "" {
- attackType = "crush"
+ attackType = combat.DefaultAttackType
}
- attRoll := combat.EffectiveRoll(hz.Attack, 0, hz.AttackBonus)
+ attRoll := combat.AttackRoll(combat.NPCEffective(hz.Attack), hz.AttackBonus)
_, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
totals := g.playerEquipBonuses(p)
equipDef := combat.SelectBonus(attackType,
totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
totals.ScienceDefense, totals.RangedDefense)
- defRoll := combat.EffectiveRoll(
+ defRoll := combat.AttackRoll(combat.PlayerEffective(
p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"),
- defStyleBonus, equipDef)
+ defStyleBonus), equipDef)
if combat.HitCheck(attRoll, defRoll) {
g.applyHazardHit(sess, p, hz)
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index cdb954b..41fe19f 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -50,7 +50,11 @@ func (g *Game) safespotBlocksHazard(p *player.Player) bool {
return g.isSafespotted(p.Name)
}
-func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
+// safespotBlocksMelee reports whether the player's active safespot shields them
+// from this mob's melee attacks (based on cover size vs mob size). It does NOT
+// consider the mob's attack types — a mob whose melee is blocked may still be
+// able to attack with ranged/science (see effectiveMobAttackType).
+func (g *Game) safespotBlocksMelee(p *player.Player, mob *world.MobInstance) bool {
ss, ok := g.safespot.Get(p.Name)
if !ok || !ss.Active || ss.HideCountdown > 0 {
return false
@@ -61,15 +65,17 @@ func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool
return false
}
- if !blocksMob(objDef.Safespot.MaxBlockSize, mob.Size) {
- return false
- }
+ return blocksMob(objDef.Safespot.MaxBlockSize, mob.Size)
+}
- if mob.AttackType == "ranged" || mob.AttackType == "science" {
+// safespotBlocksMob reports whether the player's active safespot fully prevents
+// this mob from attacking at all. This is true only when the safespot blocks the
+// mob's melee AND the mob has no ranged/science attack type to switch to.
+func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
+ if !g.safespotBlocksMelee(p, mob) {
return false
}
-
- return true
+ return mobStrongestRangedScience(mob) == ""
}
func (g *Game) sessionInRoom(name string, roomSessions []*net.Session) *net.Session {
diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go
index bded8c4..a616b42 100644
--- a/internal/game/sys_science.go
+++ b/internal/game/sys_science.go
@@ -25,15 +25,15 @@ const (
)
type ModDef struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- Level int `yaml:"level"`
- MaxHit int `yaml:"max_hit"`
- BaseXP int `yaml:"base_xp"`
- JunkCost map[string]int `yaml:"junk_cost"`
- Category ModCategory `yaml:"category"`
- Element string `yaml:"element"`
- Destination int `yaml:"destination"`
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Level int `yaml:"level"`
+ MaxHit int `yaml:"max_hit"`
+ BaseXP int `yaml:"base_xp"`
+ JunkCost map[string]int `yaml:"junk_cost"`
+ Category ModCategory `yaml:"category"`
+ Element string `yaml:"element"`
+ Destination int `yaml:"destination"`
Sequence []behavior.ModTriggerStep `yaml:"sequence"`
}
@@ -44,8 +44,7 @@ var modByID map[string]*ModDef
func (g *Game) LoadMods() error {
dir := filepath.Join(g.DataDir, "modules")
AllMods = nil
- modByID = make(map[string]*ModDef)
- behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var m ModDef
if err := yaml.Unmarshal(data, &m); err != nil {
return nil
@@ -53,7 +52,9 @@ func (g *Game) LoadMods() error {
m.ID = id
AllMods = append(AllMods, &m)
return nil
- })
+ }); err != nil {
+ return err
+ }
modByID = make(map[string]*ModDef, len(AllMods))
for _, m := range AllMods {
modByID[m.ID] = m
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index 9e6a635..c7214af 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -8,9 +8,9 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/combat"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
- "thehouseoficarus/internal/world"
)
type TechEffects struct {
@@ -44,8 +44,7 @@ var techByID map[string]*TechDef
func (g *Game) LoadTechs() error {
dir := filepath.Join(g.DataDir, "techs")
AllTechs = nil
- techByID = make(map[string]*TechDef)
- behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var t TechDef
if err := yaml.Unmarshal(data, &t); err != nil {
return nil
@@ -53,7 +52,9 @@ func (g *Game) LoadTechs() error {
t.ID = id
AllTechs = append(AllTechs, &t)
return nil
- })
+ }); err != nil {
+ return err
+ }
techByID = make(map[string]*TechDef, len(AllTechs))
for _, t := range AllTechs {
techByID[t.ID] = t
@@ -199,8 +200,8 @@ func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bo
return false
}
-func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int {
- return g.damageAfterTechProtection(p, mob.AttackType, dmg)
+func (g *Game) applyTechProtection(p *player.Player, attackType string, dmg int) int {
+ return g.damageAfterTechProtection(p, attackType, dmg)
}
// damageAfterTechProtection reduces incoming damage if the player has the
@@ -220,16 +221,16 @@ func (g *Game) damageAfterTechProtection(p *player.Player, attackType string, dm
}
if attackType == "" {
- attackType = "crush"
+ attackType = combat.DefaultAttackType
}
var protectTechID string
- switch attackType {
- case "stab", "slash", "crush":
+ switch {
+ case combat.IsMeleeType(attackType):
protectTechID = "protect_melee"
- case "ranged":
+ case attackType == combat.AttackRanged:
protectTechID = "protect_ranged"
- case "science":
+ case attackType == combat.AttackScience:
protectTechID = "protect_science"
}
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index cd1bbd3..7c77fc9 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -267,8 +267,8 @@ func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) {
inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks))
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(roomID) {
- sess.WriteLine(g.colorize(sess, "broadcast",
- mobDisplayName(inst, true)+" appears!"))
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ mobDisplayName(inst, true)+" appears!"))
}
}
}
@@ -287,8 +287,8 @@ func (g *Game) despawnTriggerMobs(mobID string, owner string) {
}
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) {
- sess.WriteLine(g.colorize(sess, "broadcast",
- fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true))))
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true))))
}
}
g.MobStore.RemoveInstance(inst.InstanceID)
diff --git a/internal/game/tick.go b/internal/game/tick.go
index eba8438..4db5b58 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -111,7 +111,7 @@ func (g *Game) WanderTick() {
toRoom: toRoom,
level: mobCombatLevel(inst),
})
- inst.RoomID = toRoom
+ g.MobStore.SetInstanceRoom(inst.InstanceID, toRoom)
}
// Object wandering (fishing spots, etc.)
@@ -270,8 +270,8 @@ func (g *Game) TechTick() {
if p.Battery <= 0 {
p.Battery = 0
p.DeactivateAllTechs()
- sess.WriteLine(g.colorize(sess, "tech_depleted",
- "Your battery is depleted! All tech has been disabled."))
+ sess.WriteLine(g.colorize(sess, "tech_depleted",
+ "Your battery is depleted! All tech has been disabled."))
g.writePrompt(sess)
}
}