diff options
| author | historia <[not public]> | 2026-06-21 22:19:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-21 22:19:17 -0400 |
| commit | a3ae1e6aad696f584d138d9363c05dedff136a65 (patch) | |
| tree | 81e615f7227bc9d1ec79ceefc7311ecde917b074 | |
| parent | 84072ffe6365ad57c4976e9272762aa6db41de7e (diff) | |
| download | thehouseoficarus-a3ae1e6aad696f584d138d9363c05dedff136a65.tar.gz | |
feat: safespot system implemented
48 files changed, 1052 insertions, 127 deletions
@@ -7,4 +7,5 @@ thoi .npm/ .local/ .config/ +.opencode/ node_modules/ @@ -405,9 +405,9 @@ Account-wide options set via `option <name> <value>`. Stored in account YAML, sh | `depletion` | bool | false | Show depletion timers | | `despawn` | bool | false | Show ground item despawn timers | | `color` | string | "xterm256" | none/ansi/xterm256 — color output mode | -| `mapwidth` | int | 30 | Map viewport width | -| `mapheight` | int | 20 | Map viewport height | -| `mappadding` | string | "none" | none/x/y/xy — blank-row stripping | +| `map_width` | int | 30 | Map viewport width | +| `map_height` | int | 20 | Map viewport height | +| `map_padding` | string | "none" | none/x/y/xy — blank-row stripping | | `automap` | bool | false | Auto-show map after moving | | `queue_silently` | bool | true | Suppress queue messages | | `room_desc_width` | int | 70 | Room desc line wrap width | diff --git a/cmd/mud/main.go b/cmd/mud/main.go index c51cc6e..e9d0aed 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -57,6 +57,7 @@ func main() { g.SneakTick() g.FarmTick() g.BuffTick() + g.SafespotTick() return true }) diff --git a/data/help/automap.yaml b/data/help/automap.yaml index 32b84cb..3cd1a2f 100644 --- a/data/help/automap.yaml +++ b/data/help/automap.yaml @@ -9,5 +9,5 @@ description: | When enabled, the map command output is printed after each room movement, so you always know where you are. - Map size and padding are controlled by the mapwidth, - mapheight, and mappadding options. + Map size and padding are controlled by the map_width, + map_height, and map_padding options. diff --git a/data/help/hide.yaml b/data/help/hide.yaml new file mode 100644 index 0000000..eb2d27a --- /dev/null +++ b/data/help/hide.yaml @@ -0,0 +1,15 @@ +name: "hide" +category: "Skills" +description: | + Hide behind a safespot object to block melee attacks. + + Usage: hide <object> + hide behind <object> + safespot <object> + + Hiding takes 1 tick out of combat or 4 ticks while fighting. If a mob + hits you during repositioning in combat, the hide attempt fails. + + Leave cover with: unhide (or unsafespot) + + See also: help safespot, help combat diff --git a/data/help/map.yaml b/data/help/map.yaml index 3b4917a..7ca03a2 100644 --- a/data/help/map.yaml +++ b/data/help/map.yaml @@ -9,11 +9,11 @@ description: | centered on your current location. You are shown as @. Map size is controlled by: - option mapwidth <num> (default 30) - option mapheight <num> (default 20) + option map_width <num> (default 30) + option map_height <num> (default 20) Padding/trimming is controlled by: - option mappadding <mode> + option map_padding <mode> none - strip blank lines + left-trim (default) x - strip blank lines only y - left-trim only diff --git a/data/help/mapheight.yaml b/data/help/map_height.yaml index ee3920b..32daf0a 100644 --- a/data/help/mapheight.yaml +++ b/data/help/map_height.yaml @@ -1,4 +1,4 @@ -name: "mapheight" +name: "map_height" category: "Options" description: | Height of the full map display in characters. diff --git a/data/help/mappadding.yaml b/data/help/map_padding.yaml index 201ef53..7772226 100644 --- a/data/help/mappadding.yaml +++ b/data/help/map_padding.yaml @@ -1,4 +1,4 @@ -name: "mappadding" +name: "map_padding" category: "Options" description: | Controls how the map command trims its output. diff --git a/data/help/mapwidth.yaml b/data/help/map_width.yaml index 7e372ef..3957bed 100644 --- a/data/help/mapwidth.yaml +++ b/data/help/map_width.yaml @@ -1,4 +1,4 @@ -name: "mapwidth" +name: "map_width" category: "Options" description: | Width of the full map display in characters. diff --git a/data/help/option.yaml b/data/help/option.yaml index 9fdcf7f..75b1794 100644 --- a/data/help/option.yaml +++ b/data/help/option.yaml @@ -15,7 +15,7 @@ description: | Examples: option tinymap off option color ansi - option mapwidth 50 + option map_width 50 Use help <option> for details on a specific option. Use 'prompt' to set a custom per-character prompt. diff --git a/data/help/safespot.yaml b/data/help/safespot.yaml new file mode 100644 index 0000000..76e1768 --- /dev/null +++ b/data/help/safespot.yaml @@ -0,0 +1,38 @@ +name: "safespot" +category: "Skills" +description: | + Use environmental cover to block melee attacks from mobs while + attacking with ranged or science weapons. + + Usage: hide <object> + safespot <object> + + Find a safespot: look <object> + Leave a safespot: unhide (or unsafespot) + + While in a safespot, melee mobs cannot reach you. Ranged and science + mobs can still attack. Attacking with melee while in a safespot forces + you out of cover. + + Safespots degrade over time while in use. Each degrade level shows a + different message. When fully degraded, the safespot is destroyed. + Some safespots respawn after a delay; others require you to re-hide + to reset them. + + There is a small per-tick chance of being forced out of cover + (unsafe_chance). You can re-hide immediately if the safespot is + still intact. + + Hiding out of combat takes 1 tick. Hiding while fighting takes 4 + ticks — taking a hit during the repositioning aborts the attempt. + + Your effective safespot tier is unlocked by completing quests and + combat achievements. Higher-tier safespots require quest flags like + "quest_animal_magnetism" or achievement flags like + "achieve_medium_combat". + + You can customize the alert shown when you are forced out of a + safespot with: option safespot_alert <message> + Set to "none" to disable. Uses inline color tags. + + See also: help hide, help combat, help option diff --git a/data/items/ammo/adamant_arrow.yaml b/data/items/ammo/adamant_arrow.yaml index cbed73f..72f9470 100644 --- a/data/items/ammo/adamant_arrow.yaml +++ b/data/items/ammo/adamant_arrow.yaml @@ -18,6 +18,7 @@ equip_slot: ammo id: adamant_arrow name: adamant arrow stackable: true +recoverable: true stats: ranged_strength: 31 value: 20 diff --git a/data/items/ammo/bronze_arrow.yaml b/data/items/ammo/bronze_arrow.yaml index bc26583..a61823b 100644 --- a/data/items/ammo/bronze_arrow.yaml +++ b/data/items/ammo/bronze_arrow.yaml @@ -18,6 +18,7 @@ equip_slot: ammo id: bronze_arrow name: bronze arrow stackable: true +recoverable: true stats: ranged_strength: 7 value: 1 diff --git a/data/items/ammo/iron_arrow.yaml b/data/items/ammo/iron_arrow.yaml index 4cd5bea..9e15967 100644 --- a/data/items/ammo/iron_arrow.yaml +++ b/data/items/ammo/iron_arrow.yaml @@ -18,6 +18,7 @@ equip_slot: ammo id: iron_arrow name: iron arrow stackable: true +recoverable: true stats: ranged_strength: 10 value: 2 diff --git a/data/items/ammo/mithril_arrow.yaml b/data/items/ammo/mithril_arrow.yaml index 3f07c09..3eef148 100644 --- a/data/items/ammo/mithril_arrow.yaml +++ b/data/items/ammo/mithril_arrow.yaml @@ -18,6 +18,7 @@ equip_slot: ammo id: mithril_arrow name: mithril arrow stackable: true +recoverable: true stats: ranged_strength: 22 value: 10 diff --git a/data/items/ammo/rune_arrow.yaml b/data/items/ammo/rune_arrow.yaml index 0b497ff..4876472 100644 --- a/data/items/ammo/rune_arrow.yaml +++ b/data/items/ammo/rune_arrow.yaml @@ -18,6 +18,7 @@ equip_slot: ammo id: rune_arrow name: rune arrow stackable: true +recoverable: true stats: ranged_strength: 49 value: 40 diff --git a/data/items/ammo/steel_arrow.yaml b/data/items/ammo/steel_arrow.yaml index fb85c0d..916a2f3 100644 --- a/data/items/ammo/steel_arrow.yaml +++ b/data/items/ammo/steel_arrow.yaml @@ -18,6 +18,7 @@ equip_slot: ammo id: steel_arrow name: steel arrow stackable: true +recoverable: true stats: ranged_strength: 16 value: 5 diff --git a/data/objects/fence_post.yaml b/data/objects/fence_post.yaml new file mode 100644 index 0000000..11f6160 --- /dev/null +++ b/data/objects/fence_post.yaml @@ -0,0 +1,18 @@ +id: fence_post +name: fence post +color: "137" +hidden: true +inroom_description: "A sturdy fence post juts from the ground." +description: "A weathered wooden fence post, solid enough to crouch behind." +safespot: + tier: 1 + max_block_size: small + max_occupants: 1 + unsafe_chance: 0.005 + decay_ticks: 0 + decay_chance: 0 + respawn_on_hide: true + respawn_ticks: 0 + levels: + - message: "You crouch behind the fence post, using it as cover." + degrade_message: "" diff --git a/data/objects/rock_outcrop.yaml b/data/objects/rock_outcrop.yaml new file mode 100644 index 0000000..57b71d8 --- /dev/null +++ b/data/objects/rock_outcrop.yaml @@ -0,0 +1,22 @@ +id: rock_outcrop +name: rock outcrop +color: "248" +hidden: true +inroom_description: "A jagged rock outcrop juts from the floor." +description: "A large, jagged rock formation providing natural cover." +safespot: + tier: 1 + max_block_size: large + max_occupants: 3 + unsafe_chance: 0.008 + decay_ticks: 500 + decay_chance: 0.01 + respawn_on_hide: false + respawn_ticks: 300 + levels: + - message: "The rock formation stands solid." + degrade_message: "The rock structure begins crumbling!" + - message: "The rock outcrop is cracked and worn." + degrade_message: "A few jagged bits are all that's left of the rock outcrop!" + - message: "Only a few jagged rocks remain." + degrade_message: "The rock outcrop crumbles to nothing!" diff --git a/data/objects/simple_rock.yaml b/data/objects/simple_rock.yaml new file mode 100644 index 0000000..4c03308 --- /dev/null +++ b/data/objects/simple_rock.yaml @@ -0,0 +1,18 @@ +id: simple_rock +name: rock formation +color: "248" +hidden: true +inroom_description: "A sturdy rock formation juts from the ground." +description: "A solid rock formation, weathered but large enough to crouch behind." +safespot: + tier: 1 + max_block_size: small + max_occupants: 2 + unsafe_chance: 0.002 + decay_ticks: 0 + decay_chance: 0 + respawn_on_hide: true + respawn_ticks: 0 + levels: + - message: "You crouch behind the rock formation, using it as cover." + degrade_message: "" diff --git a/data/objects/tutorial_lever.yaml b/data/objects/tutorial_lever.yaml new file mode 100644 index 0000000..894dd07 --- /dev/null +++ b/data/objects/tutorial_lever.yaml @@ -0,0 +1,22 @@ +id: tutorial_lever +name: stone lever +color: "250" +hidden: true +inroom_description: "A stone lever protrudes from the base of the rock formation." +description: "A weathered stone lever set into a metal bracket. It looks like it can be pulled." +use_interactions: + - condition: + player_flag: unlocked_rock_cover + value: 1 + not: true + message: "You pull the stone lever down. A mechanism clicks into place somewhere in the glade." + action: + set_player_flags: + unlocked_rock_cover: 1 + - condition: + player_flag: unlocked_rock_cover + value: 1 + message: "You push the stone lever back up. The mechanism disengages with a soft clunk." + action: + set_player_flags: + unlocked_rock_cover: 0 diff --git a/data/rooms/town/4.yaml b/data/rooms/town/4.yaml index 2c9f27f..5a8b036 100644 --- a/data/rooms/town/4.yaml +++ b/data/rooms/town/4.yaml @@ -1,6 +1,6 @@ id: 4 name: "Woodcutting Glade" -description: "A sunny glade at the edge of the forest. A sturdy tree stands here, perfect for practicing woodcutting." +description: "A sunny glade at the edge of the forest. A sturdy tree stands here, perfect for practicing woodcutting. A solid rock formation near the edge of the glade looks like it could provide cover." map_symbol: "W" exits: east: 6 @@ -11,6 +11,8 @@ objects: - id: tree - id: tree - id: tree + - id: simple_rock + - id: tutorial_lever mobs: - id: rat - id: rat @@ -27,3 +29,9 @@ item_spawns: - id: lighter quantity: 1 respawn_ticks: 120 + - id: shortbow + quantity: 1 + respawn_ticks: 60 + - id: bronze_arrow + quantity: 100 + respawn_ticks: 60 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 { diff --git a/worldbuilding_guide/behaviors.md b/worldbuilding_guide/behaviors.md index 08dc2d1..22e97fe 100644 --- a/worldbuilding_guide/behaviors.md +++ b/worldbuilding_guide/behaviors.md @@ -1,7 +1,7 @@ ## Behaviors Behaviors are **inline configs** placed directly inside object YAML (`gather:`, `talk:`, -`use:`) or mob YAML (`talk:`). Each section below shows the keys you can use under each behavior type. +`use:`, `safespot:`) or mob YAML (`talk:`). Each section below shows the keys you can use under each behavior type. ### Gather (mining, fishing, woodcutting) diff --git a/worldbuilding_guide/hidden_objects.md b/worldbuilding_guide/hidden_objects.md index 39030d8..24fd6bc 100644 --- a/worldbuilding_guide/hidden_objects.md +++ b/worldbuilding_guide/hidden_objects.md @@ -24,5 +24,17 @@ Room description hints at it: description: "A dusty corridor. One of the wall stones looks slightly out of place." ``` +### Safespot Objects + +Safespot objects should always be `hidden: true`. They only become visible in `look` when the player's effective safespot tier meets the object's `safespot.tier` requirement. This creates a natural discovery mechanic: completing quests and achievements reveals new coverage opportunities. + +Players can discover safespots by: +- Reading room descriptions for hints +- Trying `hide <object_name>` (name matching works even for hidden objects) +- Using `look <object>` once they know the name +- Gaining tier through progression, which reveals safespots in `look` + +See [Safespots](objects.md#safespots) for the full YAML format. + --- diff --git a/worldbuilding_guide/mobs.md b/worldbuilding_guide/mobs.md index 70afa6a..c19efff 100644 --- a/worldbuilding_guide/mobs.md +++ b/worldbuilding_guide/mobs.md @@ -71,6 +71,7 @@ ranged_defense: 3 | `attack_type` | Determines which player defense is used against this mob | | `stab_defense` through `ranged_defense` | Per-type defense bonuses | | `weakness` | Elemental weakness (solar/hydro/eco/bio) — for future Science combat | +| `size` | Mob size for safespot blocking: small/medium/large/massive (default: medium) | ### Protected/Unique Mobs diff --git a/worldbuilding_guide/objects.md b/worldbuilding_guide/objects.md index 38eaeeb..65ab430 100644 --- a/worldbuilding_guide/objects.md +++ b/worldbuilding_guide/objects.md @@ -227,3 +227,112 @@ Use the `sneak` command to see guard watch state changes in real time. --- +## Safespots + +Safespot objects provide cover that blocks melee attacks. Players use `hide <object>` to +crouch behind them and attack with ranged or science weapons. Melee attacks force the player +out of cover. + +```yaml +id: rock_outcrop +name: rock outcrop +color: "248" +hidden: true +inroom_description: "A jagged rock outcrop juts from the floor." +description: "A large, jagged rock formation providing natural cover." +safespot: + tier: 2 + max_block_size: large + max_occupants: 3 + unsafe_chance: 0.008 + decay_ticks: 500 + decay_chance: 0.01 + respawn_on_hide: false + respawn_ticks: 300 + levels: + - message: "The rock formation stands solid." + degrade_message: "The rock structure begins crumbling!" + - message: "The rock outcrop is cracked and worn." + degrade_message: "A few jagged bits are all that's left of the rock outcrop!" + - message: "Only a few jagged rocks remain." + degrade_message: "The rock outcrop crumbles to nothing!" +``` + +Safespot objects should be `hidden: true` so they only appear in `look` when the player's +effective safespot tier meets or exceeds the object's required tier. This creates a natural +discovery mechanic: as players complete quests and achievements, they begin to notice cover +they couldn't use before. + +### SafespotConfig Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `tier` | int | 0 | Required safespot tier to use this object | +| `max_block_size` | string | `""` (all) | Largest mob size blocked: small/medium/large/massive | +| `max_occupants` | int | 0 | Max players that can hide behind this object (0 = unlimited) | +| `unsafe_chance` | float64 | 0 | Per-tick chance (0.0-1.0) to be forced out of cover | +| `decay_ticks` | float64 | 0 | Guaranteed ticks before object degrades one level | +| `decay_chance` | float64 | 0 | Per-tick random chance (0.0-1.0) to degrade one level | +| `respawn_on_hide` | bool | false | If true, re-hiding resets the safespot to max level | +| `respawn_ticks` | float64 | 0 | Ticks until a destroyed safespot respawns (0 = never) | +| `levels` | []SafespotLevel | required | At least one level entry | + +### SafespotLevel Fields + +| Field | Type | Description | +|---|---|---| +| `message` | string | Shown to the player when the safespot is at this level | +| `degrade_message` | string | Broadcast to the room when the safespot degrades FROM this level | + +Levels are ordered from highest to lowest. The first entry is the safespot's best state; +the last entry is its weakest state before destruction. When the last level degrades, +the safespot is destroyed. + +### Mob Size + +Mobs must have a `size` field for safespot blocking to work: + +```yaml +# data/mobs/cow.yaml +size: small + +# data/mobs/moss_giant.yaml +size: large +``` + +Size ordering: `small` < `medium` < `large` < `massive`. Defaults to `medium` when absent. +A safespot with `max_block_size: large` blocks mobs of size small, medium, and large, +but not massive. + +### Progression + +Players gain safespot tier by completing quests and combat achievements, which set +player flags via talk node actions: + +| Flag | Source | Tier bonus | +|---|---|---| +| `quest_animal_magnetism` | Quest completion | +1 | +| `quest_dragon_slayer` | Quest completion | +1 | +| `achieve_medium_combat` | Combat achievement | +1 | +| `achieve_hard_combat` | Combat achievement | +2 | + +### Combat Integration + +- Melee mobs (attack_type: stab/slash/crush) cannot initiate aggro or land hits on + safespotted players if the mob's size is within the safespot's `max_block_size`. +- Ranged and science mobs (attack_type: ranged/science) ignore safespots entirely. +- If a safespotted player attacks with melee, they automatically leave cover. +- Hiding in combat takes 4 ticks; if the mob hits during this time, the hide fails. +- Hiding out of combat takes 1 tick. + +### Option: safespot_alert + +Players can customize the message shown when forced out of a safespot: + + option safespot_alert "{196 bold}** DANGER **{/} Cover blown!" + option safespot_alert none + +Default: `"{196 bold}** Your safespot has been compromised! **{/}"` + +--- + |
