aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/action/behavior.go1
-rw-r--r--internal/game/action.go7
-rw-r--r--internal/game/action_agility.go180
-rw-r--r--internal/game/action_burn.go16
-rw-r--r--internal/game/action_fletch.go8
-rw-r--r--internal/game/action_gather.go8
-rw-r--r--internal/game/action_production.go23
-rw-r--r--internal/game/action_search.go8
-rw-r--r--internal/game/action_state.go8
-rw-r--r--internal/game/action_steal.go8
-rw-r--r--internal/game/action_talk.go76
-rw-r--r--internal/game/action_use.go8
-rw-r--r--internal/game/buff.go55
-rw-r--r--internal/game/cmd_agility.go37
-rw-r--r--internal/game/cmd_attack.go16
-rw-r--r--internal/game/cmd_bank.go345
-rw-r--r--internal/game/cmd_clean.go9
-rw-r--r--internal/game/cmd_construct.go157
-rw-r--r--internal/game/cmd_drink.go150
-rw-r--r--internal/game/cmd_eat.go16
-rw-r--r--internal/game/cmd_estate_directory.go223
-rw-r--r--internal/game/cmd_jack.go83
-rw-r--r--internal/game/cmd_look.go6
-rw-r--r--internal/game/cmd_quit.go10
-rw-r--r--internal/game/cmd_score.go13
-rw-r--r--internal/game/cmd_trigger.go126
-rw-r--r--internal/game/cmd_use.go5
-rw-r--r--internal/game/course.go141
-rw-r--r--internal/game/game.go43
-rw-r--r--internal/game/hacking.go157
-rw-r--r--internal/game/hacking_liars_dice.go395
-rw-r--r--internal/game/hacking_mastermind.go177
-rw-r--r--internal/game/hacking_wumpus.go286
-rw-r--r--internal/game/science.go3
-rw-r--r--internal/net/server.go2
-rw-r--r--internal/object/item.go3
-rw-r--r--internal/player/player.go57
-rw-r--r--internal/world/world.go6
38 files changed, 2854 insertions, 18 deletions
diff --git a/internal/action/behavior.go b/internal/action/behavior.go
index 236ab81..e1a79e3 100644
--- a/internal/action/behavior.go
+++ b/internal/action/behavior.go
@@ -55,6 +55,7 @@ type NodeAction struct {
SkipTask bool `yaml:"skip_task"`
ExtendTask bool `yaml:"extend_task"`
ReputationCost int `yaml:"reputation_cost"`
+ Sawmill bool `yaml:"sawmill"`
}
type ShopConfig struct {
diff --git a/internal/game/action.go b/internal/game/action.go
index be35ae2..dc21927 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -153,6 +153,11 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) {
return
}
+ if obj != nil && obj.ID == "estate_directory" {
+ g.startEstateDirectory(sess, verb)
+ return
+ }
+
if bh.Type != verb {
if obj != nil {
sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name))
@@ -251,6 +256,8 @@ func (g *Game) AdvanceActions() {
g.advanceWater(sess, p)
case "cure":
g.advanceCure(sess, p)
+ case "obstacle":
+ g.advanceObstacle(sess, p)
default:
if productionActionTypes[p.Action.Type] {
g.advanceProduction(sess, p)
diff --git a/internal/game/action_agility.go b/internal/game/action_agility.go
new file mode 100644
index 0000000..089f026
--- /dev/null
+++ b/internal/game/action_agility.go
@@ -0,0 +1,180 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) {
+ failChance := g.calcFailChance(p, info)
+
+ msgs := make([]any, len(info.Messages))
+ for i, m := range info.Messages {
+ msgs[i] = m
+ }
+
+ gerund := info.Verb + "ing"
+ if g, ok := verbGerund[info.Verb]; ok {
+ gerund = g
+ }
+
+ p.Action = &action.Action{
+ Type: "obstacle",
+ TargetID: info.CourseID,
+ TargetName: info.CourseName,
+ WaitLeft: 0,
+ Data: map[string]any{
+ "course_id": info.CourseID,
+ "phase": 0,
+ "obstacle_index": info.ObstacleIndex,
+ "total_obstacles": info.TotalObstacles,
+ "next_room": info.NextRoom,
+ "start_room": info.StartRoom,
+ "obstacle_xp": info.ObstacleXP,
+ "completion_xp": info.CompletionXP,
+ "fail_chance": failChance,
+ "fail_damage_min": info.FailDamage[0],
+ "fail_damage_max": info.FailDamage[1],
+ "messages": msgs,
+ "ticks_per_phase": info.TicksPerPhase,
+ "required_level": info.RequiredLevel,
+ },
+ }
+
+ p.ActionState = &ActionState{
+ Type: ActionTraversing,
+ Verb: gerund,
+ TargetName: info.CourseName,
+ }
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s on the %s.", p.Name, gerund, info.CourseName)))
+ }
+ }
+ }
+}
+
+func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
+ data := p.Action.Data
+ phase := data["phase"].(int)
+ messages := data["messages"].([]any)
+ ticksPerPhase := data["ticks_per_phase"].(float64)
+
+ if phase >= len(messages) {
+ g.CancelAction(p)
+ return
+ }
+
+ msg := messages[phase].(string)
+ sess.WriteLine(g.colorize(sess, "broadcast", msg))
+
+ if phase == 1 {
+ failChance := data["fail_chance"].(float64)
+ if rand.Float64() < failChance {
+ g.obstacleFail(sess, p, data)
+ return
+ }
+ }
+
+ nextPhase := phase + 1
+
+ if nextPhase >= len(messages) {
+ obstacleXP := data["obstacle_xp"].(int)
+ completionXP := data["completion_xp"].(int)
+ nextRoom := data["next_room"].(int)
+ startRoom := data["start_room"].(int)
+ obstacleIndex := data["obstacle_index"].(int)
+ totalObstacles := data["total_obstacles"].(int)
+ courseID := data["course_id"].(string)
+
+ if obstacleXP > 0 {
+ if newLevel := p.AddSkillXP(player.Agility, obstacleXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up",
+ fmt.Sprintf("*** You are now level %d agility! ***", newLevel)))
+ }
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp",
+ fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility])))
+ }
+ }
+
+ isLastObstacle := obstacleIndex == totalObstacles-1
+
+ if isLastObstacle {
+ if completionXP > 0 {
+ if newLevel := p.AddSkillXP(player.Agility, completionXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up",
+ fmt.Sprintf("*** You are now level %d agility! ***", newLevel)))
+ }
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp",
+ fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility])))
+ }
+ }
+
+ lapKey := "agility_laps_" + courseID
+ laps := getPlayerFlagInt(p, lapKey) + 1
+ setPlayerFlag(p, lapKey, laps)
+
+ courseName := p.Action.TargetName
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps)))
+
+ g.CancelAction(p)
+ g.teleportPlayer(sess, p, startRoom)
+ } else {
+ g.CancelAction(p)
+ g.teleportPlayer(sess, p, nextRoom)
+ }
+
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+
+ data["phase"] = nextPhase
+ p.Action.WaitLeft = engine.ToTicks(ticksPerPhase)
+}
+
+func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string]any) {
+ startRoom := data["start_room"].(int)
+ failMin := data["fail_damage_min"].(int)
+ failMax := data["fail_damage_max"].(int)
+
+ damage := failMin
+ if failMax > failMin {
+ damage = failMin + rand.Intn(failMax-failMin+1)
+ }
+
+ sess.WriteLine(g.colorize(sess, "damage", "You slip and fall!"))
+
+ p.HP -= damage
+ if p.HP < 1 {
+ p.HP = 1
+ }
+ sess.WriteLine(g.colorize(sess, "damage",
+ fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP())))
+
+ g.AccountStore.SaveCharacter(p)
+ g.CancelAction(p)
+ g.teleportPlayer(sess, p, startRoom)
+}
+
+func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 {
+ level := p.Level(player.Agility)
+ required := info.RequiredLevel
+ chance := 0.30 - float64(level-required)*0.01
+ if chance < 0.05 {
+ chance = 0.05
+ }
+ if chance > 0.60 {
+ chance = 0.60
+ }
+ return chance
+}
diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go
index 0b7db2c..0230f24 100644
--- a/internal/game/action_burn.go
+++ b/internal/game/action_burn.go
@@ -107,6 +107,14 @@ func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fro
p.ActionState = &ActionState{Type: ActionBurning}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts building a fire.", p.Name)))
+ }
+ }
+ }
+
p.Action = &action.Action{
Type: "burn",
TargetID: itemID,
@@ -145,6 +153,14 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) {
p.ActionState = &ActionState{Type: ActionStoking}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s tends to the fire.", p.Name)))
+ }
+ }
+ }
+
p.Action = &action.Action{
Type: "stoke",
TargetID: itemID,
diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go
index 25f2966..ddb4d2c 100644
--- a/internal/game/action_fletch.go
+++ b/internal/game/action_fletch.go
@@ -119,6 +119,14 @@ func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, recipe *act
}
p.BackgroundActionState = &ActionState{Type: ActionFletching, TargetName: outputName}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts fletching.", p.Name)))
+ }
+ }
+ }
+
sess.WriteLine(fmt.Sprintf("\nYou begin fletching %s.", outputName))
}
diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go
index 8b45129..edc8545 100644
--- a/internal/game/action_gather.go
+++ b/internal/game/action_gather.go
@@ -147,6 +147,14 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
}
p.ActionState = &ActionState{Type: ActionGathering, TargetName: obj.Name, ToolName: toolName, Verb: verb}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s the %s.", p.Name, verb, obj.Name)))
+ }
+ }
+ }
+
data := map[string]any{
"behavior_id": obj.BehaviorID,
"instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
diff --git a/internal/game/action_production.go b/internal/game/action_production.go
index c44f3d6..788cadb 100644
--- a/internal/game/action_production.go
+++ b/internal/game/action_production.go
@@ -27,13 +27,14 @@ type productionTypeInfo struct {
}
var productionTypes = map[string]productionTypeInfo{
- "cooking": {"cook", "cooking"},
- "smelting": {"smelt", "smelting"},
- "smithing": {"smith", "smithing"},
- "crafting": {"craft", "crafting"},
- "combine": {"combine", "combining"},
- "fletching": {"fletch", "fletching"},
- "pharmacy": {"mix", "mixing"},
+ "cooking": {"cook", "cooking"},
+ "smelting": {"smelt", "smelting"},
+ "smithing": {"smith", "smithing"},
+ "crafting": {"craft", "crafting"},
+ "combine": {"combine", "combining"},
+ "fletching": {"fletch", "fletching"},
+ "pharmacy": {"mix", "mixing"},
+ "construction": {"construct", "constructing"},
}
var productionActionTypes map[string]bool
@@ -253,6 +254,14 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *acti
}
p.ActionState = &ActionState{Type: ActionProducing, TargetName: outputName, Verb: displayVerb}
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s.", p.Name, displayVerb)))
+ }
+ }
+ }
}
func (g *Game) startProductionFromRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) {
diff --git a/internal/game/action_search.go b/internal/game/action_search.go
index d45066d..884157e 100644
--- a/internal/game/action_search.go
+++ b/internal/game/action_search.go
@@ -34,6 +34,14 @@ func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, s
}
p.ActionState = &ActionState{Type: ActionSearching, TargetName: def.Name}
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s searches a %s.", p.Name, def.Name)))
+ }
+ }
+ }
}
func (g *Game) advanceSearch(sess *net.Session, p *player.Player) {
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index 61288c9..55d3392 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -31,7 +31,9 @@ const (
ActionHarvesting ActionType = "harvesting_crop"
ActionRaking ActionType = "raking"
ActionWatering ActionType = "watering"
- ActionCuring ActionType = "curing"
+ ActionCuring ActionType = "curing"
+ ActionTraversing ActionType = "traversing"
+ ActionHacking ActionType = "hacking"
)
type ActionState struct {
@@ -103,6 +105,10 @@ func (a *ActionState) Description() string {
return "watering a " + a.TargetName
case ActionCuring:
return "curing a " + a.TargetName
+ case ActionTraversing:
+ return a.Verb + " across " + a.TargetName
+ case ActionHacking:
+ return "jacked into a " + a.TargetName
}
return ""
}
diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go
index dac90f2..5347c2a 100644
--- a/internal/game/action_steal.go
+++ b/internal/game/action_steal.go
@@ -221,6 +221,14 @@ func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string
sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName))
p.ActionState = &ActionState{Type: ActionStealing, TargetName: targetName}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s glances around the room.", p.Name)))
+ }
+ }
+ }
+
p.Action = &action.Action{
Type: "steal",
TargetID: targetID,
diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go
index 1a21e7f..c18e254 100644
--- a/internal/game/action_talk.go
+++ b/internal/game/action_talk.go
@@ -106,6 +106,13 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
return
}
+ if p.Action.Data != nil {
+ if bid, ok := p.Action.Data["behavior_id"].(string); ok && bid == "estate_directory_talk" {
+ g.handleEstateDirectoryTalk(sess, input)
+ return
+ }
+ }
+
input = strings.TrimSpace(input)
lower := strings.ToLower(input)
if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" {
@@ -263,4 +270,73 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) {
g.AccountStore.SaveCharacter(p)
}
}
+ if na.Sawmill {
+ g.processSawmill(sess, p)
+ }
+}
+
+func (g *Game) processSawmill(sess *net.Session, p *player.Player) {
+ logTypes := map[string]struct {
+ plankID string
+ cost int
+ name string
+ }{
+ "logs": {"planks", 5, "regular planks"},
+ "oak_logs": {"oak_planks", 10, "oak planks"},
+ "teak_logs": {"teak_planks", 20, "teak planks"},
+ "mahogany_logs": {"mahogany_planks", 40, "mahogany planks"},
+ }
+
+ totalCost := 0
+ totalPlanks := 0
+ type plankResult struct {
+ plankID string
+ qty int
+ }
+
+ var results []plankResult
+
+ for logID, info := range logTypes {
+ qty := p.CountItem(logID)
+ if qty == 0 {
+ continue
+ }
+ cost := qty * info.cost
+ totalCost += cost
+ totalPlanks += qty
+ results = append(results, plankResult{info.plankID, qty})
+ p.RemoveItem(logID, qty)
+ }
+
+ if totalPlanks == 0 {
+ sess.WriteLine("You don't have any logs to process.")
+ return
+ }
+
+ if p.Credits < totalCost {
+ sess.WriteLine(fmt.Sprintf("You need %d credits to process all your logs (you have %d).", totalCost, p.Credits))
+ return
+ }
+
+ p.Credits -= totalCost
+
+ processed := 0
+ for _, r := range results {
+ for i := 0; i < r.qty; i++ {
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ g.World.AddGroundItem(p.RoomID, r.plankID, r.qty-i)
+ break
+ }
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: r.plankID, Quantity: 1})
+ processed++
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+
+ sess.WriteLine(fmt.Sprintf("The sawmill operator processes your logs into %d planks for %d credits.", processed, totalCost))
+ if processed < totalPlanks {
+ sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", totalPlanks-processed))
+ }
}
diff --git a/internal/game/action_use.go b/internal/game/action_use.go
index b71ec17..78bf028 100644
--- a/internal/game/action_use.go
+++ b/internal/game/action_use.go
@@ -36,6 +36,14 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD
p.ActionState = &ActionState{Type: ActionUsing, TargetName: obj.Name}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name)))
+ }
+ }
+ }
+
p.Action = &action.Action{
Type: "use",
TargetID: obj.ID,
diff --git a/internal/game/buff.go b/internal/game/buff.go
new file mode 100644
index 0000000..39abd8a
--- /dev/null
+++ b/internal/game/buff.go
@@ -0,0 +1,55 @@
+package game
+
+import (
+ "strings"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) BuffTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ if sess.Player == nil {
+ continue
+ }
+ p := sess.Player.(*player.Player)
+ remaining := make([]player.PotionBuff, 0, len(p.ActiveBuffs))
+ dirty := false
+ for _, buff := range p.ActiveBuffs {
+ buff.TicksLeft--
+ if buff.TicksLeft > 0 {
+ remaining = append(remaining, buff)
+ } else {
+ dirty = true
+ }
+ }
+ if dirty {
+ p.ActiveBuffs = remaining
+ sess.WriteLine(g.colorize(sess, "broadcast", "\nYour potion buff has worn off."))
+ }
+ }
+}
+
+func (g *Game) buffLevelBonus(p *player.Player, stat string) int {
+ if g.Hub == nil {
+ return 0
+ }
+ totalPercent := 0
+ for _, buff := range p.ActiveBuffs {
+ if strings.EqualFold(buff.Stat, stat) {
+ totalPercent += buff.BonusPercent
+ }
+ }
+ if totalPercent == 0 {
+ return 0
+ }
+ baseLevel := p.Level(player.SkillName(strings.ToLower(stat)))
+ return baseLevel * totalPercent / 100
+}
+
+func (g *Game) totalBuffedLevel(sess *net.Session, p *player.Player, stat string) int {
+ return p.Level(player.SkillName(strings.ToLower(stat))) + g.buffLevelBonus(p, stat)
+}
diff --git a/internal/game/cmd_agility.go b/internal/game/cmd_agility.go
new file mode 100644
index 0000000..3bd777f
--- /dev/null
+++ b/internal/game/cmd_agility.go
@@ -0,0 +1,37 @@
+package game
+
+import (
+ "fmt"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doObstacle(sess *net.Session, verb string) {
+ p := sess.Player.(*player.Player)
+
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ info := g.CourseStore.GetObstacle(p.RoomID)
+ if info == nil || info.Verb != verb {
+ sess.WriteLine("You can't do that here.")
+ return
+ }
+
+ agilityLevel := p.Level(player.Agility)
+ if agilityLevel < info.RequiredLevel {
+ sess.WriteLine(fmt.Sprintf("You need level %d agility to attempt this course.", info.RequiredLevel))
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+ g.CancelBackgroundAction(p)
+
+ g.startObstacle(sess, p, info)
+}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index a8dc5d3..77cd569 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -164,6 +164,14 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
}
p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s attacks %s!", p.Name, mobDisplayName(mob, true))))
+ }
+ }
+ }
+
g.Ticks.Subscribe(engine.ToTicks(playerSpeed), func() bool {
cs := combat.GetCombat(p.Name)
if cs == nil || !cs.Active {
@@ -243,7 +251,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
if isRanged {
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
equipAttack := totals.RangedAttack
- effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged")
+ effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged")
attRoll = combat.AttackRoll(effectiveRanged, rangedBonus, equipAttack)
mobDefBonus := combat.SelectDefenseBonus(attackType,
@@ -256,14 +264,14 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
equipAttack := combat.SelectAttackBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack")
+ effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(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)+g.techLevelBonus(p, "strength"), strBonus, totals.StrengthBonus)
+ maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus)
}
if combat.HitCheck(attRoll, defRoll) {
@@ -362,7 +370,7 @@ 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)+g.techLevelBonus(p, "defense"), defStyleBonus, equipDef)
+ defRoll := combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef)
if combat.HitCheck(attRoll, defRoll) {
maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus)
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
new file mode 100644
index 0000000..ff3a3ac
--- /dev/null
+++ b/internal/game/cmd_bank.go
@@ -0,0 +1,345 @@
+package game
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) handleBankInput(sess *net.Session, input string) {
+ if sess.Player == nil {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ return
+ }
+
+ input = strings.TrimSpace(input)
+ parts := strings.Fields(strings.ToLower(input))
+ if len(parts) == 0 {
+ g.showBankBrowse(sess)
+ return
+ }
+
+ cmd := parts[0]
+
+ switch cmd {
+ case "deposit":
+ if len(parts) < 2 {
+ sess.WriteLine("Deposit what?")
+ } else if parts[1] == "all" {
+ g.doBankDepositAll(sess)
+ } else {
+ g.doBankDeposit(sess, strings.Join(parts[1:], " "))
+ }
+ case "withdraw":
+ if len(parts) < 2 {
+ sess.WriteLine("Withdraw what?")
+ } else {
+ g.doBankWithdraw(sess, strings.Join(parts[1:], " "))
+ }
+ case "browse", "list":
+ g.showBankBrowse(sess)
+ case "leave", "bye", "exit", "quit":
+ g.leaveBank(sess)
+ return
+ default:
+ sess.WriteLine("Commands: deposit <item>, deposit all, withdraw <item>, browse, leave")
+ }
+ g.writeBankPrompt(sess)
+}
+
+func (g *Game) writeBankPrompt(sess *net.Session) {
+ sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "Bank> ")))
+}
+
+func (g *Game) showBankBrowse(sess *net.Session) {
+ p, _ := sess.Player.(*player.Player)
+
+ unicode := p.OptionBool("unicode")
+
+ t := Table{
+ Title: "Bank Contents",
+ Columns: []string{"#", "Item", "Quantity"},
+ }
+
+ type bankEntry struct {
+ slot int
+ name string
+ id string
+ qty int
+ }
+
+ var entries []bankEntry
+ for _, i := range p.BankSlots() {
+ slot := p.BankSlot(i)
+ if slot == nil {
+ continue
+ }
+ def, _ := g.ItemStore.Load(slot.ItemID)
+ itemName := slot.ItemID
+ if def != nil {
+ itemName = def.Name
+ }
+ entries = append(entries, bankEntry{slot: i, name: itemName, id: slot.ItemID, qty: slot.Quantity})
+ }
+
+ if len(entries) == 0 {
+ sess.WriteLine("\nYour bank is empty.")
+ return
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].slot < entries[j].slot
+ })
+
+ for _, e := range entries {
+ t.Rows = append(t.Rows, []string{
+ fmt.Sprintf("%d", e.slot+1),
+ g.colorize(sess, "item", e.name),
+ fmt.Sprintf("%d", e.qty),
+ })
+ }
+
+ for _, line := range t.Render(unicode) {
+ sess.WriteLine(line)
+ }
+}
+
+func (g *Game) doBankDeposit(sess *net.Session, input string) {
+ p, _ := sess.Player.(*player.Player)
+
+ matches := g.findInventoryMatches(input, p)
+ if len(matches) == 0 {
+ sess.WriteLine("You don't have that item.")
+ return
+ }
+
+ if len(matches) > 1 {
+ g.showWhichOne(sess, matches)
+ return
+ }
+
+ match := matches[0]
+
+ slot := p.InvSlot(match.Slot)
+ if slot == nil {
+ return
+ }
+
+ itemID := slot.ItemID
+ qty := slot.Quantity
+ def, _ := g.ItemStore.Load(itemID)
+
+ p.SetInvSlot(match.Slot, nil)
+
+ bankIdx := p.NextBankSlot()
+ p.SetBankSlot(bankIdx, &player.InventorySlot{
+ ItemID: itemID,
+ Quantity: qty,
+ Quality: slot.Quality,
+ })
+
+ g.AccountStore.SaveCharacter(p)
+
+ displayName := itemID
+ if def != nil {
+ displayName = def.Name
+ }
+ itemColor := g.itemColorize(sess, def, displayName)
+ if qty > 1 {
+ sess.WriteLine(fmt.Sprintf("You deposit %d %ss.", qty, itemColor))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You deposit a %s.", itemColor))
+ }
+}
+
+func (g *Game) doBankDepositAll(sess *net.Session) {
+ p, _ := sess.Player.(*player.Player)
+
+ var deposited int
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot == nil {
+ continue
+ }
+ bankIdx := p.NextBankSlot()
+ p.SetBankSlot(bankIdx, &player.InventorySlot{
+ ItemID: slot.ItemID,
+ Quantity: slot.Quantity,
+ Quality: slot.Quality,
+ })
+ p.SetInvSlot(i, nil)
+ deposited++
+ }
+
+ if deposited == 0 {
+ sess.WriteLine("Your inventory is already empty.")
+ return
+ }
+
+ g.AccountStore.SaveCharacter(p)
+
+ sess.WriteLine(fmt.Sprintf("You deposit %d item%s.", deposited, plural(deposited)))
+}
+
+func (g *Game) doBankWithdraw(sess *net.Session, input string) {
+ p, _ := sess.Player.(*player.Player)
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+
+ var entries []struct {
+ slot int
+ name string
+ id string
+ qty int
+ }
+
+ for _, i := range p.BankSlots() {
+ bs := p.BankSlot(i)
+ if bs == nil {
+ continue
+ }
+ def, _ := g.ItemStore.Load(bs.ItemID)
+ itemName := bs.ItemID
+ if def != nil {
+ itemName = def.Name
+ }
+ entries = append(entries, struct {
+ slot int
+ name string
+ id string
+ qty int
+ }{slot: i, name: itemName, id: bs.ItemID, qty: bs.Quantity})
+ }
+
+ idx, err := parseChoiceIndex(input)
+ if err == nil && idx > 0 && idx <= len(entries) {
+ e := entries[idx-1]
+ g.withdrawBankItem(sess, p, e.slot)
+ return
+ }
+
+ var matches []struct {
+ slot int
+ name string
+ id string
+ qty int
+ }
+ lower := strings.ToLower(input)
+ for _, e := range entries {
+ if world.WordPrefixMatch(e.name, lower) {
+ matches = append(matches, e)
+ }
+ }
+
+ if len(matches) == 1 {
+ g.withdrawBankItem(sess, p, matches[0].slot)
+ return
+ }
+
+ for _, e := range entries {
+ def, _ := g.ItemStore.Load(e.id)
+ if def != nil && def.MatchesName(input) {
+ matches = append(matches, e)
+ }
+ }
+
+ if len(matches) == 0 {
+ sess.WriteLine("That item isn't in your bank. Use 'browse' to see your bank contents.")
+ return
+ }
+
+ if len(matches) > 1 {
+ var names []string
+ for _, m := range matches {
+ names = append(names, m.name)
+ }
+ sess.WriteLine("That's ambiguous, which one?")
+ for _, n := range names {
+ sess.WriteLine(fmt.Sprintf(" - %s", n))
+ }
+ return
+ }
+
+ g.withdrawBankItem(sess, p, matches[0].slot)
+}
+
+func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx int) {
+ bs := p.BankSlot(bankSlotIdx)
+ if bs == nil {
+ return
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+
+ itemID := bs.ItemID
+ qty := bs.Quantity
+ def, _ := g.ItemStore.Load(itemID)
+
+ if def != nil && def.Stackable {
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
+ } else {
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: 1})
+ if qty > 1 {
+ bs.Quantity--
+ g.AccountStore.SaveCharacter(p)
+ displayName := itemID
+ if def != nil {
+ displayName = def.Name
+ }
+ itemColor := g.itemColorize(sess, def, displayName)
+ sess.WriteLine(fmt.Sprintf("You withdraw a %s.", itemColor))
+ return
+ }
+ }
+
+ p.SetBankSlot(bankSlotIdx, nil)
+ g.AccountStore.SaveCharacter(p)
+
+ displayName := itemID
+ if def != nil {
+ displayName = def.Name
+ }
+ itemColor := g.itemColorize(sess, def, displayName)
+ if qty > 1 {
+ sess.WriteLine(fmt.Sprintf("You withdraw %d %ss.", qty, itemColor))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You withdraw a %s.", itemColor))
+ }
+}
+
+func (g *Game) canUseBank(p *player.Player) bool {
+ instances := g.World.FindObjInstances(p.RoomID, "bank booth")
+ return len(instances) > 0
+}
+
+func (g *Game) doBank(sess *net.Session) {
+ p, _ := sess.Player.(*player.Player)
+
+ if !g.canUseBank(p) {
+ sess.WriteLine("There is no bank here.")
+ return
+ }
+
+ sess.State = net.StateBank
+ sess.WriteLine(g.colorize(sess, "dialog", "\nYou access the bank terminal."))
+ g.showBankBrowse(sess)
+ g.writeBankPrompt(sess)
+}
+
+func (g *Game) leaveBank(sess *net.Session) {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+}
diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go
index b3d8408..70dedb9 100644
--- a/internal/game/cmd_clean.go
+++ b/internal/game/cmd_clean.go
@@ -1,6 +1,7 @@
package game
import (
+ "fmt"
"strings"
"thehouseoficarus/internal/action"
@@ -62,6 +63,14 @@ func (g *Game) doClean(sess *net.Session, input string) {
}
p.BackgroundActionState = &ActionState{Type: ActionCleaning}
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts cleaning herbs.", p.Name)))
+ }
+ }
+ }
+
sess.WriteLine("\nYou begin cleaning herbs.")
}
diff --git a/internal/game/cmd_construct.go b/internal/game/cmd_construct.go
new file mode 100644
index 0000000..4ab6409
--- /dev/null
+++ b/internal/game/cmd_construct.go
@@ -0,0 +1,157 @@
+package game
+
+import (
+ "strings"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) doConstruct(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ g.CancelAction(p)
+
+ allRecipes, err := g.RecipeStore.LoadAll()
+ if err != nil {
+ sess.WriteLine("Error loading recipes.")
+ return
+ }
+
+ var constructRecipes []action.RecipeDef
+ for _, r := range allRecipes {
+ if r.Type != "construction" {
+ continue
+ }
+ if !g.constructRecipeVisible(p, &r) {
+ continue
+ }
+ constructRecipes = append(constructRecipes, r)
+ }
+
+ if input != "" {
+ g.doConstructWithInput(sess, p, constructRecipes, input)
+ return
+ }
+
+ lastRecipeID, _ := p.Flags["last_construct"].(string)
+ if lastRecipeID != "" {
+ for i := range constructRecipes {
+ if constructRecipes[i].ID == lastRecipeID {
+ r := &constructRecipes[i]
+ if g.canDoConstructRecipe(p, r) {
+ g.startConstructRecipe(sess, p, r, 0)
+ return
+ }
+ break
+ }
+ }
+ }
+
+ available := g.availableConstructRecipes(p, constructRecipes)
+ if len(available) == 0 {
+ sess.WriteLine("You don't have any materials to construct.")
+ return
+ }
+
+ if p.OptionBool("construct_all") && len(available) == 1 {
+ g.startConstructRecipe(sess, p, &available[0], 0)
+ return
+ }
+
+ g.showProductionTable(sess, p, available, "Construction", "construction", "last_construct", "Construct", false)
+}
+
+func (g *Game) doConstructWithInput(sess *net.Session, p *player.Player, constructRecipes []action.RecipeDef, input string) {
+ qty, productName := parseQty(input)
+ productName = strings.TrimSpace(productName)
+
+ var matched []action.RecipeDef
+ for _, r := range constructRecipes {
+ outDef, _ := g.ItemStore.Load(r.Output)
+ name := r.Output
+ if outDef != nil {
+ name = outDef.Name
+ }
+ if world.WordPrefixMatch(productName, name) {
+ matched = append(matched, r)
+ }
+ }
+
+ if len(matched) == 0 {
+ sess.WriteLine("You can't construct that.")
+ return
+ }
+
+ var available []action.RecipeDef
+ for _, r := range matched {
+ if g.canDoConstructRecipe(p, &r) {
+ available = append(available, r)
+ }
+ }
+
+ if len(available) == 0 {
+ sess.WriteLine("You don't have the materials or level for that.")
+ return
+ }
+
+ if len(available) == 1 {
+ g.startConstructRecipe(sess, p, &available[0], qty)
+ return
+ }
+
+ g.showProductionTable(sess, p, available, "Construction", "construction", "last_construct", "Construct", false)
+}
+
+func (g *Game) constructRecipeVisible(p *player.Player, r *action.RecipeDef) bool {
+ if len(r.Station) > 0 {
+ stID, _ := g.findStation(p.RoomID, r.Station)
+ if stID == "" {
+ return false
+ }
+ }
+ if r.Tool != "" && !g.hasToolType(p, r.Tool) {
+ return false
+ }
+ return true
+}
+
+func (g *Game) canDoConstructRecipe(p *player.Player, r *action.RecipeDef) bool {
+ if p.Level(player.Construction) < r.Level {
+ return false
+ }
+ if !r.HasAllItemsQty(p.CountItem) {
+ return false
+ }
+ if len(r.Station) > 0 {
+ stID, _ := g.findStation(p.RoomID, r.Station)
+ if stID == "" {
+ return false
+ }
+ }
+ if r.Tool != "" && !g.hasToolType(p, r.Tool) {
+ return false
+ }
+ return true
+}
+
+func (g *Game) availableConstructRecipes(p *player.Player, allConstruct []action.RecipeDef) []action.RecipeDef {
+ var result []action.RecipeDef
+ for _, r := range allConstruct {
+ if r.HasAllItemsQty(p.CountItem) {
+ result = append(result, r)
+ }
+ }
+ return result
+}
+
+func (g *Game) startConstructRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) {
+ if p.Flags == nil {
+ p.Flags = make(map[string]any)
+ }
+ p.Flags["last_construct"] = recipe.ID
+ g.AccountStore.SaveCharacter(p)
+
+ g.startProductionFromRecipe(sess, p, recipe, count)
+}
diff --git a/internal/game/cmd_drink.go b/internal/game/cmd_drink.go
new file mode 100644
index 0000000..c7f9b34
--- /dev/null
+++ b/internal/game/cmd_drink.go
@@ -0,0 +1,150 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doDrink(sess *net.Session, args []string) {
+ p := sess.Player.(*player.Player)
+
+ if len(args) == 0 {
+ sess.WriteLine("Drink what?")
+ return
+ }
+
+ input := strings.Join(args, " ")
+ matches := g.findInventoryMatches(input, p)
+ if len(matches) == 0 {
+ sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input))
+ return
+ }
+
+ unique := uniqueItemNames(matches)
+ if len(unique) > 1 {
+ g.showWhichOne(sess, matches)
+ return
+ }
+
+ itemID := matches[0].ID
+ def, _ := g.ItemStore.Load(itemID)
+
+ if def == nil {
+ sess.WriteLine("Something went wrong.")
+ return
+ }
+
+ if def.PotionEffect == "" && def.HealValue <= 0 {
+ sess.WriteLine(fmt.Sprintf("You can't drink the %s.", g.itemColorize(sess, def, def.Name)))
+ return
+ }
+
+ if p.ConsumeCooldown > 0 {
+ sess.WriteLine("You're still recovering from your last drink.")
+ return
+ }
+
+ g.cancelRest(p.Name)
+
+ if p.Action == nil && len(p.WalkSequence) == 0 {
+ g.applyPotion(sess, p, itemID, def)
+ return
+ }
+
+ g.CancelAction(p)
+
+ g.consumeQueue[p.Name] = &QueuedCommand{
+ Session: sess,
+ Command: "drink",
+ Args: itemID,
+ Timestamp: time.Now(),
+ }
+
+ if !p.OptionBool("queue_silently") {
+ sess.WriteLine(fmt.Sprintf("\nYou prepare to drink the %s.", g.itemColorize(sess, def, def.Name)))
+ }
+}
+
+func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) {
+ if def == nil {
+ var err error
+ def, err = g.ItemStore.Load(itemID)
+ if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) {
+ return
+ }
+ }
+
+ p.RemoveItem(itemID, 1)
+
+ effect := strings.ToLower(def.PotionEffect)
+
+ switch effect {
+ case "battery":
+ batteryRestore := def.PotionBonus
+ p.Battery += float64(batteryRestore)
+ if p.Battery > 100 {
+ p.Battery = 100
+ }
+ sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your battery is restored by %d%%.",
+ g.itemColorize(sess, def, def.Name), batteryRestore))
+
+ case "heal", "":
+ healAmount := def.PotionBonus
+ if healAmount <= 0 {
+ healAmount = def.HealValue
+ }
+ if healAmount <= 0 {
+ healAmount = 50
+ }
+ before := p.HP
+ maxHP := p.Level(player.Hitpoints)
+ if p.HP+healAmount > maxHP {
+ p.HP = maxHP
+ } else {
+ p.HP += healAmount
+ }
+ actualHeal := p.HP - before
+ sess.WriteLine(fmt.Sprintf("\nYou drink the %s. You restore %d hitpoints.",
+ g.itemColorize(sess, def, def.Name), actualHeal))
+
+ case "all_combat":
+ duration := engine.ToTicks(def.PotionDuration)
+ buffs := []string{"attack", "strength", "defense"}
+ for _, stat := range buffs {
+ p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{
+ Stat: stat,
+ BonusPercent: def.PotionBonus,
+ TicksLeft: duration,
+ })
+ }
+ sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your combat stats are boosted by %d%% for %d ticks.",
+ g.itemColorize(sess, def, def.Name), def.PotionBonus, duration))
+
+ default:
+ duration := engine.ToTicks(def.PotionDuration)
+ p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{
+ Stat: effect,
+ BonusPercent: def.PotionBonus,
+ TicksLeft: duration,
+ })
+ sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your %s is boosted by %d%% for %d ticks.",
+ g.itemColorize(sess, def, def.Name), effect, def.PotionBonus, duration))
+ }
+
+ p.ConsumeCooldown = 3
+ p.ActionState = &ActionState{Type: ActionEating}
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s drinks a %s.", p.Name, def.Name)))
+ }
+ }
+ }
+}
diff --git a/internal/game/cmd_eat.go b/internal/game/cmd_eat.go
index 6a74979..e6a0386 100644
--- a/internal/game/cmd_eat.go
+++ b/internal/game/cmd_eat.go
@@ -111,6 +111,22 @@ func (g *Game) processConsumeQueue(p *player.Player, sess *net.Session) bool {
itemID := qc.Args
def, err := g.ItemStore.Load(itemID)
+
+ if qc.Command == "drink" {
+ if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) {
+ return false
+ }
+ if p.ConsumeCooldown > 0 {
+ return false
+ }
+ if !p.HasItem(itemID) {
+ sess.WriteLine("You no longer have that to drink.")
+ return false
+ }
+ g.applyPotion(sess, p, itemID, def)
+ return true
+ }
+
if err != nil || def.EatMessage == "" {
return false
}
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
new file mode 100644
index 0000000..6c66ca4
--- /dev/null
+++ b/internal/game/cmd_estate_directory.go
@@ -0,0 +1,223 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+
+ "gopkg.in/yaml.v3"
+)
+
+func (g *Game) startEstateDirectory(sess *net.Session, verb string) {
+ p := sess.Player.(*player.Player)
+
+ if verb == "toggle" {
+ sess.WriteLine("You can't do that with the directory.")
+ return
+ }
+
+ homeowners := g.scanHomeowners("owns_house_local_neighborhood")
+ ownedByMe := false
+ for _, h := range homeowners {
+ if h == p.Name {
+ ownedByMe = true
+ break
+ }
+ }
+
+ if verb == "talk" {
+ g.showEstateDirectoryTalk(sess, p, homeowners, ownedByMe)
+ return
+ }
+
+ if verb == "use" {
+ if ownedByMe {
+ houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood")
+ if houseRoom > 0 {
+ g.CancelAction(p)
+ g.teleportPlayer(sess, p, houseRoom)
+ return
+ }
+ }
+ sess.WriteLine("You don't own a house here. Talk to the estate broker to purchase one.")
+ return
+ }
+}
+
+func (g *Game) lookEstateDirectory(sess *net.Session) {
+ homeowners := g.scanHomeowners("owns_house_local_neighborhood")
+ sess.WriteLine("")
+ sess.WriteLine(g.colorize(sess, "dialog", "Estate Directory"))
+ sess.WriteLine(fmt.Sprintf(" A holographic terminal displaying property records for the Local Neighborhood."))
+ sess.WriteLine("")
+ if len(homeowners) == 0 {
+ sess.WriteLine(" No registered homeowners in this area.")
+ } else {
+ sess.WriteLine(" Registered homeowners:")
+ for i, name := range homeowners {
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, name))
+ }
+ }
+ sess.WriteLine("")
+ sess.WriteLine(" Use the directory to enter your home, or talk to the estate broker to purchase.")
+}
+
+func (g *Game) showEstateDirectoryTalk(sess *net.Session, p *player.Player, homeowners []string, ownedByMe bool) {
+ sess.WriteLine("")
+ sess.WriteLine(g.colorize(sess, "dialog", "The directory terminal hums quietly, displaying a list of registered homeowners..."))
+
+ if ownedByMe {
+ sess.WriteLine("")
+ sess.WriteLine(" You are a registered homeowner in this neighborhood.")
+ sess.WriteLine(" Type 'enter' to go to your house, or 'leave' to step away.")
+ sess.State = net.StateTalk
+ p.ActionState = &ActionState{Type: ActionTalking, TargetName: "Estate Directory"}
+ p.Action = &action.Action{
+ Type: "talk",
+ TargetID: "estate_directory",
+ TargetName: "Estate Directory",
+ Data: map[string]any{"node": "start", "behavior_id": "estate_directory_talk"},
+ }
+ sess.Write("\nChoice: ")
+ return
+ }
+
+ sess.WriteLine("")
+ if len(homeowners) > 0 {
+ sess.WriteLine(" Currently owned by: " + strings.Join(homeowners, ", "))
+ }
+ sess.WriteLine(" You don't own a house here yet.")
+ sess.WriteLine(" Talk to the estate broker to purchase a plot for 10 credits.")
+}
+
+func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) {
+ input = strings.TrimSpace(strings.ToLower(input))
+
+ switch input {
+ case "enter":
+ p, ok := sess.Player.(*player.Player)
+ if !ok {
+ return
+ }
+ houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood")
+ if houseRoom > 0 {
+ sess.State = net.StateGame
+ p.Action = nil
+ p.ActionState = nil
+ g.teleportPlayer(sess, p, houseRoom)
+ return
+ }
+ sess.State = net.StateGame
+ p.Action = nil
+ p.ActionState = nil
+ g.writePrompt(sess)
+ case "leave", "bye", "exit", "quit":
+ p, ok := sess.Player.(*player.Player)
+ if ok {
+ p.Action = nil
+ p.ActionState = nil
+ }
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ default:
+ sess.WriteLine("Type 'enter' to go to your house, or 'leave' to step away.")
+ sess.Write("\nChoice: ")
+ }
+}
+
+func (g *Game) scanHomeowners(flagKey string) []string {
+ var homeowners []string
+
+ charsDir := filepath.Join(g.dataDir, "players", "characters")
+ entries, err := os.ReadDir(charsDir)
+ if err != nil {
+ return homeowners
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
+ continue
+ }
+
+ name := strings.TrimSuffix(entry.Name(), ".yaml")
+ data, err := os.ReadFile(filepath.Join(charsDir, entry.Name()))
+ if err != nil {
+ continue
+ }
+
+ var p struct {
+ Flags map[string]any `yaml:"flags"`
+ }
+ if err := yaml.Unmarshal(data, &p); err != nil {
+ continue
+ }
+
+ if p.Flags != nil {
+ if val, ok := p.Flags[flagKey]; ok && val != nil {
+ owned := false
+ switch v := val.(type) {
+ case bool:
+ owned = v
+ case string:
+ owned = v != "" && v != "false"
+ }
+ if owned {
+ homeowners = append(homeowners, name)
+ }
+ }
+ }
+ }
+
+ sort.Strings(homeowners)
+ return homeowners
+}
+
+func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int {
+ if p.Flags == nil {
+ return 0
+ }
+ val, ok := p.Flags[flagKey]
+ if !ok || val == nil {
+ return 0
+ }
+
+ v, ok := val.(string)
+ if !ok || v == "" {
+ return 0
+ }
+
+ houseMap := map[string]int{
+ "local_neighborhood": 200,
+ }
+ return houseMap[v]
+}
+
+func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) {
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("\n%s disappears in a shimmer of light.", p.Name))
+ }
+ }
+ }
+
+ p.RoomID = roomID
+ if g.Hub != nil {
+ g.Hub.LeaveRoom(sess)
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+
+ g.World.SeedGroundItems(p.RoomID)
+ g.seedRoomMobs(p.RoomID)
+ g.seedRoomObjects(p.RoomID)
+ g.AccountStore.SaveCharacter(p)
+ g.doLook(sess)
+ g.RunEnterSteps(sess, p.RoomID)
+ g.writePrompt(sess)
+}
diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go
new file mode 100644
index 0000000..db157f3
--- /dev/null
+++ b/internal/game/cmd_jack.go
@@ -0,0 +1,83 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doJack(sess *net.Session, target string) {
+ p := sess.Player.(*player.Player)
+
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ if target == "" {
+ target = g.findTerminalInRoom(p.RoomID)
+ if target == "" {
+ sess.WriteLine("There's no terminal here to jack into.")
+ return
+ }
+ }
+
+ instances := g.World.FindObjInstances(p.RoomID, strings.ToLower(target))
+ if len(instances) == 0 {
+ sess.WriteLine("You don't see that here.")
+ return
+ }
+
+ defID := instances[0].DefID
+ tDef, ok := terminalDefs[defID]
+ if !ok {
+ sess.WriteLine("You can't jack into that.")
+ return
+ }
+
+ hackLevel := p.Level(player.Hacking)
+ if hackLevel < tDef.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d Hacking to use this terminal.", tDef.Level))
+ return
+ }
+
+ g.CancelAction(p)
+ g.CancelBackgroundAction(p)
+
+ minigame := tDef.Minigame()
+ display := minigame.Init(hackLevel)
+
+ g.hackingStates[p.Name] = &HackingSession{
+ Minigame: minigame,
+ TerminalID: defID,
+ Level: hackLevel,
+ ReqLevel: tDef.Level,
+ Started: false,
+ }
+
+ p.ActionState = &ActionState{Type: ActionHacking, TargetName: tDef.Name}
+ sess.State = net.StateHacking
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("\n%s jacks into a terminal.", p.Name))
+ }
+ }
+ }
+
+ sess.WriteLine(display)
+ sess.Write("\nhack> ")
+}
+
+func (g *Game) findTerminalInRoom(roomID int) string {
+ for _, st := range g.World.FindObjInstances(roomID, "") {
+ if strings.HasPrefix(st.DefID, "terminal_") {
+ return st.Name
+ }
+ }
+ return ""
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 4abd89e..6c83130 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -480,6 +480,12 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
if len(instances) > 0 {
st := &instances[0]
def, _ := g.ObjectStore.Load(st.DefID)
+
+ if st.DefID == "estate_directory" {
+ g.lookEstateDirectory(sess)
+ return
+ }
+
sess.WriteLine("")
if len(instances) > 1 {
sess.WriteLine(fmt.Sprintf("%d %ss:", len(instances), def.Name))
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index 89b6e7f..2ec0221 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -1,6 +1,8 @@
package game
import (
+ "fmt"
+
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
@@ -19,6 +21,14 @@ func (g *Game) doQuit(sess *net.Session) {
p.ActionState = &ActionState{Type: ActionResting}
g.cancelRest(p.Name)
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s sits down to rest.", p.Name)))
+ }
+ }
+ }
+
ticksLeft := 10
id := g.Ticks.Subscribe(engine.ToTicks(1), func() bool {
switch ticksLeft {
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index 2005903..82c0803 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -57,4 +57,17 @@ func (g *Game) doScore(sess *net.Session) {
}
}
}
+
+ if len(p.ActiveBuffs) > 0 {
+ sess.WriteLine("")
+ sess.WriteLine("Active Buffs:")
+ for _, buff := range p.ActiveBuffs {
+ sess.WriteLine(fmt.Sprintf(" %s +%d%% %s (%d ticks)",
+ color.Render(mode, color.Parse("82"), "[BUFF]"),
+ buff.BonusPercent,
+ color.Render(mode, color.Parse("75"), buff.Stat),
+ buff.TicksLeft,
+ ))
+ }
+ }
}
diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go
index 8e3a84e..7ef4913 100644
--- a/internal/game/cmd_trigger.go
+++ b/internal/game/cmd_trigger.go
@@ -228,6 +228,8 @@ func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef,
g.triggerEmGrab(sess, p, mod, targetArg)
case "superheat":
g.triggerSuperheat(sess, p, mod, targetArg)
+ case "plank_make":
+ g.triggerPlankMake(sess, p, mod, targetArg)
}
}
@@ -440,6 +442,128 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
}
}
+func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ logTypes := map[string]struct {
+ plankID string
+ name string
+ cost int
+ }{
+ "logs": {"planks", "planks", 3},
+ "oak_logs": {"oak_planks", "oak planks", 7},
+ "teak_logs": {"teak_planks", "teak planks", 14},
+ "mahogany_logs": {"mahogany_planks", "mahogany planks", 28},
+ }
+
+ if targetArg != "" {
+ slot, inv := g.findInventoryItem(p, targetArg)
+ if slot < 0 {
+ sess.WriteLine("You don't have that item.")
+ return
+ }
+ info, ok := logTypes[inv.ItemID]
+ if !ok {
+ sess.WriteLine("You can't turn that into planks.")
+ return
+ }
+
+ if p.Credits < info.cost {
+ sess.WriteLine(fmt.Sprintf("You need %d credits for Plank Make (70%% of sawmill cost).", info.cost))
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ p.RemoveItem(inv.ItemID, 1)
+ p.Credits -= info.cost
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1})
+
+ sciXP := int(mod.BaseXP)
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ if newLevel := p.AddSkillXP(player.Construction, 10); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d construction! ***", p.Level(player.Construction))))
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ sess.WriteLine(fmt.Sprintf("You convert the log into %s.", info.name))
+ if p.OptionBool("xp_drops") {
+ parts := []string{fmt.Sprintf("+%dxp sci", sciXP), "+10xp con"}
+ sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")"))
+ }
+ return
+ }
+
+ for logID, info := range logTypes {
+ qty := p.CountItem(logID)
+ if qty == 0 {
+ continue
+ }
+
+ totalCost := info.cost * qty
+ if p.Credits < totalCost {
+ continue
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ p.RemoveItem(logID, qty)
+ p.Credits -= totalCost
+
+ processed := 0
+ for i := 0; i < qty; i++ {
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ g.World.AddGroundItem(p.RoomID, info.plankID, qty-i)
+ break
+ }
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1})
+ processed++
+ }
+
+ sciXP := int(mod.BaseXP) * qty
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ conXP := 10 * qty
+ if newLevel := p.AddSkillXP(player.Construction, conXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d construction! ***", p.Level(player.Construction))))
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ sess.WriteLine(fmt.Sprintf("You convert %d logs into %s for %d credits.", processed, info.name, totalCost))
+ if processed < qty {
+ sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", qty-processed))
+ }
+ if p.OptionBool("xp_drops") {
+ parts := []string{fmt.Sprintf("+%dxp sci", sciXP), fmt.Sprintf("+%dxp con", conXP)}
+ sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")"))
+ }
+ return
+ }
+
+ sess.WriteLine("You don't have any logs to convert.")
+}
+
func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
if chipInfo, ok := chipMap[mod.ID]; ok {
g.triggerChipBolts(sess, p, mod, chipInfo)
@@ -568,7 +692,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob
g.consumeJunkCost(p, mod)
equipSciBonus := g.totalEquipScienceAttack(p)
- attRoll := (p.Level(player.Science) + 8) * (equipSciBonus + 64)
+ attRoll := (p.Level(player.Science) + g.buffLevelBonus(p, "science") + 8) * (equipSciBonus + 64)
mobSciDef := mob.ScienceDefense
defRoll := (mob.Defense + 9) * (mobSciDef + 64)
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 7d40cc2..95a7b5b 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -25,6 +25,11 @@ func (g *Game) doUse(sess *net.Session, input string) {
lower := strings.ToLower(input)
+ if lower == "bank" || lower == "bank booth" || strings.HasPrefix(lower, "bank ") {
+ g.doBank(sess)
+ return
+ }
+
sep := ""
if strings.Contains(lower, " on ") {
sep = " on "
diff --git a/internal/game/course.go b/internal/game/course.go
new file mode 100644
index 0000000..335c97f
--- /dev/null
+++ b/internal/game/course.go
@@ -0,0 +1,141 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+type ObstacleDef struct {
+ RoomID int `yaml:"room_id"`
+ Verb string `yaml:"verb"`
+ TicksPerPhase float64 `yaml:"ticks_per_phase"`
+ XP int `yaml:"xp"`
+ FailDamage [2]int `yaml:"fail_damage"`
+ Messages []string `yaml:"messages"`
+}
+
+type CourseConfig struct {
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ RequiredLevel int `yaml:"required_level"`
+ StartRoom int `yaml:"start_room"`
+ CompletionXP int `yaml:"completion_xp"`
+ Obstacles []ObstacleDef `yaml:"obstacles"`
+}
+
+type ObstacleInfo struct {
+ CourseID string
+ CourseName string
+ ObstacleIndex int
+ TotalObstacles int
+ Verb string
+ Messages []string
+ TicksPerPhase float64
+ ObstacleXP int
+ CompletionXP int
+ FailDamage [2]int
+ NextRoom int
+ StartRoom int
+ RequiredLevel int
+}
+
+var obstacleVerbs = map[string]bool{}
+
+var verbGerund = map[string]string{
+ "scramble": "scrambling",
+ "jump": "jumping",
+ "swing": "swinging",
+ "balance": "balancing",
+ "climb": "climbing",
+ "crawl": "crawling",
+ "vault": "vaulting",
+ "leap": "leaping",
+ "slide": "sliding",
+}
+
+type CourseStore struct {
+ dataDir string
+ mu sync.Mutex
+ courses map[string]*CourseConfig
+ roomToObstacle map[int]*ObstacleInfo
+ loaded bool
+}
+
+func NewCourseStore(dataDir string) *CourseStore {
+ return &CourseStore{
+ dataDir: dataDir,
+ courses: make(map[string]*CourseConfig),
+ }
+}
+
+func (cs *CourseStore) LoadAll() {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
+ cs.loadAllLocked()
+}
+
+func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
+ if !cs.loaded {
+ cs.loadAllLocked()
+ }
+ return cs.roomToObstacle[roomID]
+}
+
+func (cs *CourseStore) loadAllLocked() {
+ cs.loaded = true
+ cs.roomToObstacle = make(map[int]*ObstacleInfo)
+ localVerbs := make(map[string]bool)
+
+ pattern := filepath.Join(cs.dataDir, "courses", "*.yaml")
+ files, _ := filepath.Glob(pattern)
+
+ for _, f := range files {
+ data, err := os.ReadFile(f)
+ if err != nil {
+ continue
+ }
+ var cfg CourseConfig
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ continue
+ }
+ cs.courses[cfg.ID] = &cfg
+
+ totalObstacles := len(cfg.Obstacles)
+ for i, obs := range cfg.Obstacles {
+ nextRoom := 0
+ if i < totalObstacles-1 {
+ nextRoom = cfg.Obstacles[i+1].RoomID
+ }
+
+ completionXP := 0
+ if i == totalObstacles-1 {
+ completionXP = cfg.CompletionXP
+ }
+
+ info := &ObstacleInfo{
+ CourseID: cfg.ID,
+ CourseName: cfg.Name,
+ ObstacleIndex: i,
+ TotalObstacles: totalObstacles,
+ Verb: obs.Verb,
+ Messages: obs.Messages,
+ TicksPerPhase: obs.TicksPerPhase,
+ ObstacleXP: obs.XP,
+ CompletionXP: completionXP,
+ FailDamage: obs.FailDamage,
+ NextRoom: nextRoom,
+ StartRoom: cfg.StartRoom,
+ RequiredLevel: cfg.RequiredLevel,
+ }
+ cs.roomToObstacle[obs.RoomID] = info
+ localVerbs[obs.Verb] = true
+ }
+ }
+
+ obstacleVerbs = localVerbs
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index e800a78..03300a4 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -41,6 +41,7 @@ type Game struct {
MobStore *world.MobStore
BehaviorStore *action.Store
RecipeStore *action.RecipeStore
+ CourseStore *CourseStore
Hub *net.Hub
Ticks *engine.Engine
WorldFlags map[string]any
@@ -56,10 +57,11 @@ type Game struct {
pendingDepletions []pendingDepletion
guardWatchTimers map[string]int
farmTickCounter int
+ hackingStates map[string]*HackingSession
}
func New(dataDir string, colorConfig *config.ColorsConfig) *Game {
- return &Game{
+ g := &Game{
World: world.New(dataDir),
ObjectStore: object.NewObjectStore(dataDir),
ItemStore: object.NewItemStore(dataDir),
@@ -67,6 +69,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game {
MobStore: world.NewMobStore(dataDir),
BehaviorStore: action.NewStore(dataDir),
RecipeStore: action.NewRecipeStore(dataDir),
+ CourseStore: NewCourseStore(dataDir),
Ticks: engine.New(),
WorldFlags: make(map[string]any),
ColorConfig: colorConfig,
@@ -78,7 +81,10 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game {
consumeQueue: make(map[string]*QueuedCommand),
pendingDepletions: nil,
guardWatchTimers: make(map[string]int),
+ hackingStates: make(map[string]*HackingSession),
}
+ g.CourseStore.LoadAll()
+ return g
}
func (g *Game) SetHub(hub *net.Hub) {
@@ -89,6 +95,7 @@ func (g *Game) SetHub(hub *net.Hub) {
g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
g.charsMu.Unlock()
+ delete(g.hackingStates, p.Name)
}
})
}
@@ -126,6 +133,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleTalkInput(sess, input)
case net.StateShop:
g.handleShopInput(sess, input)
+ case net.StateBank:
+ g.handleBankInput(sess, input)
case net.StateDropAllConfirm:
g.handleDropAllConfirm(sess, input)
case net.StateRecipeChoice:
@@ -136,6 +145,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleProductChoice(sess, input)
case net.StateColorChoice:
g.handleColorChoice(sess, input)
+ case net.StateHacking:
+ g.handleHackingInput(sess, input)
}
}
@@ -160,14 +171,20 @@ func classifyCommand(cmd string) CommandClass {
"id", "identify",
"steal", "thieve",
"trigger", "cast",
- "plant", "harvest", "rake", "water", "cure":
+ "plant", "harvest", "rake", "water", "cure",
+ "bank", "construct", "make":
return ClassActive
- case "eat", "fletch", "clean":
+ case "jack", "jackin":
+ return ClassActive
+ case "eat", "fletch", "clean", "drink":
return ClassFree
}
if _, ok := verbAliases[cmd]; ok {
return ClassActive
}
+ if obstacleVerbs[cmd] {
+ return ClassActive
+ }
return ClassUnknown
}
@@ -404,6 +421,9 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
case "eat":
g.doEat(sess, strings.Join(args, " "))
return
+ case "drink":
+ g.doDrink(sess, args)
+ return
case "fletch":
g.doFletch(sess, strings.Join(args, " "))
return
@@ -512,6 +532,16 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
case "walk":
g.doWalk(sess, args)
return
+ case "bank":
+ g.doBank(sess)
+ return
+ case "construct", "make":
+ g.doConstruct(sess, strings.Join(args, " "))
+ return
+ case "jack", "jackin":
+ g.CancelAction(p)
+ g.doJack(sess, strings.Join(args, " "))
+ return
case "eq", "equipment", "equip", "wear", "wield":
g.doWear(sess, strings.Join(args, " "))
return
@@ -519,6 +549,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
g.doRemove(sess, strings.Join(args, " "))
return
default:
+ if obstacleVerbs[cmd] {
+ g.doObstacle(sess, cmd)
+ return
+ }
if a, ok := verbAliases[cmd]; ok {
g.CancelAction(p)
switch a {
@@ -565,7 +599,8 @@ func (g *Game) ProcessQueuedCommands() {
switch as.Type {
case ActionGathering, ActionCombating, ActionUsing, ActionTalking,
ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing,
- ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring:
+ ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring,
+ ActionTraversing:
default:
if as.Type != ActionMoving || p.MoveTicks <= 0 {
p.ActionState = nil
diff --git a/internal/game/hacking.go b/internal/game/hacking.go
new file mode 100644
index 0000000..dfefd5c
--- /dev/null
+++ b/internal/game/hacking.go
@@ -0,0 +1,157 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+type HackingMinigame interface {
+ Init(level int) string
+ HandleInput(input string) (output string, done bool, won bool)
+ Name() string
+}
+
+type XPBonuser interface {
+ BonusXP() int
+}
+
+type HackingSession struct {
+ Minigame HackingMinigame
+ TerminalID string
+ Level int
+ ReqLevel int
+ Started bool
+}
+
+type TerminalDef struct {
+ ObjectID string
+ Level int
+ Minigame func() HackingMinigame
+ BaseXPWin int
+ BaseXPLose int
+ Name string
+}
+
+var terminalDefs = map[string]TerminalDef{
+ "terminal_basic": {
+ ObjectID: "terminal_basic",
+ Level: 1,
+ Minigame: func() HackingMinigame { return NewWumpusGame() },
+ BaseXPWin: 50,
+ BaseXPLose: 10,
+ Name: "Hunt the Wumpus",
+ },
+ "terminal_advanced": {
+ ObjectID: "terminal_advanced",
+ Level: 20,
+ Minigame: func() HackingMinigame { return NewMastermindGame() },
+ BaseXPWin: 150,
+ BaseXPLose: 30,
+ Name: "Code Breaker",
+ },
+ "terminal_secure": {
+ ObjectID: "terminal_secure",
+ Level: 40,
+ Minigame: func() HackingMinigame { return NewLiarsDiceGame() },
+ BaseXPWin: 300,
+ BaseXPLose: 50,
+ Name: "Signal Bluff",
+ },
+}
+
+func (g *Game) handleHackingInput(sess *net.Session, input string) {
+ p, ok := sess.Player.(*player.Player)
+ if !ok {
+ sess.State = net.StateGame
+ return
+ }
+
+ hs, ok := g.hackingStates[p.Name]
+ if !ok {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ return
+ }
+
+ input = strings.TrimSpace(input)
+ lower := strings.ToLower(input)
+
+ if lower == "jack out" || lower == "quit" || lower == "disconnect" {
+ g.endHacking(sess, p, false, false)
+ return
+ }
+
+ hs.Started = true
+ output, done, won := hs.Minigame.HandleInput(input)
+
+ sess.WriteLine(output)
+
+ if done {
+ g.endHacking(sess, p, true, won)
+ return
+ }
+
+ sess.Write("\nhack> ")
+}
+
+func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, won bool) {
+ hs, ok := g.hackingStates[p.Name]
+ if !ok {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ return
+ }
+
+ tDef := terminalDefs[hs.TerminalID]
+ var xp int
+
+ if completed && won {
+ xp = hackingXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel)
+ sess.WriteLine(fmt.Sprintf("\nConnection terminated. Contract complete."))
+ } else if completed {
+ xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel)
+ sess.WriteLine(fmt.Sprintf("\nConnection lost."))
+ } else if hs.Started {
+ xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel)
+ sess.WriteLine("\nYou jack out of the terminal.")
+ } else {
+ sess.WriteLine("\nYou jack out of the terminal.")
+ }
+
+ if won {
+ if b, ok := hs.Minigame.(XPBonuser); ok {
+ bonus := b.BonusXP()
+ if bonus > 0 {
+ xp += hackingXP(bonus, hs.Level, hs.ReqLevel)
+ }
+ }
+ }
+
+ if xp > 0 {
+ if newLevel := p.AddSkillXP(player.Hacking, xp); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up",
+ fmt.Sprintf("*** You are now level %d hacking! ***", newLevel)))
+ }
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp",
+ fmt.Sprintf("(+%dxp %s)", xp, player.SkillAbbr[player.Hacking])))
+ }
+ g.AccountStore.SaveCharacter(p)
+ }
+
+ delete(g.hackingStates, p.Name)
+ p.ActionState = nil
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+}
+
+func hackingXP(baseXP int, playerLevel int, reqLevel int) int {
+ multiplier := 1.0 + 0.01*float64(playerLevel-reqLevel)
+ if multiplier < 1.0 {
+ multiplier = 1.0
+ }
+ return int(float64(baseXP) * multiplier)
+}
diff --git a/internal/game/hacking_liars_dice.go b/internal/game/hacking_liars_dice.go
new file mode 100644
index 0000000..12f76c1
--- /dev/null
+++ b/internal/game/hacking_liars_dice.go
@@ -0,0 +1,395 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+ "strconv"
+ "strings"
+)
+
+type LiarsDiceGame struct {
+ playerDice []int
+ aiDice []int
+ currentBid LiarsBid
+ hasBid bool
+ playerTurn bool
+ round int
+ gameOver bool
+ won bool
+ playerLost int
+ completed bool
+}
+
+type LiarsBid struct {
+ Quantity int
+ Face int
+}
+
+func NewLiarsDiceGame() *LiarsDiceGame {
+ return &LiarsDiceGame{
+ playerDice: make([]int, 5),
+ aiDice: make([]int, 5),
+ }
+}
+
+func (l *LiarsDiceGame) Name() string {
+ return "Signal Bluff"
+}
+
+func (l *LiarsDiceGame) Init(level int) string {
+ l.playerDice = make([]int, 5)
+ l.aiDice = make([]int, 5)
+ l.round = 0
+ l.gameOver = false
+ l.playerTurn = true
+ l.hasBid = false
+ l.playerLost = 0
+ l.completed = false
+
+ var sb strings.Builder
+ sb.WriteString("=== SIGNAL BLUFF ===\n")
+ sb.WriteString("\n")
+ sb.WriteString("You've connected to a secure channel running a bluffing protocol.\n")
+ sb.WriteString("You and the system each have 5 signal fragments (dice). Each round,\n")
+ sb.WriteString("fragments are scrambled. You see only yours. Take turns making claims\n")
+ sb.WriteString("about the total count of a specific signal across ALL fragments.\n")
+ sb.WriteString("\n")
+ sb.WriteString("1s are wild -- they count as any signal.\n")
+ sb.WriteString("\n")
+ sb.WriteString("Commands:\n")
+ sb.WriteString(" bid <qty> <face> - Claim at least N fragments show face F\n")
+ sb.WriteString(" (e.g., 'bid 3 4' = \"at least three 4s\")\n")
+ sb.WriteString(" call - Challenge the last bid (reveal all)\n")
+ sb.WriteString(" exact - Claim the bid is exactly right\n")
+ sb.WriteString(" status - Show your fragments and current bid\n")
+ sb.WriteString(" jack out - Disconnect (forfeit)\n")
+ sb.WriteString("\n")
+ sb.WriteString(l.startRound())
+ return sb.String()
+}
+
+func (l *LiarsDiceGame) HandleInput(input string) (string, bool, bool) {
+ input = strings.TrimSpace(strings.ToLower(input))
+ parts := strings.Fields(input)
+
+ if len(parts) == 0 {
+ return "Commands: bid <qty> <face>, call, exact, status, jack out", false, false
+ }
+
+ cmd := parts[0]
+
+ switch cmd {
+ case "bid", "b":
+ if len(parts) < 3 {
+ return "Usage: bid <quantity> <face> (e.g., bid 3 4)", false, false
+ }
+ qty, err1 := strconv.Atoi(parts[1])
+ face, err2 := strconv.Atoi(parts[2])
+ if err1 != nil || err2 != nil {
+ return "Usage: bid <quantity> <face> (e.g., bid 3 4)", false, false
+ }
+ return l.doBid(qty, face)
+ case "call", "c", "intercept", "liar":
+ return l.doCall(false)
+ case "exact", "e":
+ return l.doExact()
+ case "status":
+ return l.doStatus(), false, false
+ default:
+ if len(parts) == 2 {
+ qty, err1 := strconv.Atoi(parts[0])
+ face, err2 := strconv.Atoi(parts[1])
+ if err1 == nil && err2 == nil {
+ return l.doBid(qty, face)
+ }
+ }
+ return "Commands: bid <qty> <face>, call, exact, status, jack out", false, false
+ }
+}
+
+func (l *LiarsDiceGame) doBid(qty, face int) (string, bool, bool) {
+ if face < 1 || face > 6 {
+ return "Face must be 1-6.", false, false
+ }
+ if qty < 1 {
+ return "Quantity must be at least 1.", false, false
+ }
+ if !l.playerTurn {
+ return "It's not your turn to bid. Use 'call' or 'exact'.", false, false
+ }
+
+ if l.hasBid {
+ if qty < l.currentBid.Quantity || (qty == l.currentBid.Quantity && face <= l.currentBid.Face) {
+ return "You must bid higher (higher quantity or same quantity with higher face).", false, false
+ }
+ }
+
+ l.currentBid = LiarsBid{Quantity: qty, Face: face}
+ l.hasBid = true
+ l.playerTurn = false
+
+ return l.aiTurn()
+}
+
+func (l *LiarsDiceGame) doCall(player bool) (string, bool, bool) {
+ if !l.hasBid {
+ return "No bid to call yet. Make a bid first.", false, false
+ }
+
+ var sb strings.Builder
+ if player {
+ sb.WriteString("System intercepts!\n")
+ } else {
+ sb.WriteString("You intercept!\n")
+ }
+
+ sb.WriteString(l.showAllDice())
+ sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face)))
+
+ total := l.countFace(l.currentBid.Face)
+ sb.WriteString(fmt.Sprintf("Actual count: %d (%d %ss + %d wild 1s)\n\n",
+ total,
+ l.countFaceNonWild(l.currentBid.Face),
+ faceName(l.currentBid.Face),
+ l.countFace(1)-l.countFaceNonWild(1)))
+
+ if total >= l.currentBid.Quantity {
+ sb.WriteString("The bid holds! ")
+ if player {
+ sb.WriteString("System loses a fragment.")
+ l.aiDice = l.aiDice[:len(l.aiDice)-1]
+ } else {
+ sb.WriteString("You lose a fragment.")
+ l.playerDice = l.playerDice[:len(l.playerDice)-1]
+ l.playerLost++
+ }
+ } else {
+ sb.WriteString("Bluff called! ")
+ if player {
+ sb.WriteString("You lose a fragment.")
+ l.playerDice = l.playerDice[:len(l.playerDice)-1]
+ l.playerLost++
+ } else {
+ sb.WriteString("System loses a fragment.")
+ l.aiDice = l.aiDice[:len(l.aiDice)-1]
+ }
+ }
+
+ return l.checkGameOver(sb)
+}
+
+func (l *LiarsDiceGame) doExact() (string, bool, bool) {
+ if !l.hasBid {
+ return "No bid to claim exact on. Make a bid first.", false, false
+ }
+
+ var sb strings.Builder
+ sb.WriteString("You claim exact match!\n")
+
+ sb.WriteString(l.showAllDice())
+ total := l.countFace(l.currentBid.Face)
+ sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face)))
+ sb.WriteString(fmt.Sprintf("Actual count: %d\n\n", total))
+
+ if total == l.currentBid.Quantity {
+ sb.WriteString("Exact match! You recover a fragment.\n")
+ if len(l.playerDice) < 5 {
+ l.playerDice = append(l.playerDice, rand.Intn(6)+1)
+ }
+ } else {
+ sb.WriteString("Not an exact match. You lose a fragment.\n")
+ l.playerDice = l.playerDice[:len(l.playerDice)-1]
+ l.playerLost++
+ }
+
+ return l.checkGameOver(sb)
+}
+
+func (l *LiarsDiceGame) checkGameOver(sb strings.Builder) (string, bool, bool) {
+ if len(l.playerDice) == 0 {
+ sb.WriteString("\nYou've lost all your fragments. Connection terminated.")
+ l.gameOver = true
+ l.won = false
+ l.completed = true
+ return sb.String(), true, false
+ }
+ if len(l.aiDice) == 0 {
+ sb.WriteString("\nSystem has no fragments left. You win!")
+ l.gameOver = true
+ l.won = true
+ l.completed = true
+ return sb.String(), true, true
+ }
+
+ sb.WriteString(l.startRound())
+ return sb.String(), false, false
+}
+
+func (l *LiarsDiceGame) showAllDice() string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Your fragments: %s\n", l.diceStr(l.playerDice)))
+ sb.WriteString(fmt.Sprintf("System fragments: %s\n", l.diceStr(l.aiDice)))
+ return sb.String()
+}
+
+func (l *LiarsDiceGame) diceStr(dice []int) string {
+ var parts []string
+ for _, d := range dice {
+ parts = append(parts, fmt.Sprintf("[%d]", d))
+ }
+ return strings.Join(parts, " ")
+}
+
+func (l *LiarsDiceGame) doStatus() string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round))
+ sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n",
+ l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice)))
+ if l.hasBid {
+ who := "System"
+ if !l.playerTurn {
+ who = "System"
+ } else {
+ who = "you"
+ }
+ sb.WriteString(fmt.Sprintf("Current bid: %d %s (by %s)\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face), who))
+ }
+ if l.playerTurn {
+ sb.WriteString("Your turn: make a bid.")
+ } else {
+ sb.WriteString("Your turn: bid higher, call, or exact.")
+ }
+ return sb.String()
+}
+
+func (l *LiarsDiceGame) startRound() string {
+ l.round++
+ for i := range l.playerDice {
+ l.playerDice[i] = rand.Intn(6) + 1
+ }
+ for i := range l.aiDice {
+ l.aiDice[i] = rand.Intn(6) + 1
+ }
+ l.hasBid = false
+ l.playerTurn = true
+
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round))
+ sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n",
+ l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice)))
+ sb.WriteString("You go first. Make a bid.\n")
+ return sb.String()
+}
+
+func (l *LiarsDiceGame) aiTurn() (string, bool, bool) {
+ totalDice := len(l.playerDice) + len(l.aiDice)
+
+ aiCount := l.countFace(l.currentBid.Face)
+ unknownDice := len(l.playerDice)
+ estimated := float64(aiCount) + float64(unknownDice)/3.0
+
+ if float64(l.currentBid.Quantity) > estimated*1.5 {
+ return l.doCall(true)
+ }
+
+ bestFace, bestCount := l.aiBestFace()
+ newQty := l.currentBid.Quantity
+ newFace := l.currentBid.Face
+
+ if bestFace > newFace && bestCount >= newQty {
+ newFace = bestFace
+ } else {
+ newQty++
+ }
+
+ if newQty > totalDice {
+ return l.doCall(true)
+ }
+
+ l.currentBid = LiarsBid{Quantity: newQty, Face: newFace}
+ l.playerTurn = true
+
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("System broadcasts: %d %s.\n", newQty, faceNamePlural(newFace)))
+ sb.WriteString("Your turn: bid higher, call, or exact.\n")
+ return sb.String(), false, false
+}
+
+func (l *LiarsDiceGame) aiBestFace() (face int, count int) {
+ counts := make(map[int]int)
+ for _, d := range l.aiDice {
+ if d == 1 {
+ continue
+ }
+ counts[d]++
+ }
+ wilds := 0
+ for _, d := range l.aiDice {
+ if d == 1 {
+ wilds++
+ }
+ }
+ best := 2
+ bestN := counts[2] + wilds
+ for f := 3; f <= 6; f++ {
+ n := counts[f] + wilds
+ if n > bestN || (n == bestN && f > best) {
+ best = f
+ bestN = n
+ }
+ }
+ return best, bestN
+}
+
+func (l *LiarsDiceGame) countFace(face int) int {
+ count := 0
+ for _, d := range l.playerDice {
+ if d == face || d == 1 {
+ count++
+ }
+ }
+ for _, d := range l.aiDice {
+ if d == face || d == 1 {
+ count++
+ }
+ }
+ return count
+}
+
+func (l *LiarsDiceGame) countFaceNonWild(face int) int {
+ count := 0
+ for _, d := range l.playerDice {
+ if d == face {
+ count++
+ }
+ }
+ for _, d := range l.aiDice {
+ if d == face {
+ count++
+ }
+ }
+ return count
+}
+
+func faceName(f int) string {
+ names := []string{"", "one", "two", "three", "four", "five", "six"}
+ if f >= 1 && f <= 6 {
+ return names[f]
+ }
+ return fmt.Sprint(f)
+}
+
+func faceNamePlural(f int) string {
+ names := []string{"", "ones", "twos", "threes", "fours", "fives", "sixes"}
+ if f >= 1 && f <= 6 {
+ return names[f]
+ }
+ return fmt.Sprint(f)
+}
+
+func (l *LiarsDiceGame) BonusXP() int {
+ if l.completed && l.won && l.playerLost == 0 {
+ return 150
+ }
+ return 0
+}
diff --git a/internal/game/hacking_mastermind.go b/internal/game/hacking_mastermind.go
new file mode 100644
index 0000000..3ab7cca
--- /dev/null
+++ b/internal/game/hacking_mastermind.go
@@ -0,0 +1,177 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+)
+
+type MastermindGame struct {
+ code [4]int
+ guesses []MastermindGuess
+ maxGuess int
+ gameOver bool
+ won bool
+}
+
+type MastermindGuess struct {
+ Digits [4]int
+ Exact int
+ Partial int
+}
+
+func NewMastermindGame() *MastermindGame {
+ return &MastermindGame{
+ maxGuess: 10,
+ }
+}
+
+func (m *MastermindGame) Name() string {
+ return "Code Breaker"
+}
+
+func (m *MastermindGame) Init(level int) string {
+ pool := []int{1, 2, 3, 4, 5, 6}
+ rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
+ copy(m.code[:], pool[:4])
+ m.guesses = nil
+ m.gameOver = false
+ m.won = false
+
+ var sb strings.Builder
+ sb.WriteString("=== CODE BREAKER ===\n")
+ sb.WriteString("\n")
+ sb.WriteString("The encryption module is running a 4-digit cipher using symbols 1-6.\n")
+ sb.WriteString("No symbol repeats. You have 10 attempts to crack the code.\n")
+ sb.WriteString("\n")
+ sb.WriteString("After each guess, you'll see:\n")
+ sb.WriteString(" {40}[X]{/} = correct symbol in correct position\n")
+ sb.WriteString(" {226}[O]{/} = correct symbol in wrong position\n")
+ sb.WriteString(" [ ] = symbol not in code\n")
+ sb.WriteString("\n")
+ sb.WriteString("Commands:\n")
+ sb.WriteString(" <4 digits> - Guess the code (e.g., 1234)\n")
+ sb.WriteString(" status - Show previous guesses\n")
+ sb.WriteString(" jack out - Disconnect (forfeit)\n")
+ return sb.String()
+}
+
+func (m *MastermindGame) HandleInput(input string) (string, bool, bool) {
+ input = strings.TrimSpace(input)
+ lower := strings.ToLower(strings.TrimSpace(input))
+
+ if lower == "status" {
+ return m.doStatus(), false, false
+ }
+
+ if len(input) != 4 {
+ return "Enter exactly 4 digits (1-6, no repeats).", false, false
+ }
+
+ var guess [4]int
+ seen := make(map[int]bool)
+ for i, ch := range input {
+ if ch < '1' || ch > '6' {
+ return "Each digit must be 1-6.", false, false
+ }
+ d := int(ch - '0')
+ if seen[d] {
+ return "No repeating digits allowed.", false, false
+ }
+ seen[d] = true
+ guess[i] = d
+ }
+
+ exact := 0
+ partial := 0
+ for i := 0; i < 4; i++ {
+ if guess[i] == m.code[i] {
+ exact++
+ } else {
+ for j := 0; j < 4; j++ {
+ if guess[i] == m.code[j] {
+ partial++
+ break
+ }
+ }
+ }
+ }
+
+ m.guesses = append(m.guesses, MastermindGuess{
+ Digits: guess,
+ Exact: exact,
+ Partial: partial,
+ })
+
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Attempt %d/%d: ", len(m.guesses), m.maxGuess))
+ for i, d := range guess {
+ match := "[ ]"
+ if guess[i] == m.code[i] {
+ match = "{40}[X]{/}"
+ } else {
+ for j := 0; j < 4; j++ {
+ if guess[i] == m.code[j] {
+ match = "{226}[O]{/}"
+ break
+ }
+ }
+ }
+ sb.WriteString(fmt.Sprintf("%d%s ", d, match))
+ }
+ sb.WriteString(fmt.Sprintf(" (%d locked, %d found)", exact, partial))
+
+ if exact == 4 {
+ m.gameOver = true
+ m.won = true
+ sb.WriteString("\n\nCode cracked! The encryption module yields.\nConnection terminated.")
+ return sb.String(), true, true
+ }
+
+ if len(m.guesses) >= m.maxGuess {
+ m.gameOver = true
+ m.won = false
+ sb.WriteString(fmt.Sprintf("\n\nOut of attempts. The code was: %d %d %d %d.\nConnection terminated.",
+ m.code[0], m.code[1], m.code[2], m.code[3]))
+ return sb.String(), true, false
+ }
+
+ return sb.String(), false, false
+}
+
+func (m *MastermindGame) doStatus() string {
+ if len(m.guesses) == 0 {
+ return fmt.Sprintf("No guesses yet. Remaining: %d/%d", m.maxGuess, m.maxGuess)
+ }
+ var sb strings.Builder
+ sb.WriteString("=== Attempts ===\n")
+ for i, g := range m.guesses {
+ sb.WriteString(fmt.Sprintf(" %2d: %d %d %d %d -> ", i+1, g.Digits[0], g.Digits[1], g.Digits[2], g.Digits[3]))
+ for j, d := range g.Digits {
+ match := "[ ]"
+ if g.Digits[j] == m.code[j] {
+ match = "{40}[X]{/}"
+ } else {
+ found := false
+ for k := 0; k < 4; k++ {
+ if g.Digits[j] == m.code[k] {
+ found = true
+ break
+ }
+ }
+ if found {
+ match = "{226}[O]{/}"
+ }
+ }
+ _ = d
+ sb.WriteString(match)
+ }
+ sb.WriteString(fmt.Sprintf(" (%d locked, %d found)\n", g.Exact, g.Partial))
+ }
+ sb.WriteString(fmt.Sprintf("Remaining: %d/%d\n", m.maxGuess-len(m.guesses), m.maxGuess))
+ return sb.String()
+}
+
+func (m *MastermindGame) BonusXP() int {
+ return (m.maxGuess - len(m.guesses)) * 15
+}
diff --git a/internal/game/hacking_wumpus.go b/internal/game/hacking_wumpus.go
new file mode 100644
index 0000000..600b1be
--- /dev/null
+++ b/internal/game/hacking_wumpus.go
@@ -0,0 +1,286 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+ "strconv"
+ "strings"
+)
+
+var dodecahedron = [20][3]int{
+ {1, 4, 7},
+ {0, 2, 9},
+ {1, 3, 11},
+ {2, 4, 13},
+ {0, 3, 5},
+ {4, 6, 14},
+ {5, 7, 16},
+ {0, 6, 8},
+ {7, 9, 17},
+ {1, 8, 10},
+ {9, 11, 18},
+ {2, 10, 12},
+ {11, 13, 19},
+ {3, 12, 14},
+ {5, 13, 15},
+ {14, 16, 19},
+ {6, 15, 17},
+ {8, 16, 18},
+ {10, 17, 19},
+ {12, 15, 18},
+}
+
+type WumpusGame struct {
+ player int
+ wumpus int
+ pits [2]int
+ ice [2]int
+ probes int
+ gameOver bool
+ won bool
+}
+
+func NewWumpusGame() *WumpusGame {
+ return &WumpusGame{}
+}
+
+func (w *WumpusGame) Name() string {
+ return "Hunt the Wumpus"
+}
+
+func (w *WumpusGame) Init(level int) string {
+ taken := make(map[int]bool)
+ w.player = rand.Intn(20)
+ taken[w.player] = true
+
+ w.wumpus = randFree(taken)
+ taken[w.wumpus] = true
+
+ w.pits[0] = randFree(taken)
+ taken[w.pits[0]] = true
+ w.pits[1] = randFree(taken)
+ taken[w.pits[1]] = true
+
+ w.ice[0] = randFree(taken)
+ taken[w.ice[0]] = true
+ w.ice[1] = randFree(taken)
+
+ w.probes = 5
+ w.gameOver = false
+ w.won = false
+
+ var sb strings.Builder
+ sb.WriteString("=== HUNT THE ROGUE AI ===\n")
+ sb.WriteString("\n")
+ sb.WriteString("You've jacked into an abandoned network. Somewhere in this maze of\n")
+ sb.WriteString("20 nodes, a rogue AI lurks. You have 5 probes to find and neutralize it.\n")
+ sb.WriteString("\n")
+ sb.WriteString("Hazards:\n")
+ sb.WriteString(" - Rogue AI: Moves to an adjacent node if you enter its node. Kills you.\n")
+ sb.WriteString(" - Data Traps: Fall in and you're fried. 2 in the network.\n")
+ sb.WriteString(" - ICE: Grabs you and dumps you in a random node. 2 in the network.\n")
+ sb.WriteString("\n")
+ sb.WriteString("Commands:\n")
+ sb.WriteString(" move <node> - Move to an adjacent node (1-20)\n")
+ sb.WriteString(" shoot <node> - Launch a probe into an adjacent node (1-20)\n")
+ sb.WriteString(" status - Show your current status\n")
+ sb.WriteString(" map - Show network map\n")
+ sb.WriteString(" jack out - Disconnect (forfeit)\n")
+ sb.WriteString("\n")
+ sb.WriteString(w.roomDesc())
+ return sb.String()
+}
+
+func (w *WumpusGame) HandleInput(input string) (string, bool, bool) {
+ input = strings.TrimSpace(strings.ToLower(input))
+ parts := strings.Fields(input)
+
+ if len(parts) == 0 {
+ return "Unknown command.", false, false
+ }
+
+ cmd := parts[0]
+
+ switch cmd {
+ case "move", "m":
+ if len(parts) < 2 {
+ return "Move where? (e.g., move 3)", false, false
+ }
+ n, err := strconv.Atoi(parts[1])
+ if err != nil || n < 1 || n > 20 {
+ return "Invalid node. Use 1-20.", false, false
+ }
+ return w.doMove(n - 1)
+ case "shoot", "probe", "launch", "s":
+ if len(parts) < 2 {
+ return "Shoot where? (e.g., shoot 3)", false, false
+ }
+ n, err := strconv.Atoi(parts[1])
+ if err != nil || n < 1 || n > 20 {
+ return "Invalid node. Use 1-20.", false, false
+ }
+ return w.doShoot(n - 1)
+ case "status":
+ return w.doStatus(), false, false
+ case "map":
+ return w.doMap(), false, false
+ default:
+ n, err := strconv.Atoi(cmd)
+ if err == nil && n >= 1 && n <= 20 {
+ return w.doMove(n - 1)
+ }
+ return "Commands: move <N>, shoot <N>, status, map, jack out", false, false
+ }
+}
+
+func (w *WumpusGame) doMove(target int) (string, bool, bool) {
+ if !w.hasAdjacent(w.player, target) {
+ adj := w.adjStrings(w.player)
+ return fmt.Sprintf("Can't move there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false
+ }
+ w.player = target
+ return w.checkRoom()
+}
+
+func (w *WumpusGame) doShoot(target int) (string, bool, bool) {
+ if !w.hasAdjacent(w.player, target) {
+ adj := w.adjStrings(w.player)
+ return fmt.Sprintf("Can't shoot there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false
+ }
+ w.probes--
+ if target == w.wumpus {
+ w.gameOver = true
+ w.won = true
+ return "Your probe hits the rogue AI! It destabilizes and crashes.\n\nConnection terminated.", true, true
+ }
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Your probe finds nothing in node %d.", target+1))
+ if w.probes == 0 {
+ sb.WriteString("\nYou're out of probes. The rogue AI detects your presence and terminates your connection.")
+ w.gameOver = true
+ w.won = false
+ return sb.String(), true, false
+ }
+ if rand.Float64() < 0.75 {
+ w.wumpus = w.randomAdjacent(w.wumpus)
+ sb.WriteString("\nYou hear something shift in the network...")
+ }
+ sb.WriteString("\n")
+ sb.WriteString(w.roomDesc())
+ return sb.String(), false, false
+}
+
+func (w *WumpusGame) checkRoom() (string, bool, bool) {
+ var sb strings.Builder
+ cur := w.player
+ if cur == w.wumpus {
+ if rand.Float64() < 0.75 {
+ newRoom := w.randomAdjacent(w.wumpus)
+ w.wumpus = newRoom
+ sb.WriteString("--- Node " + fmt.Sprint(cur+1) + " ---\n")
+ sb.WriteString("The rogue AI stirs and relocates to a nearby node...\n")
+ sb.WriteString("\n")
+ sb.WriteString(w.roomDesc())
+ return sb.String(), false, false
+ }
+ sb.WriteString("The rogue AI devours your connection!\n\nConnection terminated.")
+ w.gameOver = true
+ w.won = false
+ return sb.String(), true, false
+ }
+ for _, p := range w.pits {
+ if cur == p {
+ sb.WriteString("You've fallen into a data trap! Your connection is severed.")
+ w.gameOver = true
+ w.won = false
+ return sb.String(), true, false
+ }
+ }
+ for _, i := range w.ice {
+ if cur == i {
+ sb.WriteString("ICE detected! You're relocated to a random node...\n")
+ w.player = rand.Intn(20)
+ chained, _, _ := w.checkRoom()
+ sb.WriteString(chained)
+ return sb.String(), false, false
+ }
+ }
+ sb.WriteString(w.roomDesc())
+ return sb.String(), false, false
+}
+
+func (w *WumpusGame) roomDesc() string {
+ cur := w.player
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("--- Node %d ---\n", cur+1))
+ adj := w.adjStrings(cur)
+ sb.WriteString(fmt.Sprintf("Tunnels lead to: %s\n", strings.Join(adj, ", ")))
+
+ for _, a := range dodecahedron[cur] {
+ if a == w.wumpus {
+ sb.WriteString("{196}You detect corrupted data nearby...{/}\n")
+ }
+ for _, p := range w.pits {
+ if a == p {
+ sb.WriteString("{208}You sense a void in the network...{/}\n")
+ }
+ }
+ for _, i := range w.ice {
+ if a == i {
+ sb.WriteString("{226}You hear static crackling...{/}\n")
+ }
+ }
+ }
+ return sb.String()
+}
+
+func (w *WumpusGame) doStatus() string {
+ return fmt.Sprintf("Node: %d | Probes: %d/5", w.player+1, w.probes)
+}
+
+func (w *WumpusGame) doMap() string {
+ var sb strings.Builder
+ sb.WriteString("=== Network Map ===\n")
+ for i := 0; i < 20; i++ {
+ marker := " "
+ if i == w.player {
+ marker = "*"
+ }
+ adj := dodecahedron[i]
+ sb.WriteString(fmt.Sprintf(" [%s] Node %2d -> %2d, %2d, %2d\n",
+ marker, i+1, adj[0]+1, adj[1]+1, adj[2]+1))
+ }
+ sb.WriteString(fmt.Sprintf("\nYou are at Node %d.\n", w.player+1))
+ return sb.String()
+}
+
+func (w *WumpusGame) hasAdjacent(from, to int) bool {
+ for _, a := range dodecahedron[from] {
+ if a == to {
+ return true
+ }
+ }
+ return false
+}
+
+func (w *WumpusGame) randomAdjacent(r int) int {
+ adj := dodecahedron[r]
+ return adj[rand.Intn(len(adj))]
+}
+
+func (w *WumpusGame) adjStrings(r int) []string {
+ var out []string
+ for _, a := range dodecahedron[r] {
+ out = append(out, fmt.Sprint(a+1))
+ }
+ return out
+}
+
+func randFree(taken map[int]bool) int {
+ for {
+ r := rand.Intn(20)
+ if !taken[r] {
+ return r
+ }
+ }
+}
diff --git a/internal/game/science.go b/internal/game/science.go
index 38cb138..bf9a961 100644
--- a/internal/game/science.go
+++ b/internal/game/science.go
@@ -275,6 +275,9 @@ func init() {
{ID: "superheat", Name: "Superheat Item", Level: 43, MaxHit: 0, BaseXP: 53.0,
JunkCost: map[string]int{"naturejunk": 4, "solarjunk": 1, "scrap_metal": 1},
Category: ModUtility, Element: "", TargetType: "inventory"},
+ {ID: "plank_make", Name: "Plank Make", Level: 86, MaxHit: 0, BaseXP: 90.0,
+ JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModUtility, Element: "", TargetType: "inventory"},
{ID: "transport_town", Name: "Transport: Town Square", Level: 25, MaxHit: 0, BaseXP: 27.0,
JunkCost: map[string]int{"lawjunk": 1, "solarjunk": 1, "biojunk": 1, "scrap_metal": 1},
diff --git a/internal/net/server.go b/internal/net/server.go
index 7c1b79a..7c7a18c 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -39,6 +39,8 @@ const (
StateProductChoice
StateColorChoice
StateShop
+ StateBank
+ StateHacking
)
type Session struct {
diff --git a/internal/object/item.go b/internal/object/item.go
index 7aed6c2..4959a22 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -67,6 +67,9 @@ type ItemDef struct {
FarmProduct string `yaml:"farm_product"`
FarmMinYield int `yaml:"farm_min_yield"`
FarmMaxYield int `yaml:"farm_max_yield"`
+ PotionEffect string `yaml:"potion_effect"`
+ PotionBonus int `yaml:"potion_bonus"`
+ PotionDuration float64 `yaml:"potion_duration"`
}
type MadeFromEntry struct {
diff --git a/internal/player/player.go b/internal/player/player.go
index a3ca4ce..88cf7f8 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -136,6 +136,7 @@ var OptionDefs = []OptionDef{
{"cook_all", OptBool, false, nil, "Auto-start cooking when only one product is possible"},
{"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"},
}
var optionByName map[string]*OptionDef
@@ -155,6 +156,7 @@ type Player struct {
Name string `yaml:"name"`
Skills map[SkillName]int `yaml:"skills"`
Inventory map[int]*InventorySlot `yaml:"inventory"`
+ Bank map[int]*InventorySlot `yaml:"bank"`
Equipment map[object.EquipSlot]string `yaml:"equipment"`
EquipQuality map[string]int `yaml:"equip_quality,omitempty"`
RoomID int `yaml:"room_id"`
@@ -184,6 +186,13 @@ type Player struct {
TechActivatedSinceTick map[string]bool `yaml:"-"`
Sneaking bool `yaml:"-"`
SneakNotified map[string]bool `yaml:"-"`
+ ActiveBuffs []PotionBuff `yaml:"-"`
+}
+
+type PotionBuff struct {
+ Stat string
+ BonusPercent int
+ TicksLeft int
}
func (p *Player) ClearMoveState() {
@@ -289,6 +298,7 @@ func New(name string) *Player {
p := &Player{
Name: name,
Skills: make(map[SkillName]int),
+ Bank: make(map[int]*InventorySlot),
Equipment: make(map[object.EquipSlot]string),
AttackStyle: Accurate,
Prompt: "> ",
@@ -421,6 +431,53 @@ func (p *Player) DeactivateAllTechs() {
p.TechActivatedSinceTick = nil
}
+func (p *Player) BankSlot(i int) *InventorySlot {
+ if p.Bank == nil {
+ return nil
+ }
+ return p.Bank[i]
+}
+
+func (p *Player) SetBankSlot(i int, slot *InventorySlot) {
+ if p.Bank == nil {
+ p.Bank = make(map[int]*InventorySlot)
+ }
+ if slot == nil {
+ delete(p.Bank, i)
+ } else {
+ p.Bank[i] = slot
+ }
+}
+
+func (p *Player) NextBankSlot() int {
+ if p.Bank == nil {
+ return 0
+ }
+ i := 0
+ for {
+ if p.Bank[i] == nil {
+ return i
+ }
+ i++
+ }
+}
+
+func (p *Player) BankSlots() []int {
+ if p.Bank == nil {
+ return nil
+ }
+ keys := make([]int, 0, len(p.Bank))
+ for k := range p.Bank {
+ keys = append(keys, k)
+ }
+ sort.Ints(keys)
+ return keys
+}
+
+func (p *Player) BankCount() int {
+ return len(p.Bank)
+}
+
func (p *Player) ActiveTechList() []string {
var result []string
for id := range p.ActiveTechs {
diff --git a/internal/world/world.go b/internal/world/world.go
index 9bed76e..f918293 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -307,7 +307,11 @@ func (w *World) LoadRoom(id int) (*Room, error) {
path := filepath.Join(w.dataDir, "rooms", fmt.Sprintf("%d.yaml", id))
data, err := os.ReadFile(path)
if err != nil {
- return nil, fmt.Errorf("read room %d: %w", id, err)
+ path = filepath.Join(w.dataDir, "rooms", "player_housing", fmt.Sprintf("%d.yaml", id))
+ data, err = os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read room %d: %w", id, err)
+ }
}
var room Room
if err := yaml.Unmarshal(data, &room); err != nil {