aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/action.go1
-rw-r--r--internal/game/action_state.go3
-rw-r--r--internal/game/cmd_attack.go88
-rw-r--r--internal/game/cmd_hide.go171
-rw-r--r--internal/game/cmd_look.go46
-rw-r--r--internal/game/cmd_map.go6
-rw-r--r--internal/game/cmd_move.go8
-rw-r--r--internal/game/cmd_option.go8
-rw-r--r--internal/game/cmd_registry.go4
-rw-r--r--internal/game/cmd_score.go2
-rw-r--r--internal/game/cmd_stats.go6
-rw-r--r--internal/game/core_login_account.go29
-rw-r--r--internal/game/game.go18
-rw-r--r--internal/game/sys_combat.go3
-rw-r--r--internal/game/sys_safespot.go330
15 files changed, 684 insertions, 39 deletions
diff --git a/internal/game/action.go b/internal/game/action.go
index d6ef1bc..9a07419 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -25,6 +25,7 @@ var verbAliases = map[string]string{
"ask": "talk",
"steal": "steal",
"thieve": "steal",
+ "hide": "safespot",
}
var verbSkill = map[string]string{
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index 0ba3767..ffce545 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -29,6 +29,7 @@ const (
ActionCuring ActionType = "curing"
ActionTraversing ActionType = "traversing"
ActionHacking ActionType = "hacking"
+ ActionHiding ActionType = "hiding"
)
type ActionState struct {
@@ -98,6 +99,8 @@ func (a *ActionState) Description() string {
return a.Verb + " across " + a.TargetName
case ActionHacking:
return "jacked into a " + a.TargetName
+ case ActionHiding:
+ return "positioning behind a " + a.TargetName
}
return ""
}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 703b9ac..ce396e8 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "math/rand"
"sort"
"strconv"
"strings"
@@ -248,6 +249,15 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob)
+ if g.isSafespotted(p.Name) && attackType != "ranged" && attackType != "science" {
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+ if ok {
+ g.forceLeaveSafespot(sess, p, ss, "You leave your cover to close in for melee!")
+ }
+ }
+
if combat.HitCheck(attRoll, defRoll) {
g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit)
} else {
@@ -277,25 +287,25 @@ 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.AttackRoll(effectiveRanged, rangedBonus, equipAttack)
+ attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack)
- mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength)
} else {
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
- equipAttack := combat.SelectAttackBonus(attackType,
+ equipAttack := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack")
- attRoll = combat.AttackRoll(effectiveAttack, attBonus, equipAttack)
+ attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, equipAttack)
- mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ 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)
}
return
@@ -322,12 +332,12 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack")
- attRoll = combat.AttackRoll(effectiveAttack, attBonus, 0)
+ attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, 0)
- mobDefBonus := combat.SelectDefenseBonus(attackType,
+ mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus)
+ defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0)
return
}
@@ -361,12 +371,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
gains := g.awardCombatXP(sess, p, dmg, isRanged)
if isRanged {
- p.AmmoQty--
- if p.AmmoQty <= 0 {
- delete(p.Equipment, object.SlotAmmo)
- p.AmmoQty = 0
- }
- g.AccountStore.SaveCharacter(p)
+ g.consumeAmmo(sess, p)
}
mobName := mobDisplayName(mob, true)
@@ -404,18 +409,39 @@ func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.M
sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You miss %s.", mobDisplayName(mob, true))))
if weaponType == object.WeaponRanged {
- p.AmmoQty--
+ g.consumeAmmo(sess, p)
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) consumeAmmo(sess *net.Session, p *player.Player) {
+ ammoID := p.Equipment[object.SlotAmmo]
+
+ p.AmmoQty--
+ if p.AmmoQty <= 0 {
+ delete(p.Equipment, object.SlotAmmo)
+ p.AmmoQty = 0
+ }
+
+ if ammoID != "" {
+ if def, err := g.ItemStore.Load(ammoID); err == nil && def.Recoverable {
+ if rand.Float64() < 0.80 {
+ g.World.AddGroundItem(p.RoomID, def.ID, 1)
+ }
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+}
+
func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) {
+ return
+ }
+
attRoll, defRoll := g.calculateMobAttack(p, mob)
if combat.HitCheck(attRoll, defRoll) {
@@ -433,13 +459,13 @@ func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (att
mobAttackType = "crush"
}
- attRoll = combat.AttackRoll(mob.Attack, 0, mob.AttackBonus)
+ attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus)
totals := g.playerEquipBonuses(p)
- equipDef := combat.SelectDefenseBonus(mobAttackType,
+ equipDef := combat.SelectBonus(mobAttackType,
totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
totals.ScienceDefense, totals.RangedDefense)
- defRoll = combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef)
+ defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef)
return
}
@@ -504,6 +530,15 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP())
sess.WriteLine(prefix + hpSuffix)
+ g.safespotMu.Lock()
+ ss, hasSS := g.safespotStates[p.Name]
+ if hasSS && ss.HideCountdown > 0 {
+ delete(g.safespotStates, p.Name)
+ p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
+ sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide."))
+ }
+ g.safespotMu.Unlock()
+
if p.MoveTicks > 0 {
p.ClearMoveState()
p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
@@ -542,6 +577,13 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
p.Stats.RecordDeath()
sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!"))
p.ClearMoveState()
+ g.safespotMu.Lock()
+ if ss, ok := g.safespotStates[p.Name]; ok {
+ g.safespotMu.Unlock()
+ g.forceLeaveSafespot(sess, p, ss, "")
+ } else {
+ g.safespotMu.Unlock()
+ }
g.dropItemsOnDeath(p)
p.HP = p.MaxHP()
p.RoomID = 1
diff --git a/internal/game/cmd_hide.go b/internal/game/cmd_hide.go
new file mode 100644
index 0000000..9445949
--- /dev/null
+++ b/internal/game/cmd_hide.go
@@ -0,0 +1,171 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+
+ if len(args) == 0 {
+ sess.WriteLine("Hide behind what?")
+ return
+ }
+
+ // Skip optional "behind"
+ target := strings.Join(args, " ")
+ if args[0] == "behind" {
+ if len(args) < 2 {
+ sess.WriteLine("Hide behind what?")
+ return
+ }
+ target = strings.Join(args[1:], " ")
+ }
+
+ instances := g.World.FindObjInstances(p.RoomID, target)
+ if len(instances) == 0 {
+ sess.WriteLine("You don't see that here.")
+ return
+ }
+
+ if len(instances) > 1 && objectInstancesNeedDisambiguation(instances) {
+ sess.WriteLines(
+ "That's ambiguous, which one?",
+ formatObjInstanceChoices(instances),
+ )
+ return
+ }
+
+ objDef, err := g.ObjectStore.Load(instances[0].DefID)
+ if err != nil || !objDef.IsSafespot() {
+ sess.WriteLine("You can't hide behind that.")
+ return
+ }
+
+ cfg := objDef.Safespot
+ st := g.World.GetObjState(p.RoomID, objDef.ID, instances[0].Index)
+ if st == nil {
+ sess.WriteLine("You can't hide behind that.")
+ return
+ }
+
+ g.safespotMu.Lock()
+ existingSS, alreadyHiding := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+
+ if alreadyHiding && existingSS.Active {
+ if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == instances[0].Index {
+ if existingSS.HideCountdown > 0 {
+ sess.WriteLine("You're already moving into position there.")
+ } else {
+ sess.WriteLine("You're already hiding there.")
+ }
+ return
+ }
+ g.leaveSafespot(sess, p)
+ }
+
+ if g.safespotTier(p) < cfg.Tier {
+ sess.WriteLine("You can't figure out how to use this as effective cover.")
+ return
+ }
+
+ if cfg.MaxOccupants > 0 && len(st.SafespotOccupants) >= cfg.MaxOccupants {
+ sess.WriteLines(
+ "",
+ fmt.Sprintf("%s already hiding there!",
+ joinSafespotOccupants(st.SafespotOccupants)),
+ )
+ return
+ }
+
+ if st.SafespotLevel <= 0 {
+ if cfg.RespawnTicks > 0 {
+ sess.WriteLine(fmt.Sprintf("The %s has been destroyed and hasn't reformed yet.", objDef.Name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("The %s has been completely destroyed.", objDef.Name))
+ }
+ return
+ }
+
+ inCombat := combat.GetCombat(p.Name) != nil
+ hideCountdown := 1
+ if inCombat {
+ hideCountdown = 4
+ }
+
+ g.safespotMu.Lock()
+ g.safespotStates[p.Name] = &SafespotState{
+ Active: true,
+ ObjectDefID: objDef.ID,
+ ObjectIndex: instances[0].Index,
+ RoomID: p.RoomID,
+ HideCountdown: hideCountdown,
+ }
+ g.safespotMu.Unlock()
+
+ p.ActionState = &ActionState{Type: ActionHiding, TargetName: objDef.Name}
+
+ if inCombat {
+ sess.WriteLine(fmt.Sprintf("\nYou try to get behind the %s for cover...", objDef.Name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You position yourself behind the %s.", objDef.Name))
+ }
+}
+
+func (g *Game) executeUnhide(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+
+ if !ok || !ss.Active {
+ sess.WriteLine("You're not hiding behind anything.")
+ return
+ }
+
+ if ss.HideCountdown > 0 {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ p.ActionState = nil
+ sess.WriteLine("You stop trying to hide.")
+ return
+ }
+
+ g.leaveSafespot(sess, p)
+}
+
+func objectInstancesNeedDisambiguation(instances []world.ObjState) bool {
+ if len(instances) < 2 {
+ return false
+ }
+ first := instances[0].DefID
+ for i := 1; i < len(instances); i++ {
+ if instances[i].DefID != first {
+ return true
+ }
+ }
+ return false
+}
+
+func formatObjInstanceChoices(instances []world.ObjState) string {
+ var lines []string
+ for _, inst := range instances {
+ lines = append(lines, fmt.Sprintf(" %d.%s", inst.Index+1, inst.Name))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func joinSafespotOccupants(names []string) string {
+ if len(names) == 1 {
+ return fmt.Sprintf("%s is", names[0])
+ }
+ return fmt.Sprintf("%s are", strings.Join(names, ", "))
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 936a6b0..4cc556a 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -124,7 +124,11 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
for _, objID := range order {
count := grouped[objID]
def, err := g.ObjectStore.Load(objID)
- if err != nil || def.Hidden {
+ if err != nil {
+ continue
+ }
+
+ if def.Hidden && (def.Safespot == nil || g.safespotTier(p) < def.Safespot.Tier) {
continue
}
@@ -132,6 +136,24 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
continue
}
+ if def.Hidden && def.Safespot != nil {
+ st := g.World.GetObjState(p.RoomID, objID, 0)
+ levelInfo := ""
+ if st != nil {
+ if st.SafespotLevel <= 0 && def.Safespot.RespawnTicks > 0 {
+ continue
+ }
+ if st.SafespotLevel <= 0 {
+ levelInfo = " (destroyed)"
+ } else if st.SafespotLevel < len(def.Safespot.Levels) {
+ levelInfo = " (degraded)"
+ }
+ }
+ sess.WriteLine(fmt.Sprintf(" %s that could provide cover.%s",
+ g.objColorize(sess, def, def.Name), levelInfo))
+ continue
+ }
+
type instInfo struct {
idx int
depleted bool
@@ -522,6 +544,28 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(fmt.Sprintf(" %s", def.Description))
}
+ if def.Safespot != nil {
+ realSt := g.World.GetObjState(p.RoomID, st.DefID, st.Index)
+ if realSt != nil {
+ levelStr := "intact"
+ if realSt.SafespotLevel <= 0 {
+ levelStr = "destroyed"
+ } else if realSt.SafespotLevel < len(def.Safespot.Levels) {
+ levelStr = fmt.Sprintf("degraded (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels))
+ } else {
+ levelStr = fmt.Sprintf("intact (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels))
+ }
+ blockInfo := "anything"
+ if def.Safespot.MaxBlockSize != "" {
+ blockInfo = fmt.Sprintf("up to %s mobs", def.Safespot.MaxBlockSize)
+ }
+ sess.WriteLine(fmt.Sprintf(" Safespot: %s — blocks %s", levelStr, blockInfo))
+ if len(realSt.SafespotOccupants) > 0 {
+ sess.WriteLine(fmt.Sprintf(" Occupied by: %s", strings.Join(realSt.SafespotOccupants, ", ")))
+ }
+ }
+ }
+
if p.OptionBool("depletion") {
for _, ist := range instances {
if ist.Depleted {
diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go
index be35a66..90f58ab 100644
--- a/internal/game/cmd_map.go
+++ b/internal/game/cmd_map.go
@@ -10,8 +10,8 @@ func (g *Game) executeMap(sess *net.Session, args []string, rawInput string) {
func (g *Game) doMap(sess *net.Session) {
p := sess.Player
- mapWidth := p.OptionInt("mapwidth")
- mapHeight := p.OptionInt("mapheight")
+ mapWidth := p.OptionInt("map_width")
+ mapHeight := p.OptionInt("map_height")
if mapWidth < 5 {
mapWidth = 5
}
@@ -21,7 +21,7 @@ func (g *Game) doMap(sess *net.Session) {
lines := buildFullMap(g, p.RoomID, mapWidth, mapHeight, mapGlyphsForPlayer(p.OptionBool("unicode")))
- mode := p.OptionString("mappadding")
+ mode := p.OptionString("map_padding")
if mode == "none" || mode == "x" {
lines = stripBlankRows(lines)
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index f921a81..a5f1690 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -117,6 +117,14 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
p.Action = nil
+ g.safespotMu.Lock()
+ if ss, ok := g.safespotStates[p.Name]; ok {
+ g.safespotMu.Unlock()
+ g.forceLeaveSafespot(sess, p, ss, "")
+ } else {
+ g.safespotMu.Unlock()
+ }
+
oldRoom := p.RoomID
p.RoomID = targetID
p.Stats.RecordRoomVisit(targetID)
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 19256ba..ff7725c 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -26,6 +26,7 @@ func (g *Game) doOption(sess *net.Session, input string) {
}
for _, def := range player.OptionDefs {
val := formatOptionValue(p, &def)
+ val = truncateForTable(val)
valid := formatValidValues(&def)
table.Rows = append(table.Rows, []string{
color.Render(mode, color.Parse("75"), def.Name),
@@ -148,3 +149,10 @@ func parseOptionValue(def *player.OptionDef, input string) (any, bool) {
}
return nil, false
}
+
+func truncateForTable(s string) string {
+ if len(s) <= 10 {
+ return s
+ }
+ return s[:7] + "..."
+}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 4824684..381f34d 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -125,6 +125,10 @@ var commandRegistry = map[string]commandDef{
"syntax": {(*Game).executeVerbs, ClassInstant},
"commands": {(*Game).executeVerbs, ClassInstant},
"stop": {(*Game).executeStop, ClassInstant},
+ "hide": {(*Game).executeHide, ClassActive},
+ "safespot": {(*Game).executeHide, ClassActive},
+ "unhide": {(*Game).executeUnhide, ClassActive},
+ "unsafespot": {(*Game).executeUnhide, ClassActive},
}
func classifyCommand(cmd string) CommandClass {
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index d0302c2..9f3c786 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -98,7 +98,7 @@ func (g *Game) doScore(sess *net.Session) {
creditsStr := g.colorize(sess, "credits_pickup", formatInt(p.Credits))
styleStr := color.Render(mode, color.Parse("75"), fmt.Sprintf("Style: %s", p.AttackStyle))
- invStr := fmt.Sprintf("Inv: %d/28 used", 28-p.FreeSlots())
+ invStr := fmt.Sprintf("Inv: %d/28", 28-p.FreeSlots())
row2 := padded("Credits: "+creditsStr, 20) + padded(styleStr, 24) + invStr
content(row2)
diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go
index 333d535..9987a1d 100644
--- a/internal/game/cmd_stats.go
+++ b/internal/game/cmd_stats.go
@@ -60,14 +60,14 @@ func (g *Game) doStats(sess *net.Session) {
var attRoll, maxHitVal int
if weaponType == object.WeaponRanged {
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
- attRoll = combat.AttackRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack)
+ attRoll = combat.EffectiveRoll(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,
+ equipAtt := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- attRoll = combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
+ attRoll = combat.EffectiveRoll(p.Level(player.Attack), attBonus, equipAtt)
maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index a2c1bd8..093ae71 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -5,6 +5,7 @@ import (
"os"
"strings"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
@@ -172,8 +173,20 @@ func (g *Game) handleColorChoice(sess *net.Session, input string) {
g.showMenu(sess)
}
+func menuColorMode(sess *net.Session) string {
+ if sess != nil && sess.Account != nil && sess.Account.Options != nil {
+ if v, ok := sess.Account.Options["color"]; ok {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ }
+ return "xterm256"
+}
+
func (g *Game) showMenu(sess *net.Session) {
sess.State = net.StateMenu
+ mode := menuColorMode(sess)
lines := []string{
"",
fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name),
@@ -181,18 +194,18 @@ func (g *Game) showMenu(sess *net.Session) {
}
if len(sess.Account.Characters) > 0 {
lines = append(lines,
- " (C)onnect character to THOI",
+ color.ExpandTags(mode, " {3}(C){/}{4}onnect character to THOI{/}"),
"",
- " (L)ist characters",
- " (R)ename character",
- " (D)elete character",
+ color.ExpandTags(mode, " {3}(L){/}{4}ist characters{/}"),
+ color.ExpandTags(mode, " {3}(R){/}{4}ename character{/}"),
+ color.ExpandTags(mode, " {3}(D){/}{4}elete character{/}"),
)
}
lines = append(lines,
- " (N)ew character",
- " (P)urge account",
- " (A)ccount rename",
- " (Q)uit",
+ color.ExpandTags(mode, " {3}(N){/}{4}ew character{/}"),
+ color.ExpandTags(mode, " {3}(P){/}{4}urge account{/}"),
+ color.ExpandTags(mode, " {3}(A){/}{4}ccount rename{/}"),
+ color.ExpandTags(mode, " {3}(Q){/}{4}uit{/}"),
"",
)
sess.WriteLines(lines...)
diff --git a/internal/game/game.go b/internal/game/game.go
index 8bf289b..d3bb391 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -57,6 +57,16 @@ type Game struct {
guardWatchTimers map[string]int
farmTickCounter int
hackingStates map[string]*hacking.Session
+ safespotMu sync.Mutex
+ safespotStates map[string]*SafespotState
+}
+
+type SafespotState struct {
+ Active bool
+ ObjectDefID string
+ ObjectIndex int
+ RoomID int
+ HideCountdown int
}
func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game {
@@ -81,6 +91,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
pendingDepletions: nil,
guardWatchTimers: make(map[string]int),
hackingStates: make(map[string]*hacking.Session),
+ safespotStates: make(map[string]*SafespotState),
}
g.CourseStore.LoadAll()
g.LoadMods()
@@ -98,6 +109,13 @@ func (g *Game) SetHub(hub *net.Hub) {
delete(g.loggedInChars, p.Name)
g.charsMu.Unlock()
delete(g.hackingStates, p.Name)
+ g.safespotMu.Lock()
+ if ss, ok := g.safespotStates[p.Name]; ok {
+ g.safespotMu.Unlock()
+ g.forceLeaveSafespot(sess, p, ss, "")
+ } else {
+ g.safespotMu.Unlock()
+ }
}
})
}
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index 81b2c83..91a07cf 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -90,6 +90,9 @@ func (g *Game) checkAggro(sess *net.Session) {
if playerLevel > mobLevel*2 {
continue
}
+ if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) {
+ continue
+ }
aggMob := mob
g.Ticks.Subscribe(engine.ToTicks(1), func() bool {
if combat.GetCombat(p.Name) != nil {
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
new file mode 100644
index 0000000..ffb7948
--- /dev/null
+++ b/internal/game/sys_safespot.go
@@ -0,0 +1,330 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+var sizeRank = map[string]int{
+ "small": 1,
+ "medium": 2,
+ "large": 3,
+ "massive": 4,
+}
+
+func (g *Game) isSafespotted(playerName string) bool {
+ g.safespotMu.Lock()
+ defer g.safespotMu.Unlock()
+ ss, ok := g.safespotStates[playerName]
+ return ok && ss.Active && ss.HideCountdown <= 0
+}
+
+func (g *Game) safespotTier(p *player.Player) int {
+ tier := 1
+ if getPlayerFlagInt(p, "unlocked_rock_cover") > 0 {
+ tier++
+ }
+ return tier
+}
+
+func blocksMob(maxBlockSize, mobSize string) bool {
+ if maxBlockSize == "" {
+ return true
+ }
+ max := sizeRank[maxBlockSize]
+ mob := sizeRank[mobSize]
+ if mob == 0 {
+ mob = sizeRank["medium"]
+ }
+ return mob <= max
+}
+
+func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ g.safespotMu.Unlock()
+ if !ok || !ss.Active || ss.HideCountdown > 0 {
+ return false
+ }
+
+ objDef, err := g.ObjectStore.Load(ss.ObjectDefID)
+ if err != nil || objDef.Safespot == nil {
+ return false
+ }
+
+ if !blocksMob(objDef.Safespot.MaxBlockSize, mob.Size) {
+ return false
+ }
+
+ if mob.AttackType == "ranged" || mob.AttackType == "science" {
+ return false
+ }
+
+ return true
+}
+
+func (g *Game) sessionInRoom(name string, roomSessions []*net.Session) *net.Session {
+ for _, sess := range roomSessions {
+ if sess.Player != nil && sess.Player.Name == name {
+ return sess
+ }
+ }
+ return nil
+}
+
+func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *SafespotState) {
+ objDef, err := g.ObjectStore.Load(ss.ObjectDefID)
+ if err != nil || objDef.Safespot == nil {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ sess.WriteLine("The safespot no longer exists.")
+ return
+ }
+
+ cfg := objDef.Safespot
+ st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex)
+ if st == nil {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ sess.WriteLine("The safespot no longer exists.")
+ return
+ }
+
+ if cfg.RespawnOnHide {
+ st.SafespotLevel = len(cfg.Levels)
+ st.SafespotTicksAtLevel = 0
+ } else if st.SafespotLevel <= 0 {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+ sess.WriteLine(fmt.Sprintf("The %s has been completely destroyed.", objDef.Name))
+ return
+ }
+
+ st.SafespotOccupants = append(st.SafespotOccupants, p.Name)
+ p.ActionState = nil
+
+ var msg string
+ levelIdx := st.SafespotLevel - 1
+ if levelIdx >= 0 && levelIdx < len(cfg.Levels) && cfg.Levels[levelIdx].Message != "" {
+ msg = cfg.Levels[levelIdx].Message
+ } else {
+ msg = fmt.Sprintf("You crouch behind the %s, using it as cover.", objDef.Name)
+ }
+ sess.WriteLine(g.colorize(sess, "combat_info", msg))
+}
+
+func (g *Game) safespotMaintenance(sess *net.Session, p *player.Player, ss *SafespotState) {
+ objDef, err := g.ObjectStore.Load(ss.ObjectDefID)
+ if err != nil || objDef.Safespot == nil {
+ g.forceLeaveSafespot(sess, p, ss, "The safespot no longer exists.")
+ return
+ }
+
+ cfg := objDef.Safespot
+ st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex)
+ if st == nil {
+ g.forceLeaveSafespot(sess, p, ss, "The safespot no longer exists.")
+ return
+ }
+
+ if st.SafespotLevel <= 0 {
+ if cfg.RespawnTicks > 0 {
+ return
+ }
+ g.forceLeaveSafespot(sess, p, ss, fmt.Sprintf("The %s has crumbled away!", objDef.Name))
+ return
+ }
+
+ if len(st.SafespotOccupants) == 0 {
+ return
+ }
+
+ if cfg.UnsafeChance > 0 && rand.Float64() < cfg.UnsafeChance {
+ alert := p.OptionString("safespot_alert")
+ g.forceLeaveSafespot(sess, p, ss, "You slip and are no longer covered!")
+ if alert != "" {
+ sess.WriteLine(color.ExpandTags(g.colorMode(sess), alert))
+ }
+ return
+ }
+
+ levelIdx := st.SafespotLevel - 1
+ degraded := false
+
+ if cfg.DecayChance > 0 && rand.Float64() < cfg.DecayChance {
+ degraded = true
+ }
+
+ if !degraded && cfg.DecayTicks > 0 {
+ st.SafespotTicksAtLevel++
+ if st.SafespotTicksAtLevel >= engine.ToTicks(cfg.DecayTicks) {
+ degraded = true
+ }
+ }
+
+ if degraded {
+ g.degradeSafespot(st, objDef, cfg, levelIdx)
+ }
+}
+
+func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg *object.SafespotConfig, currentLevelIdx int) {
+ roomSessions := g.Hub.PlayersInRoom(st.RoomID)
+
+ if currentLevelIdx >= 0 && currentLevelIdx < len(cfg.Levels) {
+ msg := cfg.Levels[currentLevelIdx].DegradeMessage
+ if msg != "" {
+ for _, rs := range roomSessions {
+ rs.WriteLine("")
+ rs.WriteLine(color.ExpandTags(g.colorMode(rs), msg))
+ }
+ }
+ }
+
+ st.SafespotLevel--
+ st.SafespotTicksAtLevel = 0
+
+ if st.SafespotLevel <= 0 {
+ for _, name := range st.SafespotOccupants {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, name)
+ g.safespotMu.Unlock()
+ if occSess := g.sessionInRoom(name, roomSessions); occSess != nil {
+ occSess.WriteLine(color.ExpandTags(g.colorMode(occSess),
+ fmt.Sprintf("The %s crumbles away!", objDef.Name)))
+ if occSess.Player != nil {
+ alert := occSess.Player.OptionString("safespot_alert")
+ if alert != "" {
+ occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), alert))
+ }
+ }
+ g.writePrompt(occSess)
+ }
+ }
+ st.SafespotOccupants = nil
+ g.scheduleSafespotRespawn(st, objDef, cfg)
+ return
+ }
+
+ newLevelIdx := st.SafespotLevel - 1
+ if newLevelIdx >= 0 && newLevelIdx < len(cfg.Levels) && cfg.Levels[newLevelIdx].Message != "" {
+ msg := cfg.Levels[newLevelIdx].Message
+ for _, name := range st.SafespotOccupants {
+ if occSess := g.sessionInRoom(name, roomSessions); occSess != nil {
+ occSess.WriteLine("")
+ occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), msg))
+ }
+ }
+ }
+}
+
+func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.ObjectDef, cfg *object.SafespotConfig) {
+ if cfg.RespawnTicks <= 0 {
+ return
+ }
+ roomID := st.RoomID
+ defID := objDef.ID
+ g.Ticks.Subscribe(engine.ToTicks(cfg.RespawnTicks), func() bool {
+ respawnSt := g.World.GetObjState(roomID, defID, 0)
+ if respawnSt == nil {
+ return false
+ }
+ respawnSt.SafespotLevel = len(cfg.Levels)
+ respawnSt.SafespotTicksAtLevel = 0
+ for _, sess := range g.Hub.PlayersInRoom(roomID) {
+ sess.WriteLine("")
+ sess.WriteLine(color.ExpandTags(g.colorMode(sess),
+ fmt.Sprintf("The %s reforms and looks stable again.", objDef.Name)))
+ }
+ return false
+ })
+}
+
+func (g *Game) leaveSafespot(sess *net.Session, p *player.Player) {
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ if !ok {
+ g.safespotMu.Unlock()
+ return
+ }
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+
+ g.removeSafespotOccupant(p.Name, ss)
+
+ objDef, _ := g.ObjectStore.Load(ss.ObjectDefID)
+ objName := "cover"
+ if objDef != nil {
+ objName = objDef.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You step out from behind the %s.", objName))
+ p.ActionState = nil
+}
+
+func (g *Game) forceLeaveSafespot(sess *net.Session, p *player.Player, ss *SafespotState, reason string) {
+ g.safespotMu.Lock()
+ delete(g.safespotStates, p.Name)
+ g.safespotMu.Unlock()
+
+ g.removeSafespotOccupant(p.Name, ss)
+
+ if reason != "" {
+ sess.WriteLine(reason)
+ }
+ p.ActionState = nil
+}
+
+func (g *Game) removeSafespotOccupant(playerName string, ss *SafespotState) {
+ st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex)
+ if st == nil {
+ return
+ }
+ for i, name := range st.SafespotOccupants {
+ if name == playerName {
+ st.SafespotOccupants = append(st.SafespotOccupants[:i], st.SafespotOccupants[i+1:]...)
+ return
+ }
+ }
+}
+
+func (g *Game) SafespotTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p := sess.Player
+ if p == nil {
+ continue
+ }
+
+ g.safespotMu.Lock()
+ ss, ok := g.safespotStates[p.Name]
+ if !ok || !ss.Active {
+ g.safespotMu.Unlock()
+ continue
+ }
+
+ if ss.HideCountdown > 0 {
+ ss.HideCountdown--
+ if ss.HideCountdown > 0 {
+ g.safespotMu.Unlock()
+ continue
+ }
+ g.safespotMu.Unlock()
+ g.activateSafespot(sess, p, ss)
+ continue
+ }
+ g.safespotMu.Unlock()
+
+ g.safespotMaintenance(sess, p, ss)
+ }
+}