diff options
| -rw-r--r-- | AGENTS.md | 39 | ||||
| -rw-r--r-- | TODO.md | 7 | ||||
| -rw-r--r-- | cmd/mud/main.go | 1 | ||||
| -rw-r--r-- | data/help/tech.yaml | 21 | ||||
| -rw-r--r-- | data/items/capacitor_gloves.yaml | 11 | ||||
| -rw-r--r-- | data/items/neural_headband.yaml | 11 | ||||
| -rw-r--r-- | data/items/power_amulet.yaml | 8 | ||||
| -rw-r--r-- | data/objects/charging_station.yaml | 4 | ||||
| -rw-r--r-- | data/rooms/1.yaml | 1 | ||||
| -rw-r--r-- | internal/config/config.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_attack.go | 28 | ||||
| -rw-r--r-- | internal/game/cmd_score.go | 15 | ||||
| -rw-r--r-- | internal/game/cmd_tech.go | 196 | ||||
| -rw-r--r-- | internal/game/cmd_use.go | 4 | ||||
| -rw-r--r-- | internal/game/game.go | 6 | ||||
| -rw-r--r-- | internal/game/prompt.go | 2 | ||||
| -rw-r--r-- | internal/game/tech.go | 250 | ||||
| -rw-r--r-- | internal/game/tick.go | 77 | ||||
| -rw-r--r-- | internal/player/player.go | 55 | ||||
| -rw-r--r-- | worldbuilding_guide/items.md | 83 | ||||
| -rw-r--r-- | worldbuilding_guide/mobs.md | 56 |
21 files changed, 857 insertions, 20 deletions
@@ -55,13 +55,21 @@ internal/ | `action_room.go` | RunEnterSteps (on_enter scripts), BroadcastRespawns | | `action_state.go` | ActionType consts, ActionState struct, Description() for look display | | `action_default_target.go` | Smart auto-selection for gather/attack | -| `tick.go` | DisconnectTick, RegenTick, WanderTick, SharedDepletionTick, MoveTick, VisualTick | +| `tick.go` | DisconnectTick, RegenTick, WanderTick, SharedDepletionTick, MoveTick, VisualTick, TechTick | | `map.go` | BFS graph builder, tiny map, full map, display utilities (strip, trim) | | `types.go` | EquipSlots, itemMatch, xpGain, deathDrop | | `utils.go` | Helper functions (plural, parseQty, parseChoiceIndex, formatPickupList, etc.) | | `help.go` | Help topic YAML loading and display | | `color.go` | colorMode(), colorize(), colorTag() helpers | | `table.go` | Unicode/ASCII table rendering for score/option/help/crafting menus | +| `tech.go` | TechDef struct, 25 hardcoded tech definitions, techLevelBonus, applyTechProtection, totalTechBonus | +| `cmd_tech.go` | doTech, toggleTech, doTechList, doTechQuick — technology command handlers | +| `cmd_stats.go` | doStats — equipment bonus totals, attack roll, max hit display | +| `cmd_clean.go` | doClean — background herb cleaning (Pharmacy) | +| `cmd_mix.go` | doMix — potion mixing via production system (Pharmacy) | +| `action_clean.go` | advanceClean — background clean action lifecycle | +| `equip_stats.go` | playerEquipBonuses — sums all equipped items into one ItemStats | +| `aggro.go` | checkAggro — aggressive mob attack on room enter | ## Key Conventions @@ -154,6 +162,8 @@ Item and object YAML `color` fields also support gradient syntax: `color: "g:196 | `color <target> off` | Disable color for a specific target | | `colortable` | Display xterm-256 color reference chart | | `style <name>` | Set combat style: accurate, aggressive, defensive, balanced (prefix match) | +| `stats` | Show total equipment bonuses, attack type, attack roll, max hit | +| `tech [name]` / `t` | Toggle tech on/off, `tech list` shows all, `tech quick <name>` sets quick tech | ### Free (stackable, execute before active) | Command | Description | @@ -353,17 +363,30 @@ See `internal/game/action_burn.go`. ## Combat System +**Attack types.** Weapons have an `attack_type` field (stab/slash/crush/ranged/science). The attack type determines which of the player's 5 attack bonuses is used for accuracy and which of the mob's 5 defense bonuses is used. Equipment stats use per-type fields: `stab_attack`, `slash_attack`, `crush_attack`, `science_attack`, `ranged_attack` (and matching defense fields). + +**OSRS accuracy formula.** `HitChance(attackRoll, defenseRoll)` uses a continuous probability curve: if `a > d`, chance = `1 - (d+2)/(2*(a+1))`; otherwise `a/(2*(d+1))`. Equal rolls ≈ 50% hit chance. + +**Ranged combat.** When `weapon_type` is "ranged", uses Ranged level for attack rolls, `ranged_attack` for accuracy, `ranged_strength` from ammo for max hit. Consumes 1 ammo per attack. If ammo runs out, combat ends. XP: 75% Ranged, 25% Hitpoints (all styles). + +**Aggressive mobs.** Mobs with `aggressive: true` auto-attack players entering their room (1-tick delay). Only aggros if `playerLevel <= mobLevel * 2` (OSRS rule). Checked on room enter and login via `checkAggro()`. + +**Equipment requirements.** Items can have `requirements: { attack: 20, defense: 10 }`. Checked in `doWear` and `doWearAll` before equipping. Displayed when examining items. + +**Technology in combat.** Active tech boosts (Clarity, Amplifier, etc.) add percentage-based level bonuses to attack/strength/defense/ranged. Protection techs (Kinetic Barrier, Projectile Screen, Neural Firewall) reduce incoming damage by 40%. Dead Man's Switch deals 25% max HP damage to the mob on player death. + **Fleeing combat:** Players can move in any direction to attempt to flee. Movement takes the normal tick delay (default 4, reduced by equipment). During the countdown, the mob continues attacking. If the mob lands a hit, the escape fails ("Can't escape!") and combat resumes. If no hit lands, the player escapes. With Cape of Agility, fleeing is instant. The `run_countdown` option shows tick-by-tick countdown messages. -**Death mechanic:** Player drops credits to ground. If player has >3 items (inventory + equipment), the 3 most valuable are kept and rest are dropped. Player teleports to room 1 at full HP. +**Death mechanic:** Player drops credits to ground. If player has >3 items (inventory + equipment), the 3 most valuable are kept and rest are dropped. Player teleports to room 1 at full HP. All active techs deactivated on death. **XP formula:** `baseXP = dmg * 4`, distributed by attack style: - Accurate: 75% Attack, 25% Hitpoints - Aggressive: 75% Strength, 25% Hitpoints - Defensive: 75% Defense, 25% Hitpoints - Balanced: 25% each Attack/Strength/Defense/Hitpoints +- Ranged (all styles): 75% Ranged, 25% Hitpoints -**Mob combat level:** `0.25 * (attack + strength + defense + maxHP)` +**Mob combat level:** `base = (def + hp) / 4`, `offensive = max(melee=(atk+str)/4, ranged=rng*3/8, science=sci*3/8)`, `level = floor(base + offensive)`. **Player combat level:** Ranged-or-melee dominant calculation. Includes Technology (0.25) and Science (0.125). @@ -402,6 +425,8 @@ Account-wide options set via `option <name> <value>`. Options are stored in the Prompt is NOT an option — it is a per-character setting via the `prompt` command. Default: `"> "`. `prompt none` sets it to blank. Stored in character YAML as `prompt:`. +Prompt variables: `%h` (HP), `%H` (max HP), `%b` (battery), `%B` (max battery), `%c` (credits), `%i` (free slots), `%s` (style short), `%S` (style long), `%m` (mob HP), `%M` (mob max HP), `%X_<skill>` (XP), `%x_<skill>` (XP to next). + New accounts are asked "Should I disable color? [y/N]" after password creation. Pressing enter defaults to No (xterm256 enabled). Choosing Y sets color to "none". ## ItemDef Fields @@ -418,8 +443,10 @@ From `internal/object/item.go`: | `stackable` | Can stack in inventory | | `equip_slot` | head/neck/torso/legs/hands/feet/back/ammo/main_hand/off_hand/ring | | `weapon_type` | "melee" / "ranged" / "science" | -| `stats` | attack/strength/defense/science/technology bonuses | +| `attack_type` | "stab" / "slash" / "crush" / "ranged" / "science" | +| `stats` | Per-type attack/defense bonuses, strength, ranged strength, science damage, technology bonus | | `speed` | Attack speed | +| `requirements` | Skill level requirements to equip (e.g., `{attack: 20, defense: 10}`) | | `tool_type` | Tool type string for gather requirements | | `tool_speed` | Speed bonus for gathering | | `burn_ticks` | Burn duration for firemaking | @@ -534,7 +561,7 @@ The unified production cycle handles: "How many?" prompt, start message, timed l | Production | Cooking, Smithing, Crafting, Fletching, Pharmacy, Construction, Firemaking | | Utility | Agility | -Implemented: Mining, Fishing, Woodcutting, Firemaking, Cooking, Smithing (smelting + smithing), Fletching, Pharmacy (herb cleaning + potion mixing). The rest are stubs (skill defined, XP tracked, but no behaviors/objects/items yet). +Implemented: Mining, Fishing, Woodcutting, Firemaking, Cooking, Smithing (smelting + smithing), Fletching, Pharmacy (herb cleaning + potion mixing), Technology (25 techs, battery drain, 1-tick flicking, combat integration). The rest are stubs (skill defined, XP tracked, but no behaviors/objects/items yet). ## Places to Be Careful @@ -553,3 +580,5 @@ Implemented: Mining, Fishing, Woodcutting, Firemaking, Cooking, Smithing (smelti - Movement takes multiple ticks (default 4, reduced by Graceful set and Cape of Agility). Cape of Agility makes movement instant (1 tick). Graceful pieces each reduce by 0.25 ticks; full set bonus adds 0.5 for 2 ticks total. Cape overrides Graceful. - During combat, movement is a flee attempt. Mob hits during the countdown abort the flee ("Can't escape!"). - Level-up messages output `*** You are now level N <skill>! ***` across all skills that gain XP. + +Skills provide specialized instructions and workflows for specific tasks. @@ -11,6 +11,11 @@ Detailed implementation plans for each skill/system are in `skill_plans/`. - [x] Smithing — furnace + anvil, ore → bars → weapons/armor - [x] Fletching — logs → arrow shafts/bows, fletching as background action - [x] Equipment — bronze/iron/steel/mithril/adamantite/rune weapons and armor, bows, arrows +- [x] Step 1: Pharmacy Rename — Alchemy → Pharmacy across codebase, SkillAbbr "pha", migration code in LoadCharacter +- [x] Step 2: Combat Overhaul — OSRS per-type attack/defense bonuses (stab/slash/crush/science/ranged), OSRS accuracy formula, AttackType on weapons, per-type mob defenses, 8 new mobs, ranged combat with ammo, stats command, showItemStats on look +- [x] Step 3: Combat Feel — Aggressive mobs (auto-attack on room enter, OSRS combat level rule), equipment skill requirements on all tiered gear +- [x] Step 4: Technology — 25 techs (attack/str/def/ranged/science boosts, 3 protection techs, utility techs), Battery drain, 1-tick flicking, tech command, TechTick, charging station, score/prompt integration +- [x] Step 10: Pharmacy — clean (background action), mix (production), 14 herbs, 16 potions, 14 unfinished potions, 11 reagents, 30 recipes, herb drop tables --- @@ -52,7 +57,7 @@ Small additions that make combat feel complete before building bigger systems on See `skill_plans/technology.md`. Battery = prayer points. Techs = prayers. Players toggle techs for combat bonuses and damage reduction. Battery drains per tick while techs are active. 1-tick flicking works like OSRS — enable and disable within the same tick for no drain. -- [ ] **Technology System** — Add `Battery float64` and `ActiveTechs map[string]bool` to Player. Define ~25 techs in Go (attack/strength/defense/ranged/science boosts at 3 tiers, 3 protection techs, utility techs). Add `tech` command (Instant): `tech <name>` toggles, `tech` toggles quick tech, `tech list` shows all. Add `TechTick()` to drain battery based on active techs, reduced by equipment TechnologyBonus. Implement 1-tick flicking (drain based on tick-start snapshot). Integrate protection techs into `mobAttack()` (40% damage reduction). Integrate stat boost techs into `playerAttack()`. Add "Charging Station" object to recharge battery. Show battery and active techs on score page. +- [x] **Technology System** — Add `Battery float64` and `ActiveTechs map[string]bool` to Player. Define ~25 techs in Go (attack/strength/defense/ranged/science boosts at 3 tiers, 3 protection techs, utility techs). Add `tech` command (Instant): `tech <name>` toggles, `tech` toggles quick tech, `tech list` shows all. Add `TechTick()` to drain battery based on active techs, reduced by equipment TechnologyBonus. Implement 1-tick flicking (drain based on tick-start snapshot). Integrate protection techs into `mobAttack()` (40% damage reduction). Integrate stat boost techs into `playerAttack()`. Add "Charging Station" object to recharge battery. Show battery and active techs on score page. --- diff --git a/cmd/mud/main.go b/cmd/mud/main.go index b8024ed..55804d3 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -50,6 +50,7 @@ func main() { g.SharedDepletionTick() g.FireTick() g.AdvanceActions() + g.TechTick() g.ConsumeTick() g.BroadcastRespawns() g.VisualTick() diff --git a/data/help/tech.yaml b/data/help/tech.yaml new file mode 100644 index 0000000..25bb358 --- /dev/null +++ b/data/help/tech.yaml @@ -0,0 +1,21 @@ +name: "tech" +category: "Commands" +description: | + Technology is a skill that allows you to activate powerful tech modules + that enhance your combat abilities. Each tech drains battery while active. + + Battery is your tech power supply. Max battery equals your Technology level. + Battery does not regenerate naturally -- recharge at a Charging Station. + + Commands: + tech Toggle your quick tech (or show list if none set) + tech <name> Toggle a specific tech on/off + tech list Show all available techs + tech quick <name> Set your quick tech + t Shortcut for 'tech' (toggle quick tech) + + Tips: + - Multiple techs can be active simultaneously (from different groups) + - Equipment with Technology bonus reduces drain rate + - Toggling tech off and back on between ticks avoids drain ("flicking") + - Protection techs reduce incoming damage by 40% diff --git a/data/items/capacitor_gloves.yaml b/data/items/capacitor_gloves.yaml new file mode 100644 index 0000000..e5af9ed --- /dev/null +++ b/data/items/capacitor_gloves.yaml @@ -0,0 +1,11 @@ +id: capacitor_gloves +name: Capacitor Gloves +description: "Gloves woven with superconducting filaments." +color: "250" +value: 300 +equip_slot: hands +stats: + stab_defense: 2 + slash_defense: 2 + crush_defense: 2 + technology_bonus: 2 diff --git a/data/items/neural_headband.yaml b/data/items/neural_headband.yaml new file mode 100644 index 0000000..e01e57d --- /dev/null +++ b/data/items/neural_headband.yaml @@ -0,0 +1,11 @@ +id: neural_headband +name: Neural Headband +description: "A thin band of circuitry that amplifies neural signals, reducing battery drain." +color: "39" +value: 500 +equip_slot: head +stats: + stab_defense: 1 + slash_defense: 1 + crush_defense: 1 + technology_bonus: 4 diff --git a/data/items/power_amulet.yaml b/data/items/power_amulet.yaml new file mode 100644 index 0000000..bd136a4 --- /dev/null +++ b/data/items/power_amulet.yaml @@ -0,0 +1,8 @@ +id: power_amulet +name: Power Amulet +description: "A pendant containing a miniature power cell that augments tech efficiency." +color: "220" +value: 800 +equip_slot: neck +stats: + technology_bonus: 3 diff --git a/data/objects/charging_station.yaml b/data/objects/charging_station.yaml new file mode 100644 index 0000000..ef63c7d --- /dev/null +++ b/data/objects/charging_station.yaml @@ -0,0 +1,4 @@ +id: charging_station +name: Charging Station +description: "A humming terminal with exposed capacitor banks. Its indicator light pulses with stored energy. You could recharge your battery here." +inroom_description: "A charging station hums quietly against the wall." diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml index aaf6dda..6fe6518 100644 --- a/data/rooms/1.yaml +++ b/data/rooms/1.yaml @@ -9,6 +9,7 @@ exits: down: 31 objects: - id: lumby_fountain + - id: charging_station mobs: - "newbie_trainer" spawns: diff --git a/internal/config/config.go b/internal/config/config.go index af85c61..a9f0020 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,8 @@ func DefaultColors() ColorsConfig { "credits_pickup": "220", "visual_tick": "33 dim", "visual_first_tick": "196 bold", + "battery": "45", + "tech_depleted": "196", } } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 4d2d19f..401837f 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -209,25 +209,27 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI if isRanged { rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) equipAttack := totals.RangedAttack - attRoll = combat.AttackRoll(p.Level(player.Ranged), rangedBonus, equipAttack) + effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + attRoll = combat.AttackRoll(effectiveRanged, rangedBonus, equipAttack) mobDefBonus := combat.SelectDefenseBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength) + maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength) } else { attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) equipAttack := combat.SelectAttackBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) - attRoll = combat.AttackRoll(p.Level(player.Attack), attBonus, equipAttack) + effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + attRoll = combat.AttackRoll(effectiveAttack, attBonus, equipAttack) mobDefBonus := combat.SelectDefenseBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus) + maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) } if combat.HitCheck(attRoll, defRoll) { @@ -311,11 +313,12 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst equipDef := combat.SelectDefenseBonus(mobAttackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) - defRoll := combat.DefenseRoll(p.Level(player.Defense), defStyleBonus, equipDef) + defRoll := combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense"), defStyleBonus, equipDef) if combat.HitCheck(attRoll, defRoll) { maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) dmg := combat.RollDamage(maxHit) + dmg = g.applyTechProtection(p, mob, dmg) p.HP -= dmg if p.HP < 0 { @@ -358,6 +361,21 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst p.ActionState = nil if p.HP <= 0 { + if p.HasActiveTech("retribution") { + retDef := GetTechDef("retribution") + if retDef != nil && mob != nil && mob.HP > 0 { + retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct) + if retDmg > 0 { + mob.HP -= retDmg + if mob.HP < 0 { + mob.HP = 0 + } + sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!", + mobDisplayName(mob, true), retDmg)) + } + } + } + p.DeactivateAllTechs() sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) p.ClearMoveState() g.dropItemsOnDeath(p) diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index c31433b..2005903 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -17,6 +17,7 @@ func (g *Game) doScore(sess *net.Session) { fmt.Sprintf("Name: %s", g.colorize(sess, "player_name", p.Name)), fmt.Sprintf("Combat Level: %s", color.Render(mode, color.Parse("230"), fmt.Sprint(p.CombatLevel()))), fmt.Sprintf("HP: %s/%s", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), color.Render(mode, color.Parse("230"), fmt.Sprint(p.MaxHP()))), + fmt.Sprintf("Battery: %s/%s", g.colorize(sess, "battery", fmt.Sprintf("%.1f", p.Battery)), color.Render(mode, color.Parse("230"), fmt.Sprintf("%.0f", p.MaxBattery()))), fmt.Sprintf("Credits: %s", g.colorize(sess, "credits_pickup", fmt.Sprint(p.Credits))), ) @@ -42,4 +43,18 @@ func (g *Game) doScore(sess *net.Session) { for _, line := range t.Render(p.OptionBool("unicode")) { sess.WriteLine(line) } + + if len(p.ActiveTechs) > 0 { + sess.WriteLine("") + sess.WriteLine("Active Tech:") + for _, id := range p.ActiveTechList() { + def := GetTechDef(id) + if def != nil { + sess.WriteLine(fmt.Sprintf(" %s %s", + color.Render(mode, color.Parse("82"), "[ON]"), + color.Render(mode, color.Parse("75"), def.Name), + )) + } + } + } } diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go new file mode 100644 index 0000000..f51ccd9 --- /dev/null +++ b/internal/game/cmd_tech.go @@ -0,0 +1,196 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doTech(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + techLevel := p.Level(player.Technology) + + if techLevel < 1 { + sess.WriteLine("\nYou need at least level 1 Technology to use tech.") + return + } + + input = strings.TrimSpace(input) + + if input == "" { + if p.QuickTech == "" { + g.doTechList(sess) + return + } + g.toggleTech(sess, p, p.QuickTech) + return + } + + lower := strings.ToLower(input) + + if lower == "list" { + g.doTechList(sess) + return + } + + if strings.HasPrefix(lower, "quick ") { + name := strings.TrimSpace(input[6:]) + g.doTechQuick(sess, p, name) + return + } + + matches := TechByPrefixMatch(lower) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("\nUnknown tech: %s", input)) + return + } + if len(matches) > 1 { + var names []string + for _, m := range matches { + names = append(names, m.Name) + } + sess.WriteLine(fmt.Sprintf("\nAmbiguous tech: %s. Matches: %s", input, strings.Join(names, ", "))) + return + } + + g.toggleTech(sess, p, matches[0].ID) +} + +func (g *Game) toggleTech(sess *net.Session, p *player.Player, techID string) { + def := GetTechDef(techID) + if def == nil { + sess.WriteLine("\nUnknown tech.") + return + } + + techLevel := p.Level(player.Technology) + + if p.HasActiveTech(techID) { + p.DeactivateTech(techID) + sess.WriteLine(fmt.Sprintf("\n%s deactivated.", def.Name)) + return + } + + if techLevel < def.Level { + sess.WriteLine(fmt.Sprintf("\nYou need level %d Technology to use %s.", def.Level, def.Name)) + return + } + + if p.Battery <= 0 { + sess.WriteLine("\nYour battery is depleted!") + return + } + + deactivated := g.deactivateGroup(p, def.Group) + for _, name := range deactivated { + sess.WriteLine(fmt.Sprintf("\n%s deactivated.", name)) + } + + p.ActivateTech(techID) + sess.WriteLine(fmt.Sprintf("\n%s activated.", def.Name)) +} + +func (g *Game) deactivateGroup(p *player.Player, group string) []string { + if group == "" { + return nil + } + var deactivated []string + for id := range p.ActiveTechs { + def := GetTechDef(id) + if def != nil && def.Group == group { + p.DeactivateTech(id) + deactivated = append(deactivated, def.Name) + } + } + return deactivated +} + +func (g *Game) doTechList(sess *net.Session) { + p := sess.Player.(*player.Player) + mode := g.colorMode(sess) + techLevel := p.Level(player.Technology) + + sess.WriteLines( + "", + fmt.Sprintf("Battery: %s/%s", + g.colorize(sess, "battery", fmt.Sprintf("%.1f", p.Battery)), + color.Render(mode, color.Parse("230"), fmt.Sprintf("%.0f", p.MaxBattery())), + ), + ) + + t := &Table{Title: "Technology", Columns: []string{"Tech", "Level", "Drain/tick", "Effect", "Status"}} + + for _, tech := range AllTechs { + status := "" + nameStr := tech.Name + if tech.Level <= techLevel { + if p.HasActiveTech(tech.ID) { + status = color.Render(mode, color.Parse("82"), "ON") + } else { + status = color.Render(mode, color.Parse("240"), "off") + } + nameStr = color.Render(mode, color.Parse("75"), tech.Name) + } else { + status = color.Render(mode, color.Parse("160"), "locked") + nameStr = color.Render(mode, color.Parse("240"), tech.Name) + } + + effect := techEffectString(tech) + + t.Rows = append(t.Rows, []string{ + nameStr, + fmt.Sprint(tech.Level), + fmt.Sprintf("%.2f", tech.DrainRate), + effect, + status, + }) + } + + for _, line := range t.Render(p.OptionBool("unicode")) { + sess.WriteLine(line) + } + + if p.QuickTech != "" { + qDef := GetTechDef(p.QuickTech) + if qDef != nil { + sess.WriteLine(fmt.Sprintf("\nQuick tech: %s", qDef.Name)) + } + } +} + +func (g *Game) doTechQuick(sess *net.Session, p *player.Player, input string) { + if input == "" { + if p.QuickTech == "" { + sess.WriteLine("\nNo quick tech set. Usage: tech quick <name>") + } else { + def := GetTechDef(p.QuickTech) + name := p.QuickTech + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("\nQuick tech: %s", name)) + } + return + } + + matches := TechByPrefixMatch(strings.ToLower(input)) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("\nUnknown tech: %s", input)) + return + } + if len(matches) > 1 { + var names []string + for _, m := range matches { + names = append(names, m.Name) + } + sess.WriteLine(fmt.Sprintf("\nAmbiguous tech: %s. Matches: %s", input, strings.Join(names, ", "))) + return + } + + p.QuickTech = matches[0].ID + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("\nQuick tech set to %s.", matches[0].Name)) +} diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 4d4ce88..7d40cc2 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -19,6 +19,10 @@ func (g *Game) doUse(sess *net.Session, input string) { p := sess.Player.(*player.Player) g.CancelAction(p) + if g.tryRecharge(sess, p, input) { + return + } + lower := strings.ToLower(input) sep := "" diff --git a/internal/game/game.go b/internal/game/game.go index 8b5f924..15236f4 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -82,6 +82,7 @@ func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { if p, ok := sess.Player.(*player.Player); ok { + p.DeactivateAllTechs() g.charsMu.Lock() delete(g.loggedInChars, p.Name) g.charsMu.Unlock() @@ -139,7 +140,8 @@ func classifyCommand(cmd string) CommandClass { "look", "l", "exits", "help", "map", "option", "options", "alias", "unalias", "description", "desc", "queued", "color", "colors", - "colortable", "prompt", "style", "stats": + "colortable", "prompt", "style", "stats", + "tech", "t": return ClassInstant case "equip", "eq", "equipment", "wear", "wield", "remove", "unwear", "unwield": return ClassFree @@ -316,6 +318,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doScore(sess) case "stats": g.doStats(sess) + case "tech", "t": + g.doTech(sess, strings.Join(args, " ")) case "i", "inv", "inventory": g.doInventory(sess) case "quit": diff --git a/internal/game/prompt.go b/internal/game/prompt.go index de88bc6..c3768fe 100644 --- a/internal/game/prompt.go +++ b/internal/game/prompt.go @@ -36,6 +36,8 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string { text = strings.ReplaceAll(text, "%%", "\x00") text = strings.ReplaceAll(text, "%h", fmt.Sprint(p.HP)) text = strings.ReplaceAll(text, "%H", fmt.Sprint(p.MaxHP())) + text = strings.ReplaceAll(text, "%b", fmt.Sprintf("%.1f", p.Battery)) + text = strings.ReplaceAll(text, "%B", fmt.Sprintf("%.0f", p.MaxBattery())) text = strings.ReplaceAll(text, "%c", fmt.Sprint(p.Credits)) text = strings.ReplaceAll(text, "%i", fmt.Sprint(p.FreeSlots())) text = strings.ReplaceAll(text, "%s", attackStyleShort(p.AttackStyle)) diff --git a/internal/game/tech.go b/internal/game/tech.go new file mode 100644 index 0000000..a24c66d --- /dev/null +++ b/internal/game/tech.go @@ -0,0 +1,250 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +type TechEffects struct { + AttackPercent int + StrengthPercent int + DefensePercent int + RangedPercent int + SciencePercent int + ProtectMelee bool + ProtectRanged bool + ProtectScience bool + DamageReduction float64 + HPRegenMulti float64 + PreserveDrain float64 + RetributionPct float64 +} + +type TechDef struct { + ID string + Name string + Level int + DrainRate float64 + Category string + Group string + Effects TechEffects +} + +var AllTechs []TechDef +var techByID map[string]*TechDef + +func init() { + AllTechs = []TechDef{ + {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 5}}, + {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 10}}, + {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15}}, + + {ID: "amplifier_1", Name: "Power Amplifier", Level: 7, DrainRate: 0.05, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 5}}, + {ID: "amplifier_2", Name: "Enhanced Amplifier", Level: 23, DrainRate: 0.10, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 10}}, + {ID: "amplifier_3", Name: "Superior Amplifier", Level: 49, DrainRate: 0.15, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 15}}, + + {ID: "shield_1", Name: "Energy Shield", Level: 10, DrainRate: 0.05, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 5}}, + {ID: "shield_2", Name: "Enhanced Shield", Level: 28, DrainRate: 0.10, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 10}}, + {ID: "shield_3", Name: "Superior Shield", Level: 52, DrainRate: 0.15, Category: "defense", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15}}, + + {ID: "targeting_1", Name: "Targeting System", Level: 8, DrainRate: 0.05, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 5}}, + {ID: "targeting_2", Name: "Enhanced Targeting", Level: 22, DrainRate: 0.10, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 10}}, + {ID: "targeting_3", Name: "Superior Targeting", Level: 46, DrainRate: 0.15, Category: "ranged", Group: "ranged_tier", Effects: TechEffects{RangedPercent: 15}}, + + {ID: "focus_1", Name: "Neural Focus", Level: 9, DrainRate: 0.05, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 5}}, + {ID: "focus_2", Name: "Enhanced Focus", Level: 27, DrainRate: 0.10, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 10}}, + {ID: "focus_3", Name: "Superior Focus", Level: 55, DrainRate: 0.15, Category: "science", Group: "science_tier", Effects: TechEffects{SciencePercent: 15}}, + + {ID: "protect_melee", Name: "Kinetic Barrier", Level: 37, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectMelee: true, DamageReduction: 0.4}}, + {ID: "protect_ranged", Name: "Projectile Screen", Level: 40, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectRanged: true, DamageReduction: 0.4}}, + {ID: "protect_science", Name: "Neural Firewall", Level: 43, DrainRate: 0.20, Category: "protection", Group: "protection", Effects: TechEffects{ProtectScience: true, DamageReduction: 0.4}}, + + {ID: "regen", Name: "Nano Repair", Level: 22, DrainRate: 0.10, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 2.0}}, + {ID: "rapid_heal", Name: "Rapid Repair", Level: 31, DrainRate: 0.15, Category: "utility", Group: "regen_tier", Effects: TechEffects{HPRegenMulti: 4.0}}, + {ID: "preserve", Name: "Power Saver", Level: 55, DrainRate: 0.05, Category: "utility", Group: "", Effects: TechEffects{PreserveDrain: 0.2}}, + {ID: "retribution", Name: "Dead Man's Switch", Level: 46, DrainRate: 0.10, Category: "utility", Group: "", Effects: TechEffects{RetributionPct: 0.25}}, + + {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15, StrengthPercent: 15}}, + {ID: "fortify", Name: "Fortify", Level: 65, DrainRate: 0.25, Category: "combo", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15, HPRegenMulti: 2.0}}, + } + + techByID = make(map[string]*TechDef, len(AllTechs)) + for i := range AllTechs { + techByID[AllTechs[i].ID] = &AllTechs[i] + } +} + +func GetTechDef(id string) *TechDef { + return techByID[id] +} + +func TechByPrefixMatch(input string) []*TechDef { + input = strings.ToLower(input) + var exact []*TechDef + var prefix []*TechDef + for i := range AllTechs { + lower := strings.ToLower(AllTechs[i].Name) + lowerID := strings.ToLower(AllTechs[i].ID) + if lower == input || lowerID == input { + exact = append(exact, &AllTechs[i]) + } else if strings.HasPrefix(lower, input) || strings.HasPrefix(lowerID, input) { + prefix = append(prefix, &AllTechs[i]) + } + } + if len(exact) > 0 { + return exact + } + return prefix +} + +func techEffectString(tech TechDef) string { + var parts []string + e := tech.Effects + if e.AttackPercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Attack", e.AttackPercent)) + } + if e.StrengthPercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Strength", e.StrengthPercent)) + } + if e.DefensePercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Defense", e.DefensePercent)) + } + if e.RangedPercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Ranged", e.RangedPercent)) + } + if e.SciencePercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Science", e.SciencePercent)) + } + if e.ProtectMelee { + parts = append(parts, fmt.Sprintf("%.0f%% melee protection", e.DamageReduction*100)) + } + if e.ProtectRanged { + parts = append(parts, fmt.Sprintf("%.0f%% ranged protection", e.DamageReduction*100)) + } + if e.ProtectScience { + parts = append(parts, fmt.Sprintf("%.0f%% science protection", e.DamageReduction*100)) + } + if e.HPRegenMulti > 0 { + parts = append(parts, fmt.Sprintf("%.0fx HP regen", e.HPRegenMulti)) + } + if e.PreserveDrain > 0 { + parts = append(parts, fmt.Sprintf("%.0f%% drain reduction", e.PreserveDrain*100)) + } + if e.RetributionPct > 0 { + parts = append(parts, fmt.Sprintf("%.0f%% retribution", e.RetributionPct*100)) + } + return strings.Join(parts, ", ") +} + +func (g *Game) totalTechBonus(p *player.Player) int { + total := 0 + for _, itemID := range p.Equipment { + def, err := g.ItemStore.Load(itemID) + if err == nil { + total += def.Stats.TechnologyBonus + } + } + return total +} + +func (g *Game) techLevelBonus(p *player.Player, stat string) int { + if len(p.ActiveTechs) == 0 { + return 0 + } + + totalPercent := 0 + for id := range p.ActiveTechs { + def := GetTechDef(id) + if def == nil { + continue + } + switch stat { + case "attack": + totalPercent += def.Effects.AttackPercent + case "strength": + totalPercent += def.Effects.StrengthPercent + case "defense": + totalPercent += def.Effects.DefensePercent + case "ranged": + totalPercent += def.Effects.RangedPercent + case "science": + totalPercent += def.Effects.SciencePercent + } + } + + if totalPercent == 0 { + return 0 + } + + var baseLevel int + switch stat { + case "attack": + baseLevel = p.Level(player.Attack) + case "strength": + baseLevel = p.Level(player.Strength) + case "defense": + baseLevel = p.Level(player.Defense) + case "ranged": + baseLevel = p.Level(player.Ranged) + case "science": + baseLevel = p.Level(player.Science) + } + + return baseLevel * totalPercent / 100 +} + +func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bool { + lower := strings.ToLower(strings.TrimSpace(input)) + instances := g.World.FindObjInstances(p.RoomID, lower) + for _, st := range instances { + if st.DefID == "charging_station" || st.DefID == "power_conduit" { + if p.Battery >= p.MaxBattery() { + sess.WriteLine("\nYour battery is already full.") + } else { + p.Battery = p.MaxBattery() + g.AccountStore.SaveCharacter(p) + sess.WriteLine(g.colorize(sess, "battery", + "\nYou connect to the charging station. Your battery is fully recharged.")) + } + return true + } + } + return false +} + +func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int { + if len(p.ActiveTechs) == 0 { + return dmg + } + + attackType := mob.AttackType + if attackType == "" { + attackType = "crush" + } + + var protectTechID string + switch attackType { + case "stab", "slash", "crush": + protectTechID = "protect_melee" + case "ranged": + protectTechID = "protect_ranged" + case "science": + protectTechID = "protect_science" + } + + if protectTechID != "" && p.HasActiveTech(protectTechID) { + def := GetTechDef(protectTechID) + if def != nil { + reduced := int(float64(dmg) * (1.0 - def.Effects.DamageReduction)) + if reduced < 0 { + reduced = 0 + } + return reduced + } + } + return dmg +} diff --git a/internal/game/tick.go b/internal/game/tick.go index 8c40c96..4ab7e13 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -46,7 +46,19 @@ func (g *Game) RegenTick() { p.RegenerateTick = 0 continue } - p.RegenerateTick-- + regenSpeed := 1 + if p.ActiveTechs != nil { + for id := range p.ActiveTechs { + def := GetTechDef(id) + if def != nil && def.Effects.HPRegenMulti > 0 { + multi := int(def.Effects.HPRegenMulti) + if multi > regenSpeed { + regenSpeed = multi + } + } + } + } + p.RegenerateTick -= regenSpeed if p.RegenerateTick <= 0 { p.HP++ if p.HP >= p.MaxHP() { @@ -201,6 +213,69 @@ func (g *Game) legalMobExits(inst *world.MobInstance) []int { return legal } +func (g *Game) TechTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p == nil || len(p.ActiveTechs) == 0 { + continue + } + + totalDrain := 0.0 + hasPreserve := false + preserveReduction := 0.0 + + for id := range p.ActiveTechs { + def := GetTechDef(id) + if def != nil && def.Effects.PreserveDrain > 0 { + hasPreserve = true + preserveReduction = def.Effects.PreserveDrain + } + } + + for id := range p.ActiveTechs { + def := GetTechDef(id) + if def == nil { + continue + } + if p.TechActivatedSinceTick[id] { + continue + } + drain := def.DrainRate + if hasPreserve && def.Effects.PreserveDrain == 0 { + drain *= (1.0 - preserveReduction) + } + totalDrain += drain + } + + p.TechActivatedSinceTick = nil + + equipTechBonus := g.totalTechBonus(p) + if equipTechBonus > 0 { + reduction := float64(equipTechBonus) * 0.01 + if reduction > 0.5 { + reduction = 0.5 + } + totalDrain *= (1.0 - reduction) + } + + if totalDrain <= 0 { + continue + } + + p.Battery -= totalDrain + if p.Battery <= 0 { + p.Battery = 0 + p.DeactivateAllTechs() + sess.WriteLine(g.colorize(sess, "tech_depleted", + "\nYour battery is depleted! All tech has been disabled.")) + g.writePrompt(sess) + } + } +} + func (g *Game) ConsumeTick() { if g.Hub == nil { return diff --git a/internal/player/player.go b/internal/player/player.go index 4b26a84..a970a00 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -1,7 +1,11 @@ package player -import "thehouseoficarus/internal/action" -import "thehouseoficarus/internal/object" +import ( + "sort" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/object" +) type SkillName string @@ -173,6 +177,10 @@ type Player struct { MoveDirection string `yaml:"-"` MoveTarget int `yaml:"-"` VisualTickCurrent int `yaml:"-"` + Battery float64 `yaml:"battery"` + ActiveTechs map[string]bool `yaml:"-"` + QuickTech string `yaml:"quick_tech,omitempty"` + TechActivatedSinceTick map[string]bool `yaml:"-"` } func (p *Player) ClearMoveState() { @@ -288,6 +296,7 @@ func New(name string) *Player { } p.Skills[Hitpoints] = XPForLevel(10) p.HP = p.MaxHP() + p.Battery = p.MaxBattery() return p } @@ -375,3 +384,45 @@ func (p *Player) RemoveItem(itemID string, qty int) bool { } return remaining <= 0 } + +func (p *Player) MaxBattery() float64 { + return float64(p.Level(Technology)) +} + +func (p *Player) HasActiveTech(techID string) bool { + if p.ActiveTechs == nil { + return false + } + return p.ActiveTechs[techID] +} + +func (p *Player) ActivateTech(techID string) { + if p.ActiveTechs == nil { + p.ActiveTechs = make(map[string]bool) + } + if p.TechActivatedSinceTick == nil { + p.TechActivatedSinceTick = make(map[string]bool) + } + p.ActiveTechs[techID] = true + p.TechActivatedSinceTick[techID] = true +} + +func (p *Player) DeactivateTech(techID string) { + if p.ActiveTechs != nil { + delete(p.ActiveTechs, techID) + } +} + +func (p *Player) DeactivateAllTechs() { + p.ActiveTechs = nil + p.TechActivatedSinceTick = nil +} + +func (p *Player) ActiveTechList() []string { + var result []string + for id := range p.ActiveTechs { + result = append(result, id) + } + sort.Strings(result) + return result +} diff --git a/worldbuilding_guide/items.md b/worldbuilding_guide/items.md index 0a4592b..a1ca8c1 100644 --- a/worldbuilding_guide/items.md +++ b/worldbuilding_guide/items.md @@ -9,13 +9,88 @@ description: "A sturdy bronze pickaxe." value: 10 stackable: false equip_slot: main_hand # optional — where it equips -weapon_type: melee # optional — melee or ranged -stats: # optional — combat bonuses - attack_bonus: 2 - strength_bonus: 1 +weapon_type: melee # optional — melee, ranged, or science +attack_type: stab # stab/slash/crush/ranged/science — determines accuracy type +stats: # optional — per-type combat bonuses + stab_attack: 4 + slash_attack: -2 + crush_attack: 2 + strength_bonus: 3 speed: 5 # ticks between attacks tool_type: pickaxe # used by gather behaviors that require "tool: pickaxe" tool_speed: 2 # reduces gather wait time +requirements: # optional — skill levels needed to equip + attack: 1 +``` + +### Equipment Stats (ItemStats) + +Weapons have per-type **attack bonuses** — these determine accuracy based on the weapon's `attack_type`: + +| Field | Description | +|---|---| +| `stab_attack` | Accuracy bonus for stab attacks | +| `slash_attack` | Accuracy bonus for slash attacks | +| `crush_attack` | Accuracy bonus for crush attacks | +| `science_attack` | Accuracy bonus for science attacks | +| `ranged_attack` | Accuracy bonus for ranged attacks | + +Armor has per-type **defense bonuses** — the mob's `attack_type` determines which defense is used: + +| Field | Description | +|---|---| +| `stab_defense` | Defense vs stab attacks | +| `slash_defense` | Defense vs slash attacks | +| `crush_defense` | Defense vs crush attacks | +| `science_defense` | Defense vs science attacks (negative on metal armor) | +| `ranged_defense` | Defense vs ranged attacks | + +Other damage/utility bonuses: + +| Field | Description | +|---|---| +| `strength_bonus` | Melee max hit bonus | +| `ranged_strength` | Ranged max hit bonus (on ammo) | +| `science_damage` | Science max hit bonus | +| `technology_bonus` | Reduces tech battery drain rate | + +### Attack Type + +Weapons should always set `attack_type`. Common mappings: + +| Category | attack_type | +|---|---| +| Swords | slash | +| Daggers | stab | +| Axes | slash | +| Pickaxes | stab | +| Bows | ranged | +| Unarmed | crush (default) | + +### Equipment Requirements + +Higher-tier equipment requires skill levels to equip: + +```yaml +requirements: + attack: 20 # melee weapons + defense: 20 # armor + ranged: 30 # ranged weapons/armor +``` + +Standard tiers: bronze/iron (none), steel (5), black (10), mithril (20), adamant (30), rune (40), dragon (60). + +### Ranged Ammo + +Arrows and bolts use `ranged_strength` for damage. Accuracy comes from the bow: + +```yaml +id: iron_arrow +name: iron arrow +stackable: true +equip_slot: ammo +stats: + ranged_strength: 10 ``` ### Color diff --git a/worldbuilding_guide/mobs.md b/worldbuilding_guide/mobs.md index a025e86..55c41b0 100644 --- a/worldbuilding_guide/mobs.md +++ b/worldbuilding_guide/mobs.md @@ -1,6 +1,6 @@ ## Mobs -Basic combat mob: +Basic combat mob with per-type defense bonuses: ```yaml id: "man" name: "man" @@ -12,6 +12,12 @@ hp: 7 speed: 5 aggressive: false respawn_ticks: 30 +attack_type: crush # stab/slash/crush/ranged/science (default: crush) +stab_defense: 0 # per-type defense bonuses +slash_defense: 0 +crush_defense: 0 +science_defense: 0 +ranged_defense: 0 drops: remains: "bones" # always dropped on death loot: @@ -28,6 +34,46 @@ combat_descriptions: - "is engaged in a fight to the death with %s" ``` +### Aggressive Mobs + +Mobs with `aggressive: true` auto-attack players entering their room (1-tick delay). Only aggros if the player's combat level is at most double the mob's combat level. Checked on room enter and login. + +```yaml +id: goblin +name: goblin +aggressive: true # attacks players on sight +attack: 1 +strength: 3 +defense: 3 +hp: 12 +attack_type: slash +attack_bonus: 3 # equipment-equivalent attack bonus for mob's attack roll +strength_bonus: 2 # equipment-equivalent strength bonus for mob's max hit +stab_defense: 4 +slash_defense: 3 +crush_defense: -2 # weak to crush — negative defense is valid +science_defense: 0 +ranged_defense: 3 +``` + +### Mob Attack/Defense Fields + +| Field | Description | +|---|---| +| `attack` | Attack level (base accuracy) | +| `strength` | Strength level (base max hit) | +| `defense` | Defense level (base defense) | +| `hp` | Hit points | +| `ranged` | Ranged level (for ranged/science mobs) | +| `science` | Science level | +| `attack_bonus` | Equipment-equivalent attack bonus (added to attack roll) | +| `strength_bonus` | Equipment-equivalent strength bonus (added to max hit) | +| `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 | + +### Protected/Unique Mobs + Mob with a behavior — can be talked to, toggled, etc: ```yaml id: "guard" @@ -42,6 +88,14 @@ hp: 30 speed: 5 aggressive: false respawn_ticks: 60 +attack_type: slash +attack_bonus: 8 +strength_bonus: 6 +stab_defense: 12 +slash_defense: 15 +crush_defense: 10 +science_defense: -5 +ranged_defense: 12 idle_descriptions: - "scans the area with a watchful eye" - "adjusts the grip on his weapon" |
