aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-19 21:20:32 -0400
committerhistoria <[not public]>2026-06-19 21:20:32 -0400
commit5ea082ab4e82409aebc889b496f43e52b003d4f7 (patch)
tree942f2c4a18ecbee42ba25726048d09827ea68894
parent8ee8982b8759bcae116647cc993867f4c8b0a6ce (diff)
downloadthehouseoficarus-5ea082ab4e82409aebc889b496f43e52b003d4f7.tar.gz
feat: science combat/triggers overhauled. decks now use autotrigger attacks.
-rw-r--r--AGENTS.md13
-rw-r--r--cmd/mud/main.go6
-rw-r--r--data/help/autotrigger.yaml25
-rw-r--r--data/help/mods.yaml9
-rw-r--r--data/help/science.yaml20
-rw-r--r--data/help/trigger.yaml28
-rw-r--r--internal/game/cmd_attack.go85
-rw-r--r--internal/game/cmd_autotrigger.go5
-rw-r--r--internal/game/cmd_registry.go2
-rw-r--r--internal/game/cmd_trigger.go16
-rw-r--r--internal/game/cmd_trigger_combat.go3
-rw-r--r--internal/game/sys_science.go53
-rw-r--r--internal/player/player.go2
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 <mod>` — 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 <mod>` — 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 <mod>` 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 <mod>
+ 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 <mod>' 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 <mod> <mob>' or set 'autotrigger <mod>'
- then 'attack <mob>'
+ Deck combat:
+ - Equip a deck, set 'autotrigger <mod>', 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 <mod>' 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 <mod> [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"`