diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/combat/formulas.go | 26 | ||||
| -rw-r--r-- | internal/game/action.go | 1 | ||||
| -rw-r--r-- | internal/game/action_state.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_attack.go | 88 | ||||
| -rw-r--r-- | internal/game/cmd_hide.go | 171 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 46 | ||||
| -rw-r--r-- | internal/game/cmd_map.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_option.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_score.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_stats.go | 6 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 29 | ||||
| -rw-r--r-- | internal/game/game.go | 18 | ||||
| -rw-r--r-- | internal/game/sys_combat.go | 3 | ||||
| -rw-r--r-- | internal/game/sys_safespot.go | 330 | ||||
| -rw-r--r-- | internal/object/item.go | 1 | ||||
| -rw-r--r-- | internal/object/object.go | 29 | ||||
| -rw-r--r-- | internal/player/player.go | 8 | ||||
| -rw-r--r-- | internal/player/store.go | 4 | ||||
| -rw-r--r-- | internal/world/mob.go | 85 | ||||
| -rw-r--r-- | internal/world/objects.go | 4 |
22 files changed, 767 insertions, 113 deletions
diff --git a/internal/combat/formulas.go b/internal/combat/formulas.go index 963ee9c..f4e1ba2 100644 --- a/internal/combat/formulas.go +++ b/internal/combat/formulas.go @@ -2,12 +2,7 @@ package combat import "math/rand" -func AttackRoll(level int, styleBonus int, equipBonus int) int { - effective := level + styleBonus + 8 - return effective * (equipBonus + 64) -} - -func DefenseRoll(level int, styleBonus int, equipBonus int) int { +func EffectiveRoll(level int, styleBonus int, equipBonus int) int { effective := level + styleBonus + 8 return effective * (equipBonus + 64) } @@ -72,24 +67,7 @@ func RangedStyleBonus(style string) (ranged, defense int) { } } -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 { +func SelectBonus(attackType string, stab, slash, crush, science, ranged int) int { switch attackType { case "stab": return stab 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) + } +} diff --git a/internal/object/item.go b/internal/object/item.go index b0865d6..b90432d 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -91,6 +91,7 @@ type ItemDef struct { PotionEffect string `yaml:"potion_effect"` PotionBonus int `yaml:"potion_bonus"` PotionDuration float64 `yaml:"potion_duration"` + Recoverable bool `yaml:"recoverable"` } type CraftList []CraftDef diff --git a/internal/object/object.go b/internal/object/object.go index 826a315..12e5585 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -24,15 +24,34 @@ type ObjectDef struct { StealSpeed float64 `yaml:"steal_speed"` GuardMob string `yaml:"guard_mob"` - Gather *action.GatherConfig `yaml:"gather,omitempty"` - Talk *action.TalkConfig `yaml:"talk,omitempty"` - Use *action.UseConfig `yaml:"use,omitempty"` + Gather *action.GatherConfig `yaml:"gather,omitempty"` + Talk *action.TalkConfig `yaml:"talk,omitempty"` + Use *action.UseConfig `yaml:"use,omitempty"` + Safespot *SafespotConfig `yaml:"safespot,omitempty"` +} + +type SafespotConfig struct { + Tier int `yaml:"tier"` + MaxBlockSize string `yaml:"max_block_size"` + MaxOccupants int `yaml:"max_occupants"` + UnsafeChance float64 `yaml:"unsafe_chance"` + DecayTicks float64 `yaml:"decay_ticks"` + DecayChance float64 `yaml:"decay_chance"` + RespawnOnHide bool `yaml:"respawn_on_hide"` + RespawnTicks float64 `yaml:"respawn_ticks"` + Levels []SafespotLevel `yaml:"levels"` +} + +type SafespotLevel struct { + Message string `yaml:"message"` + DegradeMessage string `yaml:"degrade_message"` } func (d *ObjectDef) IsGatherable() bool { return d.Gather != nil } func (d *ObjectDef) IsTalkable() bool { return d.Talk != nil } func (d *ObjectDef) IsUsable() bool { return d.Use != nil } -func (d *ObjectDef) IsInteractable() bool { return d.Gather != nil || d.Talk != nil || d.Use != nil } +func (d *ObjectDef) IsSafespot() bool { return d.Safespot != nil } +func (d *ObjectDef) IsInteractable() bool { return d.Gather != nil || d.Talk != nil || d.Use != nil || d.Safespot != nil } func (d *ObjectDef) BehaviorType() string { switch { @@ -42,6 +61,8 @@ func (d *ObjectDef) BehaviorType() string { return "talk" case d.Use != nil: return "use" + case d.Safespot != nil: + return "safespot" } return "" } diff --git a/internal/player/player.go b/internal/player/player.go index 7953691..ac2c8ce 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -120,9 +120,9 @@ var OptionDefs = []OptionDef{ {"depletion", OptBool, false, nil, "Show depletion and despawn timers"}, {"despawn", OptBool, false, nil, "Show ground item despawn timers"}, {"color", OptString, "xterm256", []string{"none", "ansi", "xterm256"}, "Color output mode"}, - {"mapwidth", OptInt, 30, nil, "Map width for the map command"}, - {"mapheight", OptInt, 20, nil, "Map height for the map command"}, - {"mappadding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"}, + {"map_width", OptInt, 30, nil, "Map width for the map command"}, + {"map_height", OptInt, 20, nil, "Map height for the map command"}, + {"map_padding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"}, {"automap", OptBool, false, nil, "Show map automatically after moving"}, {"queue_silently", OptBool, true, nil, "Suppress messages for queued tick actions"}, {"room_desc_width", OptInt, 70, nil, "Maximum width for room descriptions"}, @@ -137,6 +137,8 @@ var OptionDefs = []OptionDef{ {"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"}, {"construct_all", OptBool, false, nil, "Auto-start constructing when only one product is possible"}, + {"craft_all", OptBool, false, nil, "Auto-start crafting when only one product is possible"}, + {"safespot_alert", OptString, "{196 bold}** Your safespot has been compromised! **{/}", nil, "Message shown when forced out of a safespot (set to \"none\" to disable)"}, } var optionByName map[string]*OptionDef diff --git a/internal/player/store.go b/internal/player/store.go index c05b29d..04af594 100644 --- a/internal/player/store.go +++ b/internal/player/store.go @@ -89,10 +89,6 @@ 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 9ca1a45..ec7db36 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -56,6 +56,7 @@ type MobDef struct { AssassinLevel int `yaml:"assassin_level"` FinishingBlow string `yaml:"finishing_blow"` DamageWithout string `yaml:"damage_without"` + Size string `yaml:"size"` Talk *action.TalkConfig `yaml:"talk,omitempty"` } @@ -106,12 +107,56 @@ type MobInstance struct { AssassinLevel int FinishingBlow string DamageWithout string + Size string TalkConfig *action.TalkConfig } func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil } +func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance { + return &MobInstance{ + InstanceID: instanceID, + DefID: def.ID, + Name: def.Name, + HP: def.HP, + MaxHP: def.HP, + Attack: def.Attack, + Strength: def.Strength, + Defense: def.Defense, + Ranged: def.Ranged, + Science: def.Science, + Speed: def.Speed, + Aggressive: def.Aggressive, + Protected: def.Protected, + Unique: def.Unique, + RespawnTicks: def.RespawnTicks, + RoomID: roomID, + HomeRoomID: roomID, + WanderRooms: wanderRooms, + WanderInterval: wanderInterval, + Drops: def.Drops, + AttackBonus: def.AttackBonus, + StrengthBonus: def.StrengthBonus, + AttackType: def.AttackType, + StabDefense: def.StabDefense, + SlashDefense: def.SlashDefense, + CrushDefense: def.CrushDefense, + ScienceDefense: def.ScienceDefense, + RangedDefense: def.RangedDefense, + Weakness: def.Weakness, + StealTable: def.StealTable, + StealLevel: def.StealLevel, + StealXP: def.StealXP, + StealSpeed: def.StealSpeed, + AssassinLevel: def.AssassinLevel, + FinishingBlow: def.FinishingBlow, + DamageWithout: def.DamageWithout, + Size: def.Size, + TalkConfig: def.Talk, + } +} + const ( MatchNone = 0 MatchPrefix = 1 @@ -276,45 +321,7 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) { if _, exists := s.instances[instID]; exists { continue } - inst := &MobInstance{ - InstanceID: instID, - DefID: defID, - Name: dw.def.Name, - HP: dw.def.HP, - MaxHP: dw.def.HP, - 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, - Unique: dw.def.Unique, - RespawnTicks: dw.def.RespawnTicks, - WanderRooms: dw.rm.WanderRooms, - WanderInterval: dw.rm.WanderInterval, - 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, - StealTable: dw.def.StealTable, - StealLevel: dw.def.StealLevel, - StealXP: dw.def.StealXP, - StealSpeed: dw.def.StealSpeed, - AssassinLevel: dw.def.AssassinLevel, - FinishingBlow: dw.def.FinishingBlow, - DamageWithout: dw.def.DamageWithout, - TalkConfig: dw.def.Talk, - } + inst := NewMobInstance(dw.def, instID, roomID, dw.rm.WanderRooms, dw.rm.WanderInterval) inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions) s.instances[instID] = inst } diff --git a/internal/world/objects.go b/internal/world/objects.go index ae2586a..bf4b592 100644 --- a/internal/world/objects.go +++ b/internal/world/objects.go @@ -23,6 +23,10 @@ type ObjState struct { SharedMax float64 SharedTimer int Quality float64 + + SafespotLevel int + SafespotTicksAtLevel int + SafespotOccupants []string } type ObjMove struct { |
