From 5ea082ab4e82409aebc889b496f43e52b003d4f7 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 19 Jun 2026 21:20:32 -0400 Subject: feat: science combat/triggers overhauled. decks now use autotrigger attacks. --- AGENTS.md | 13 +++++- cmd/mud/main.go | 6 +++ data/help/autotrigger.yaml | 25 +++++++---- data/help/mods.yaml | 9 ++-- data/help/science.yaml | 20 ++++++--- data/help/trigger.yaml | 28 ++++++++---- internal/game/cmd_attack.go | 85 ++++++++++++++++++++++++++++++------- internal/game/cmd_autotrigger.go | 5 ++- internal/game/cmd_registry.go | 2 + internal/game/cmd_trigger.go | 16 ++++--- internal/game/cmd_trigger_combat.go | 3 -- internal/game/sys_science.go | 53 +++++++++++++++++++++++ internal/player/player.go | 2 + 13 files changed, 214 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7464f3d..f97c8ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -354,7 +354,18 @@ Battery = Technology level (float64). Each tech has a drain rate (0.05–0.25 pe ## Science System (science.go) -Mods loaded from `data/modules/` — categories: mods, enchant, chip, processing, transport, combat. Junk cost for casting, deck equipment provides junk. Trigger command for casting, autotrigger for automation. Mod list viewable with `mods` command. +Mods loaded from `data/modules/` — categories: combat, utility, transport, enchant, processing. Each mod has a junk cost. Deck equipment (science weapons) provides junk and removes scrap_metal cost. + +**Triggering mods:** + +- `trigger ` — triggers a mod. Combat mods in non-deck combat queue as a one-shot attack. Transport/utility/enchant mods resolve immediately. Module matching searches by partial name (all words); if ambiguous, picks the highest-level module the player can cast (at or below their Science level). +- `autotrigger ` — sets a combat mod to auto-fire each attack round when a deck is equipped. `autotrigger none` disables. Without a deck, autotrigger has no effect. + +**Deck combat:** When a deck weapon is equipped, attacks fire at the weapon's speed. If an autotrigger mod is set and the player has the required junk, the mod fires instead of a normal attack. If no autotrigger or out of junk, an error is shown and the player does an unarmed attack with no bonuses. + +**Non-deck manual triggers:** `trigger ` during combat without a deck queues one module to replace the next combat attack. Only one can be queued at a time. + +Mod list viewable with `mods`, `modules`, or `module` command. ## Hacking System (game/hacking/) diff --git a/cmd/mud/main.go b/cmd/mud/main.go index ef4a560..234e3e0 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -66,6 +66,12 @@ func main() { } g.SetHub(server.Hub()) + junkItems := []string{"solarjunk", "ecojunk", "hydrojunk", "biojunk", "chaosjunk", + "cosmicjunk", "naturejunk", "bloodjunk", "deathjunk", "lawjunk", "scrap_metal"} + for _, j := range junkItems { + g.World.AddGroundItem(1, j, 500) + } + if cfg.Telnet.Enabled { log.Printf("Telnet listening on :%d", cfg.Telnet.Port) } diff --git a/data/help/autotrigger.yaml b/data/help/autotrigger.yaml index 604d42d..08ca57e 100644 --- a/data/help/autotrigger.yaml +++ b/data/help/autotrigger.yaml @@ -1,21 +1,30 @@ name: "autotrigger" category: "Commands" description: | - Set a mod to autotrigger during combat. + Set a mod to autotrigger during deck combat. Usage: autotrigger + autotrigger none autotrigger off autotrigger - Set a combat mod to automatically trigger each attack tick - during combat. When autotrigger is active, attacking a mob will - use science combat instead of melee, consuming junk each tick. + When a deck (science weapon) is equipped, set a combat mod to + automatically trigger each attack round. The mod fires at your + deck's weapon speed, consuming junk each round. - If you run out of junk, autotrigger disables and you switch to - melee attacks. + If you run out of junk, you flail with your fists in desperation + (unarmed attack with no bonuses). If no autotrigger is set while + a deck is equipped, you get the same result. + + Without a deck equipped, the autotrigger setting has no effect — + use 'trigger ' in combat to queue a one-shot mod instead. + + Module matching searches by partial name (all words). If ambiguous, + the highest-level module you can cast is selected. autotrigger - Show current autotrigger setting - autotrigger solar bolt - Set autotrigger to Solar Bolt - autotrigger off - Disable autotrigger + autotrigger solar - Set autotrigger to highest castable + solar mod (e.g. Solar Strike at Lv1) + autotrigger none - Disable autotrigger See also: trigger, mods, science diff --git a/data/help/mods.yaml b/data/help/mods.yaml index 42873da..d545b54 100644 --- a/data/help/mods.yaml +++ b/data/help/mods.yaml @@ -4,11 +4,12 @@ description: | Display available science modules. Usage: mods [all] - modlist [all] + modules [all] + module [all] + mod [all] Display science modules you have the level to use, organized - by category (Combat, Processing, Utility, Transport, - Enchantment). + by category (Combat, Utility, Transport, Enchantment). Use 'mods all' to see all modules, including ones above your current Science level (locked modules are dimmed). @@ -16,4 +17,6 @@ description: | Shows the junk cost for each module (adjusted for your equipped deck). Also shows your current autotrigger setting. + Aliases: mods, mod, module, modules + See also: trigger, autotrigger, science diff --git a/data/help/science.yaml b/data/help/science.yaml index e34aa6e..57588b3 100644 --- a/data/help/science.yaml +++ b/data/help/science.yaml @@ -11,9 +11,12 @@ description: | - All modules also cost 1 scrap metal, UNLESS you have a deck equipped - Wielding a deck (science weapon) removes the scrap - requirement - - Elemental decks also provide unlimited supply of their junk - type + requirement and enables autotrigger combat + - Elemental decks provide unlimited supply of their junk type + + Module matching: trigger and autotrigger search by partial name + (all words). If ambiguous, the highest-level module you can cast + is selected. E.g., "trigger bio" at Science Lv1 picks Bio Strike. Junk types: solarjunk, hydrojunk, ecojunk, biojunk (from scavenging) @@ -24,8 +27,15 @@ description: | Decks: basic deck, solar deck, hydro deck, eco deck, bio deck Advanced versions of each elemental deck also exist. - Combat: Use 'trigger ' or set 'autotrigger ' - then 'attack ' + Deck combat: + - Equip a deck, set 'autotrigger ', then attack + - Deck attacks at its weapon speed + - Autotrigger mod fires each round, consuming junk + - Without autotrigger or junk: unarmed attack with no bonuses + + Non-deck combat: + - 'trigger ' queues a one-shot mod for your next attack + - Only one mod can be queued at a time Type 'mods' to see all modules you can currently use. diff --git a/data/help/trigger.yaml b/data/help/trigger.yaml index 176108e..9211c08 100644 --- a/data/help/trigger.yaml +++ b/data/help/trigger.yaml @@ -5,17 +5,27 @@ description: | Usage: trigger [target] - Trigger a science mod. Combat mods target a mob and initiate - science-based combat. Utility mods act on items in your - inventory or on yourself. + Trigger a science mod. Module matching searches by partial name + (all words); if ambiguous, the highest-level module you can cast + is selected. + + Combat mods behave differently depending on context: + - With a deck equipped: sets autotrigger and starts combat + (mod fires every round at deck speed) + - Without a deck: queues the mod as a one-shot for your next + attack, then combat continues as melee + - Already in combat without a deck: queues the mod for your + next attack round (only one can be queued at a time) + + Utility/transport/enchant mods resolve immediately. Examples: - trigger bio strike goblin - Attack with Bio Strike - trigger low process iron ore - Convert iron ore to credits - trigger transport town - Teleport to Town Square - trigger enchant 1 sapphire ring - Enchant a sapphire ring - trigger superheat copper ore - Smelt copper without furnace - trigger em grab bones - Pick up bones from the ground + trigger bio goblin - Attack with highest castable + bio mod (Bio Strike at Lv1) + trigger strike goblin - Pick highest castable Strike mod + trigger low process iron - Convert iron ore to credits + trigger transport town - Teleport to Town Square + trigger enchant 1 sapphire - Enchant a sapphire ring All mods cost junk (and 1 scrap metal unless you have a deck equipped). Type 'mods' to see your available mods and costs. diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 222e3c3..f9cfba2 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -129,12 +129,9 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn g.cancelBackgroundAction(p) combat.EnterCombat(p.Name, mob.InstanceID) + p.AttackTimer = 0 autotriggerActive := p.AutotriggerMod != "" - playerSpeed := g.playerWeaponSpeed(p) - if autotriggerActive { - playerSpeed = 5.0 - } if !autotriggerActive { attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) @@ -173,7 +170,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn } } - g.Ticks.Subscribe(engine.ToTicks(playerSpeed), func() bool { + g.Ticks.Subscribe(1, func() bool { cs := combat.GetCombat(p.Name) if cs == nil || !cs.Active { return false @@ -183,21 +180,46 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn g.endCombat(sess, p, currentMob) return false } - if p.AutotriggerMod != "" { - mod := GetMod(p.AutotriggerMod) - if mod != nil { - if !g.scienceAttack(sess, p, currentMob, mod) { - p.AutotriggerMod = "" - sess.WriteLine("You've run out of junk. Switching to melee.") - g.playerAttack(sess, p, currentMob) + + p.AttackTimer++ + if float64(p.AttackTimer) >= g.playerWeaponSpeed(p) { + p.AttackTimer = 0 + + if g.hasDeckEquipped(p) { + if p.AutotriggerMod != "" { + mod := GetMod(p.AutotriggerMod) + if mod != nil && g.hasJunkCost(p, mod) { + g.scienceAttack(sess, p, currentMob, mod) + } else if mod != nil { + sess.WriteLine(g.colorize(sess, "error", + fmt.Sprintf("\nYou're out of junk for %s!! You flail with your fists in desperation!", mod.Name))) + g.playerAttackUnarmed(sess, p, currentMob) + } else { + p.AutotriggerMod = "" + sess.WriteLine(g.colorize(sess, "error", + "\nNo autotrigger set! Set a module with the 'autotrigger' command.")) + g.playerAttackUnarmed(sess, p, currentMob) + } + } else { + sess.WriteLine(g.colorize(sess, "error", + "\nNo autotrigger set! Set a module with the 'autotrigger' command.")) + g.playerAttackUnarmed(sess, p, currentMob) } } else { - p.AutotriggerMod = "" - g.playerAttack(sess, p, currentMob) + if p.QueuedTrigger != "" { + mod := GetMod(p.QueuedTrigger) + p.QueuedTrigger = "" + if mod != nil && g.hasJunkCost(p, mod) { + g.scienceAttack(sess, p, currentMob, mod) + } else { + g.playerAttack(sess, p, currentMob) + } + } else { + g.playerAttack(sess, p, currentMob) + } } - } else { - g.playerAttack(sess, p, currentMob) } + if currentMob.HP <= 0 { g.endCombat(sess, p, currentMob) return false @@ -284,6 +306,37 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc return } +func (g *Game) playerAttackUnarmed(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if g.processConsumeQueue(p, sess) { + p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"} + return + } + + attackType, _, attRoll, defRoll, maxHit := g.calculateUnarmedAttackRoll(p, mob) + + if combat.HitCheck(attRoll, defRoll) { + g.applyPlayerHit(sess, p, mob, attackType, object.WeaponMelee, maxHit) + } else { + g.applyPlayerMiss(sess, p, mob, object.WeaponMelee) + } +} + +func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType object.WeaponType, attRoll int, defRoll int, maxHit int) { + attackType = "crush" + weaponType = object.WeaponMelee + + 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) + + 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)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0) + return +} + func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, weaponType object.WeaponType, maxHit int) { dmg := combat.RollDamage(maxHit) diff --git a/internal/game/cmd_autotrigger.go b/internal/game/cmd_autotrigger.go index e2ecd1a..acfa76e 100644 --- a/internal/game/cmd_autotrigger.go +++ b/internal/game/cmd_autotrigger.go @@ -27,13 +27,14 @@ func (g *Game) doAutotrigger(sess *net.Session, input string) { return } - if strings.ToLower(input) == "off" { + lower := strings.ToLower(input) + if lower == "off" || lower == "none" { p.AutotriggerMod = "" sess.WriteLine("Autotrigger disabled.") return } - mod := FindMod(strings.ToLower(input)) + mod := FindModForPlayer(lower, p.Level(player.Science)) if mod == nil { sess.WriteLine("Unknown mod.") return diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 0439cc2..c4f7700 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -83,6 +83,8 @@ var commandRegistry = map[string]commandDef{ "mods": {(*Game).executeMods, ClassInstant}, "modlist": {(*Game).executeMods, ClassInstant}, "mod": {(*Game).executeMods, ClassInstant}, + "modules": {(*Game).executeMods, ClassInstant}, + "module": {(*Game).executeMods, ClassInstant}, "sneak": {(*Game).executeSneak, ClassInstant}, "trigger": {(*Game).executeTrigger, ClassActive}, "talk": {(*Game).executeTalk, ClassActive}, diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index 7d637ca..0acbeff 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -22,7 +22,7 @@ func (g *Game) doTrigger(sess *net.Session, input string) { return } - mod, targetArg := g.parseTriggerArgs(input) + mod, targetArg := g.parseTriggerArgs(input, p.Level(player.Science)) if mod == nil { sess.WriteLine("Unknown mod. Type 'mods' to see available mods.") return @@ -50,12 +50,12 @@ func (g *Game) doTrigger(sess *net.Session, input string) { } } -func (g *Game) parseTriggerArgs(input string) (*ModDef, string) { +func (g *Game) parseTriggerArgs(input string, sciLevel int) (*ModDef, string) { lower := strings.ToLower(input) words := strings.Fields(lower) for i := len(words); i > 0; i-- { candidate := strings.Join(words[:i], " ") - mod := FindMod(candidate) + mod := FindModForPlayer(candidate, sciLevel) if mod != nil { target := strings.TrimSpace(strings.Join(words[i:], " ")) return mod, target @@ -66,8 +66,8 @@ func (g *Game) parseTriggerArgs(input string) (*ModDef, string) { func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { if cs := combat.GetCombat(p.Name); cs != nil { - p.AutotriggerMod = mod.ID - sess.WriteLine(fmt.Sprintf("You switch to triggering %s.", mod.Name)) + p.QueuedTrigger = mod.ID + sess.WriteLine(fmt.Sprintf("You queue %s for your next attack.", mod.Name)) return } @@ -106,6 +106,10 @@ func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef return } - p.AutotriggerMod = mod.ID + if g.hasDeckEquipped(p) { + p.AutotriggerMod = mod.ID + } else { + p.QueuedTrigger = mod.ID + } g.startCombat(sess, p, mob) } diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go index 545a117..62c5083 100644 --- a/internal/game/cmd_trigger_combat.go +++ b/internal/game/cmd_trigger_combat.go @@ -15,9 +15,6 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name)) return false } - if !g.hasJunkCost(p, mod) { - return false - } if g.processConsumeQueue(p, sess) { p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"} diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go index 23aeef4..e35105a 100644 --- a/internal/game/sys_science.go +++ b/internal/game/sys_science.go @@ -81,6 +81,59 @@ func FindMod(input string) *ModDef { return nil } +func findAllModMatches(input string) []*ModDef { + inputWords := strings.Fields(strings.ToLower(input)) + if len(inputWords) == 0 { + return nil + } + var matches []*ModDef + for _, m := range AllMods { + lowerName := strings.ToLower(m.Name) + lowerID := strings.ToLower(m.ID) + allMatch := true + for _, word := range inputWords { + if !strings.Contains(lowerName, word) && !strings.Contains(lowerID, word) { + allMatch = false + break + } + } + if allMatch { + matches = append(matches, m) + } + } + return matches +} + +func FindModForPlayer(input string, sciLevel int) *ModDef { + if m, ok := modByID[input]; ok { + return m + } + lower := strings.ToLower(strings.ReplaceAll(input, " ", "_")) + for _, m := range AllMods { + if strings.HasPrefix(m.ID, lower) { + return m + } + } + + matches := findAllModMatches(input) + if len(matches) == 0 { + return nil + } + if len(matches) == 1 { + return matches[0] + } + + sort.Slice(matches, func(i, j int) bool { + return matches[i].Level > matches[j].Level + }) + for _, m := range matches { + if m.Level <= sciLevel { + return m + } + } + return matches[len(matches)-1] +} + type chipEntry struct { Input string Output string diff --git a/internal/player/player.go b/internal/player/player.go index 3842348..1ddb3ea 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -180,6 +180,8 @@ type Player struct { MoveTarget int `yaml:"-"` VisualTickCurrent int `yaml:"-"` AutotriggerMod string `yaml:"-"` + QueuedTrigger string `yaml:"-"` + AttackTimer int `yaml:"-"` Battery float64 `yaml:"battery"` ActiveTechs map[string]bool `yaml:"-"` QuickTech string `yaml:"quick_tech,omitempty"` -- cgit v1.2.3