From e4400c5f1e84c18d2126f2cb502ae10685977cbb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 20 Jun 2026 03:20:01 -0400 Subject: refactor: combined all station crafting --- internal/action/recipe.go | 107 +++++++- internal/color/color.go | 12 +- internal/config/config.go | 6 +- internal/game/action.go | 4 +- internal/game/action_agility.go | 8 +- internal/game/action_burn.go | 48 ++-- internal/game/action_clean.go | 6 +- internal/game/action_default_target.go | 2 +- internal/game/action_farm.go | 10 +- internal/game/action_fletch.go | 20 +- internal/game/action_gather.go | 78 +----- internal/game/action_search.go | 8 +- internal/game/action_steal.go | 8 +- internal/game/action_use.go | 8 +- internal/game/cmd_agility.go | 2 +- internal/game/cmd_attack.go | 14 +- internal/game/cmd_clean.go | 11 +- internal/game/cmd_consume.go | 8 +- internal/game/cmd_cook.go | 59 +---- internal/game/cmd_craft.go | 34 +-- internal/game/cmd_description.go | 4 +- internal/game/cmd_drop.go | 2 +- internal/game/cmd_fletch.go | 2 +- internal/game/cmd_get.go | 22 +- internal/game/cmd_jack.go | 10 +- internal/game/cmd_look.go | 4 +- internal/game/cmd_mix.go | 108 +------- internal/game/cmd_quit.go | 10 +- internal/game/cmd_registry.go | 4 +- internal/game/cmd_smelt.go | 52 +--- internal/game/cmd_smith.go | 4 +- internal/game/cmd_trigger_combat.go | 2 +- internal/game/cmd_trigger_enchant.go | 18 +- internal/game/cmd_trigger_transport.go | 9 +- internal/game/cmd_trigger_utility.go | 71 +---- internal/game/cmd_use.go | 24 +- internal/game/core_login_account.go | 6 +- internal/game/core_messages.go | 13 - internal/game/core_production.go | 142 +++++----- internal/game/core_startup.go | 65 +++-- internal/game/core_types.go | 2 +- internal/game/core_utils.go | 16 ++ internal/game/game.go | 16 +- internal/game/hacking/hacking_liars_dice.go | 395 ---------------------------- internal/game/hacking/hacking_mastermind.go | 177 ------------- internal/game/hacking/hacking_wumpus.go | 286 -------------------- internal/game/hacking/liars_dice.go | 395 ++++++++++++++++++++++++++++ internal/game/hacking/mastermind.go | 177 +++++++++++++ internal/game/hacking/wumpus.go | 286 ++++++++++++++++++++ internal/game/sys_combat.go | 6 +- internal/game/sys_science.go | 32 ++- internal/net/server.go | 4 +- internal/player/validate.go | 4 +- internal/world/ground.go | 10 +- internal/world/objects.go | 4 +- internal/world/room.go | 4 +- internal/world/world.go | 2 +- 57 files changed, 1280 insertions(+), 1561 deletions(-) delete mode 100644 internal/game/core_messages.go delete mode 100644 internal/game/hacking/hacking_liars_dice.go delete mode 100644 internal/game/hacking/hacking_mastermind.go delete mode 100644 internal/game/hacking/hacking_wumpus.go create mode 100644 internal/game/hacking/liars_dice.go create mode 100644 internal/game/hacking/mastermind.go create mode 100644 internal/game/hacking/wumpus.go (limited to 'internal') diff --git a/internal/action/recipe.go b/internal/action/recipe.go index 5a69195..8779d53 100644 --- a/internal/action/recipe.go +++ b/internal/action/recipe.go @@ -1,6 +1,8 @@ package action import ( + "fmt" + "os" "path/filepath" "gopkg.in/yaml.v3" @@ -30,13 +32,68 @@ type RecipeDef struct { Success *SuccessFormula `yaml:"success"` } +type consolidatedRecipeDef struct { + Type string `yaml:"type"` + Skill string `yaml:"skill"` + Station []string `yaml:"station"` + Tool string `yaml:"tool"` + Wait float64 `yaml:"wait"` + Consume []ConsumeEntry `yaml:"consume"` + Products []recipeProduct `yaml:"products"` +} + +type recipeProduct struct { + ID string `yaml:"id"` + Output string `yaml:"output"` + OutputQty int `yaml:"output_qty"` + Level int `yaml:"level"` + XP int `yaml:"xp"` + Consume []ConsumeEntry `yaml:"consume"` + Message string `yaml:"message"` + FailMessage string `yaml:"fail_message"` + Fail string `yaml:"fail"` + Success *SuccessFormula `yaml:"success"` +} + type RecipeStore struct { dataDir string cache map[string][]RecipeDef + byID map[string]*RecipeDef } func NewRecipeStore(dataDir string) *RecipeStore { - return &RecipeStore{dataDir: dataDir, cache: make(map[string][]RecipeDef)} + return &RecipeStore{ + dataDir: dataDir, + cache: make(map[string][]RecipeDef), + byID: make(map[string]*RecipeDef), + } +} + +func (s *RecipeStore) GetByID(recipeID string) *RecipeDef { + if r, ok := s.byID[recipeID]; ok { + return r + } + s.ensureLoaded() + if r, ok := s.byID[recipeID]; ok { + return r + } + return nil +} + +func (s *RecipeStore) ensureLoaded() { + if _, ok := s.cache["_all"]; ok { + return + } + all, err := s.loadAllRaw() + if err != nil { + return + } + s.cache["_all"] = all + for i := range all { + if all[i].ID != "" { + s.byID[all[i].ID] = &all[i] + } + } } func (s *RecipeStore) LoadAll() ([]RecipeDef, error) { @@ -53,13 +110,10 @@ func (s *RecipeStore) LoadByType(recipeType string) ([]RecipeDef, error) { return result, nil } - all, err := s.loadAllRaw() - if err != nil { - return nil, err - } + s.ensureLoaded() + all := s.cache["_all"] if recipeType == "_all" { - s.cache[recipeType] = all result := make([]RecipeDef, len(all)) copy(result, all) return result, nil @@ -82,15 +136,52 @@ func (s *RecipeStore) loadAllRaw() ([]RecipeDef, error) { var recipes []RecipeDef WalkYAMLDir(dir, func(path, id string, data []byte) error { var r RecipeDef - if err := yaml.Unmarshal(data, &r); err != nil { + if err := yaml.Unmarshal(data, &r); err == nil && r.Type != "" { + recipes = append(recipes, r) return nil } - recipes = append(recipes, r) + + var cr consolidatedRecipeDef + if err := yaml.Unmarshal(data, &cr); err != nil { + fmt.Fprintf(os.Stderr, "recipe parse error %s: %v\n", path, err) + return nil + } + if cr.Type == "" || len(cr.Products) == 0 { + return nil + } + + for _, p := range cr.Products { + recipes = append(recipes, s.expandProduct(cr, p)) + } return nil }) return recipes, nil } +func (s *RecipeStore) expandProduct(cr consolidatedRecipeDef, p recipeProduct) RecipeDef { + r := RecipeDef{ + ID: p.ID, + Output: p.Output, + OutputQty: p.OutputQty, + Type: cr.Type, + Skill: cr.Skill, + Level: p.Level, + XP: p.XP, + Station: cr.Station, + Tool: cr.Tool, + Wait: cr.Wait, + Consume: p.Consume, + Message: p.Message, + FailMessage: p.FailMessage, + Fail: p.Fail, + Success: p.Success, + } + if len(r.Consume) == 0 { + r.Consume = cr.Consume + } + return r +} + func (r *RecipeDef) MatchesEntry(itemID string) bool { for _, e := range r.Consume { for _, id := range e.Items { diff --git a/internal/color/color.go b/internal/color/color.go index 9496a1d..58c040c 100644 --- a/internal/color/color.go +++ b/internal/color/color.go @@ -87,7 +87,7 @@ func nearestANSIBg(index int) int { return fg - 30 + 40 } -func nearestCubeComponent(v int) int { +func nearestCube(v int) int { if v < 48 { return 0 } @@ -107,9 +107,9 @@ func nearestCubeComponent(v int) int { } func NearestXterm256(r, g, b int) int { - ri := nearestCubeComponent(r) - gi := nearestCubeComponent(g) - bi := nearestCubeComponent(b) + ri := nearestCube(r) + gi := nearestCube(g) + bi := nearestCube(b) cubeIdx := 16 + ri*36 + gi*6 + bi cubeDist := colorDist(r, g, b, cubeValues[ri], cubeValues[gi], cubeValues[bi]) @@ -325,10 +325,10 @@ func renderGradient(mode string, spec ColorSpec, text string) string { } func ExpandTags(mode string, text string) string { - return ExpandTagsWithDefault(mode, NoColor(), text) + return ExpandTagsDefault(mode, NoColor(), text) } -func ExpandTagsWithDefault(mode string, def ColorSpec, text string) string { +func ExpandTagsDefault(mode string, def ColorSpec, text string) string { if !strings.Contains(text, "{") { if !def.Empty() { return Render(mode, def, text) diff --git a/internal/config/config.go b/internal/config/config.go index 124d45d..cbf15c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,10 +61,10 @@ func DefaultColors() ColorsConfig { type GameConfig struct { TickLength int `yaml:"tick_length"` - StartupValidation StartupValidationConfig `yaml:"startup_validation"` + StartupValidation ValidationConfig `yaml:"startup_validation"` } -type StartupValidationConfig struct { +type ValidationConfig struct { CheckSources []int `yaml:"check_sources"` IgnoreUnreachable []int `yaml:"ignore_unreachable"` } @@ -97,7 +97,7 @@ func Default() *Config { return &Config{ Game: GameConfig{ TickLength: 600, - StartupValidation: StartupValidationConfig{ + StartupValidation: ValidationConfig{ CheckSources: []int{1}, IgnoreUnreachable: []int{}, }, diff --git a/internal/game/action.go b/internal/game/action.go index f25cabc..2f5dbaf 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -57,7 +57,7 @@ func (g *Game) startAction(sess *net.Session, verb, target string) { if p.Action != nil { g.cancelAction(p) } - g.cancelBackgroundAction(p) + g.cancelBgAction(p) lower := strings.ToLower(target) instanceIdx := -1 @@ -206,7 +206,7 @@ func (g *Game) cancelAction(p *player.Player) { p.ClearMoveState() } -func (g *Game) cancelBackgroundAction(p *player.Player) { +func (g *Game) cancelBgAction(p *player.Player) { p.BackgroundAction = nil p.BackgroundActionState = nil } diff --git a/internal/game/action_agility.go b/internal/game/action_agility.go index 37fbab9..43c9cfd 100644 --- a/internal/game/action_agility.go +++ b/internal/game/action_agility.go @@ -52,13 +52,7 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle 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))) - } - } - } + g.broadcastAction(sess, "\n%s starts %s on the %s.", p.Name, gerund, info.CourseName) } func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index c31d7d5..71b5a35 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -28,7 +28,7 @@ func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) { if p.Action != nil { g.cancelAction(p) } - g.cancelBackgroundAction(p) + g.cancelBgAction(p) itemID, fromGround, ambiguous := g.resolveBurnTarget(p, input) if ambiguous { @@ -47,7 +47,7 @@ func (g *Game) doStoke(sess *net.Session, p *player.Player, input string) { if p.Action != nil { g.cancelAction(p) } - g.cancelBackgroundAction(p) + g.cancelBgAction(p) if !g.hasFireObject(p.RoomID) { sess.WriteLine("There's no fire here to stoke.") @@ -120,13 +120,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s starts building a fire.", p.Name) p.Action = &action.Action{ Type: "burn", @@ -166,13 +160,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s tends to the fire.", p.Name) p.Action = &action.Action{ Type: "stoke", @@ -260,11 +248,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { } sess.WriteLine(msg) - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess { - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s started a fire!", p.Name))) - } - } + g.broadcastAction(sess, "\n%s started a fire!", p.Name) return } @@ -458,12 +442,12 @@ func (g *Game) cancelStokers(roomID int) { func (g *Game) resolveBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) { if input != "" { - return g.resolveNamedBurnTarget(p, input) + return g.namedBurnTarget(p, input) } - return g.resolveDefaultBurnTarget(p) + return g.defaultBurnTarget(p) } -func (g *Game) collectBurnableGround(roomID int, name string) []string { +func (g *Game) burnableGround(roomID int, name string) []string { var out []string for itemID := range g.World.GroundItems(roomID) { def, err := g.ItemStore.Load(itemID) @@ -477,7 +461,7 @@ func (g *Game) collectBurnableGround(roomID int, name string) []string { return out } -func (g *Game) collectBurnableInv(p *player.Player, name string) []string { +func (g *Game) burnableInv(p *player.Player, name string) []string { var out []string seen := make(map[string]bool) for _, slot := range p.Inventory { @@ -496,8 +480,8 @@ func (g *Game) collectBurnableInv(p *player.Player, name string) []string { return out } -func (g *Game) resolveNamedBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) { - groundMatches := g.collectBurnableGround(p.RoomID, input) +func (g *Game) namedBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) { + groundMatches := g.burnableGround(p.RoomID, input) if len(groundMatches) > 1 { return "", false, true } @@ -505,7 +489,7 @@ func (g *Game) resolveNamedBurnTarget(p *player.Player, input string) (itemID st return groundMatches[0], true, false } - invMatches := g.collectBurnableInv(p, input) + invMatches := g.burnableInv(p, input) if len(invMatches) > 1 { return "", false, true } @@ -516,8 +500,8 @@ func (g *Game) resolveNamedBurnTarget(p *player.Player, input string) (itemID st return "", false, false } -func (g *Game) resolveDefaultBurnTarget(p *player.Player) (itemID string, fromGround bool, ambiguous bool) { - groundBurnable := g.collectBurnableGround(p.RoomID, "") +func (g *Game) defaultBurnTarget(p *player.Player) (itemID string, fromGround bool, ambiguous bool) { + groundBurnable := g.burnableGround(p.RoomID, "") if len(groundBurnable) == 1 { return groundBurnable[0], true, false } @@ -525,7 +509,7 @@ func (g *Game) resolveDefaultBurnTarget(p *player.Player) (itemID string, fromGr return "", false, true } - invBurnable := g.collectBurnableInv(p, "") + invBurnable := g.burnableInv(p, "") if len(invBurnable) == 1 { return invBurnable[0], false, false } @@ -537,7 +521,7 @@ func (g *Game) resolveDefaultBurnTarget(p *player.Player) (itemID string, fromGr } func (g *Game) resolveStokeTarget(p *player.Player, input string) (string, bool) { - invBurnable := g.collectBurnableInv(p, input) + invBurnable := g.burnableInv(p, input) if len(invBurnable) == 1 { return invBurnable[0], false } diff --git a/internal/game/action_clean.go b/internal/game/action_clean.go index 104dcb0..7330883 100644 --- a/internal/game/action_clean.go +++ b/internal/game/action_clean.go @@ -21,7 +21,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { allRecipes, err := g.RecipeStore.LoadAll() if err != nil { - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } @@ -51,7 +51,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { if recipe == nil { sess.WriteLine("\nYou've finished cleaning herbs.") - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } @@ -105,7 +105,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { if !hasMore { sess.WriteLine("\nYou've finished cleaning herbs.") - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } diff --git a/internal/game/action_default_target.go b/internal/game/action_default_target.go index 2ca0bd3..a184f32 100644 --- a/internal/game/action_default_target.go +++ b/internal/game/action_default_target.go @@ -8,7 +8,7 @@ func verbToSkill(verb string) string { return verbSkill[verb] } -func (g *Game) resolveDefaultTarget(roomID int, verb string) string { +func (g *Game) defaultTarget(roomID int, verb string) string { skill := verbToSkill(verb) if skill == "" { return "" diff --git a/internal/game/action_farm.go b/internal/game/action_farm.go index d79150f..21604aa 100644 --- a/internal/game/action_farm.go +++ b/internal/game/action_farm.go @@ -51,7 +51,7 @@ func (g *Game) ensureFarmState(p *player.Player, prefix string) { } } -func (g *Game) findActiveFarmPrefixes(p *player.Player) []string { +func (g *Game) findFarmPrefixes(p *player.Player) []string { seen := make(map[string]bool) var prefixes []string for key := range p.Flags { @@ -94,7 +94,7 @@ func (g *Game) FarmTick() { } func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { - prefixes := g.findActiveFarmPrefixes(p) + prefixes := g.findFarmPrefixes(p) for _, prefix := range prefixes { seedID, _ := p.Flags[prefix+"_seed"].(string) if seedID == "" { @@ -492,7 +492,7 @@ func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, d return "", "", -1 } -func (g *Game) farmLookSuffixForObj(p *player.Player, defID string, index int) string { +func (g *Game) farmObjSuffix(p *player.Player, defID string, index int) string { if defID == "tool_shed" { return g.toolShedLookSuffix(p) } @@ -538,7 +538,7 @@ func (g *Game) showFarmPatches(sess *net.Session, p *player.Player) { g.ensureFarmState(p, prefix) idxStr := "" - if obj.Index > 0 || g.countFarmPatchesOfType(objInstances, obj.DefID) > 1 { + if obj.Index > 0 || g.countPatchesByType(objInstances, obj.DefID) > 1 { idxStr = fmt.Sprintf(" %d", obj.Index+1) } @@ -618,7 +618,7 @@ func (g *Game) showFarmPatches(sess *net.Session, p *player.Player) { } } -func (g *Game) countFarmPatchesOfType(instances []world.ObjState, defID string) int { +func (g *Game) countPatchesByType(instances []world.ObjState, defID string) int { count := 0 for _, obj := range instances { if obj.DefID == defID && farmPatchDefIDs[obj.DefID] { diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go index aa5dfc7..3d8c30f 100644 --- a/internal/game/action_fletch.go +++ b/internal/game/action_fletch.go @@ -115,13 +115,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s starts fletching.", p.Name) sess.WriteLine(fmt.Sprintf("\nYou begin fletching %s.", outputName)) } @@ -132,9 +126,9 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { wait, _ := p.BackgroundAction.Data["wait"].(float64) remaining, _ := p.BackgroundAction.Data["remaining"].(int) - recipe := g.loadProductionRecipe(recipeID) + recipe := g.loadRecipe(recipeID) if recipe == nil { - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } @@ -146,7 +140,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { if !g.canDoFletchRecipe(p, recipe) { sess.WriteLine("\nYou've run out of fletching materials.") - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } @@ -175,7 +169,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("\nYour inventory is too full!") - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) @@ -205,14 +199,14 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { p.BackgroundAction.Data["remaining"] = remaining if remaining <= 0 { sess.WriteLine("\nYou've finished fletching.") - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } } if !g.canContinueProduction(p, recipe) { sess.WriteLine("\nYou've run out of fletching materials.") - g.cancelBackgroundAction(p) + g.cancelBgAction(p) return } diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index e169beb..9b47de2 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -53,42 +53,11 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } wait := cfg.BaseWait + var toolName string if len(cfg.Tools) > 0 { - var toolSpeed float64 = -1 - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - def, err := g.ItemStore.Load(itemID) - if err == nil { - for _, t := range cfg.Tools { - if def.ToolType == t { - toolSpeed = def.ToolSpeed - break - } - } - } - } - if toolSpeed < 0 { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil { - continue - } - for _, t := range cfg.Tools { - if def.ToolType == t { - toolSpeed = def.ToolSpeed - break - } - } - if toolSpeed >= 0 { - break - } - } - } - if toolSpeed < 0 { + toolSpeed, name, found := g.findTool(p, cfg.Tools) + if !found { names := make([]string, len(cfg.Tools)) for i, t := range cfg.Tools { names[i] = strings.ReplaceAll(t, "_", " ") @@ -97,6 +66,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje sess.WriteLine(fmt.Sprintf("You need a %s to do that.", toolList)) return } + toolName = name wait = cfg.BaseWait - toolSpeed if wait < 1 { wait = 1 @@ -118,38 +88,6 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje sess.WriteLine("Your inventory is too full!") return } - - toolName := "" - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil { - for _, t := range cfg.Tools { - if def.ToolType == t { - toolName = def.Name - break - } - } - } - } - if toolName == "" { - for _, slot := range p.Inventory { - if slot == nil || slot.Quantity <= 0 { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil { - continue - } - for _, t := range cfg.Tools { - if def.ToolType == t { - toolName = def.Name - break - } - } - if toolName != "" { - break - } - } - } verb := cfg.Skill switch cfg.Skill { case "woodcutting": @@ -157,13 +95,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s starts %s the %s.", p.Name, verb, obj.Name) data := map[string]any{ "obj_def_id": obj.ID, diff --git a/internal/game/action_search.go b/internal/game/action_search.go index d7dabe0..5cb8644 100644 --- a/internal/game/action_search.go +++ b/internal/game/action_search.go @@ -35,13 +35,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\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_steal.go b/internal/game/action_steal.go index 3510ce7..a95be25 100644 --- a/internal/game/action_steal.go +++ b/internal/game/action_steal.go @@ -259,13 +259,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s glances around the room.", p.Name) p.Action = &action.Action{ Type: "steal", diff --git a/internal/game/action_use.go b/internal/game/action_use.go index 368162b..a3858a9 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -36,13 +36,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s uses the %s.", p.Name, obj.Name) p.Action = &action.Action{ Type: "use", diff --git a/internal/game/cmd_agility.go b/internal/game/cmd_agility.go index 084c5cf..e347322 100644 --- a/internal/game/cmd_agility.go +++ b/internal/game/cmd_agility.go @@ -31,7 +31,7 @@ func (g *Game) doObstacle(sess *net.Session, verb string) { if p.Action != nil { g.cancelAction(p) } - g.cancelBackgroundAction(p) + g.cancelBgAction(p) g.startObstacle(sess, p, info) } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index f9cfba2..89cde46 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -126,7 +126,7 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { g.cancelRest(p.Name) - g.cancelBackgroundAction(p) + g.cancelBgAction(p) combat.EnterCombat(p.Name, mob.InstanceID) p.AttackTimer = 0 @@ -162,13 +162,7 @@ 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.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true)) g.Ticks.Subscribe(1, func() bool { cs := combat.GetCombat(p.Name) @@ -567,7 +561,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } if mob.Drops.Remains != "" { - g.World.AddReservedGroundItem(p.RoomID, mob.Drops.Remains, 1, p.Name) + g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name) def, _ := g.ItemStore.Load(mob.Drops.Remains) name := mob.Drops.Remains if def != nil { @@ -588,7 +582,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst if qty <= 0 { qty = 1 } - g.World.AddReservedGroundItem(p.RoomID, entry.ItemID, qty, p.Name) + g.World.AddReservedItem(p.RoomID, entry.ItemID, qty, p.Name) def, _ := g.ItemStore.Load(entry.ItemID) name := entry.ItemID if def != nil { diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go index 47896bb..951b41d 100644 --- a/internal/game/cmd_clean.go +++ b/internal/game/cmd_clean.go @@ -1,7 +1,6 @@ package game import ( - "fmt" "strings" "thehouseoficarus/internal/action" @@ -54,7 +53,7 @@ func (g *Game) doClean(sess *net.Session, input string) { return } - g.cancelBackgroundAction(p) + g.cancelBgAction(p) p.BackgroundAction = &action.Action{ Type: "clean", @@ -67,13 +66,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s starts cleaning herbs.", p.Name) sess.WriteLine("\nYou begin cleaning herbs.") } diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go index 0a7d335..3782237 100644 --- a/internal/game/cmd_consume.go +++ b/internal/game/cmd_consume.go @@ -283,11 +283,5 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d 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))) - } - } - } + g.broadcastAction(sess, "\n%s drinks a %s.", p.Name, def.Name) } diff --git a/internal/game/cmd_cook.go b/internal/game/cmd_cook.go index 288afb0..04c98bb 100644 --- a/internal/game/cmd_cook.go +++ b/internal/game/cmd_cook.go @@ -61,28 +61,13 @@ func (g *Game) doCook(sess *net.Session, input string) { } } - if len(recipes) == 0 { - sess.WriteLine("You can't cook that.") - return - } - - if len(recipes) == 1 { - g.promptHowMany(sess, recipes[0].ID) - return - } - - sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) - sess.State = net.StateRecipeChoice - var names []string - for _, r := range recipes { - names = append(names, g.recipeName(sess, &r)) - } - g.showMenuTable(sess, fmt.Sprintf("What would you like to cook on the %s?", stationName), names) + title := fmt.Sprintf("What would you like to cook on the %s?", stationName) + g.showRecipeChoice(sess, p, recipes, "You can't cook that.", title, "") } func (g *Game) showCookMenu(sess *net.Session, p *player.Player, allRecipes []action.RecipeDef, stationDefID, stationName string) { seen := make(map[string]bool) - var entries []recipeEntry + var recipes []action.RecipeDef for _, r := range allRecipes { if r.Type != "cooking" { @@ -98,41 +83,9 @@ func (g *Game) showCookMenu(sess *net.Session, p *player.Player, allRecipes []ac continue } seen[r.ID] = true - entries = append(entries, recipeEntry{g.recipeName(sess, &r), r}) - } - - if len(entries) == 0 { - sess.WriteLine("You don't have anything you can cook.") - return + recipes = append(recipes, r) } - if len(entries) == 1 { - if p.OptionBool("cook_all") { - g.startProductionFromRecipe(sess, p, &entries[0].Recipe, 0) - return - } - g.promptHowMany(sess, entries[0].Recipe.ID) - return - } - - sess.State = net.StateRecipeChoice - var names []string - for _, e := range entries { - names = append(names, e.ItemName) - } - g.showMenuTable(sess, fmt.Sprintf("What would you like to cook on the %s?", stationName), names) - ids := make([]string, len(entries)) - for i, e := range entries { - ids[i] = e.Recipe.ID - } - sess.PendingMenu = recipeMenuData(ids) -} - -func (g *Game) recipeName(sess *net.Session, r *action.RecipeDef) string { - if r.Output != "" { - if def, err := g.ItemStore.Load(r.Output); err == nil { - return g.itemColorize(sess, def, def.Name) - } - } - return r.DisplayName() + title := fmt.Sprintf("What would you like to cook on the %s?", stationName) + g.showRecipeChoice(sess, p, recipes, "You don't have anything you can cook.", title, "cook_all") } diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go index 447d980..d061a9c 100644 --- a/internal/game/cmd_craft.go +++ b/internal/game/cmd_craft.go @@ -17,14 +17,14 @@ func (g *Game) executeConstruct(sess *net.Session, args []string, rawInput strin } func (g *Game) doCraft(sess *net.Session, input string) { - g.doGenericStationSkill(sess, input, "crafting", player.Crafting, "last_craft", "Crafting", "craft", "Craft") + g.doStationSkill(sess, input, "crafting", player.Crafting, "last_craft", "Crafting", "craft", "Craft") } func (g *Game) doConstruct(sess *net.Session, input string) { - g.doGenericStationSkill(sess, input, "construction", player.Construction, "last_construct", "Construction", "construct", "Construct") + g.doStationSkill(sess, input, "construction", player.Construction, "last_construct", "Construction", "construct", "Construct") } -func (g *Game) doGenericStationSkill(sess *net.Session, input string, recipeType string, skill player.SkillName, flagKey, label, verbPast, verbCap string) { +func (g *Game) doStationSkill(sess *net.Session, input string, recipeType string, skill player.SkillName, flagKey, label, verbPast, verbCap string) { p := sess.Player g.cancelAction(p) @@ -39,14 +39,14 @@ func (g *Game) doGenericStationSkill(sess *net.Session, input string, recipeType if r.Type != recipeType { continue } - if !g.genericRecipeVisible(p, &r) { + if !g.recipeVisible(p, &r) { continue } recipes = append(recipes, r) } if input != "" { - g.doGenericWithInput(sess, p, recipes, input, skill, flagKey, label, verbCap) + g.doWithInput(sess, p, recipes, input, skill, flagKey, label, verbCap) return } @@ -55,8 +55,8 @@ func (g *Game) doGenericStationSkill(sess *net.Session, input string, recipeType for i := range recipes { if recipes[i].ID == lastRecipeID { r := &recipes[i] - if g.canDoGenericRecipe(p, r, skill) { - g.startGenericRecipe(sess, p, r, 0, flagKey) + if g.canDoRecipe(p, r, skill) { + g.startRecipe(sess, p, r, 0, flagKey) return } break @@ -64,7 +64,7 @@ func (g *Game) doGenericStationSkill(sess *net.Session, input string, recipeType } } - available := g.availableGenericRecipes(p, recipes) + available := g.availableRecipes(p, recipes) if len(available) == 0 { sess.WriteLine("You don't have any materials to " + verbPast + ".") return @@ -72,14 +72,14 @@ func (g *Game) doGenericStationSkill(sess *net.Session, input string, recipeType optionKey := verbPast + "_all" if p.OptionBool(optionKey) && len(available) == 1 { - g.startGenericRecipe(sess, p, &available[0], 0, flagKey) + g.startRecipe(sess, p, &available[0], 0, flagKey) return } g.showProductionTable(sess, p, available, label, recipeType, flagKey, verbCap, false) } -func (g *Game) doGenericWithInput(sess *net.Session, p *player.Player, recipes []action.RecipeDef, input string, skill player.SkillName, flagKey, label, verbCap string) { +func (g *Game) doWithInput(sess *net.Session, p *player.Player, recipes []action.RecipeDef, input string, skill player.SkillName, flagKey, label, verbCap string) { qty, productName := parseQty(input) productName = strings.TrimSpace(productName) @@ -102,7 +102,7 @@ func (g *Game) doGenericWithInput(sess *net.Session, p *player.Player, recipes [ var available []action.RecipeDef for _, r := range matched { - if g.canDoGenericRecipe(p, &r, skill) { + if g.canDoRecipe(p, &r, skill) { available = append(available, r) } } @@ -113,14 +113,14 @@ func (g *Game) doGenericWithInput(sess *net.Session, p *player.Player, recipes [ } if len(available) == 1 { - g.startGenericRecipe(sess, p, &available[0], qty, flagKey) + g.startRecipe(sess, p, &available[0], qty, flagKey) return } g.showProductionTable(sess, p, available, label, label, flagKey, verbCap, false) } -func (g *Game) genericRecipeVisible(p *player.Player, r *action.RecipeDef) bool { +func (g *Game) recipeVisible(p *player.Player, r *action.RecipeDef) bool { if len(r.Station) > 0 { stID, _ := g.findStation(p.RoomID, r.Station) if stID == "" { @@ -133,7 +133,7 @@ func (g *Game) genericRecipeVisible(p *player.Player, r *action.RecipeDef) bool return true } -func (g *Game) canDoGenericRecipe(p *player.Player, r *action.RecipeDef, skill player.SkillName) bool { +func (g *Game) canDoRecipe(p *player.Player, r *action.RecipeDef, skill player.SkillName) bool { if p.Level(skill) < r.Level { return false } @@ -152,7 +152,7 @@ func (g *Game) canDoGenericRecipe(p *player.Player, r *action.RecipeDef, skill p return true } -func (g *Game) availableGenericRecipes(p *player.Player, allRecipes []action.RecipeDef) []action.RecipeDef { +func (g *Game) availableRecipes(p *player.Player, allRecipes []action.RecipeDef) []action.RecipeDef { var result []action.RecipeDef for _, r := range allRecipes { if r.HasAllItemsQty(p.CountItem) { @@ -162,7 +162,7 @@ func (g *Game) availableGenericRecipes(p *player.Player, allRecipes []action.Rec return result } -func (g *Game) startGenericRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int, flagKey string) { +func (g *Game) startRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int, flagKey string) { p.EnsureFlags() p.Flags[flagKey] = recipe.ID g.AccountStore.SaveCharacter(p) @@ -170,7 +170,7 @@ func (g *Game) startGenericRecipe(sess *net.Session, p *player.Player, recipe *a g.startProductionFromRecipe(sess, p, recipe, count) } -func (g *Game) findCraftRecipesForItems(p *player.Player, itemAID, itemBID string) []action.RecipeDef { +func (g *Game) findCraftRecipes(p *player.Player, itemAID, itemBID string) []action.RecipeDef { allRecipes, err := g.RecipeStore.LoadAll() if err != nil { return nil diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go index 5d0c5c1..1fc24ae 100644 --- a/internal/game/cmd_description.go +++ b/internal/game/cmd_description.go @@ -20,10 +20,10 @@ func (g *Game) doDescription(sess *net.Session) { } sess.WriteLine("") sess.Write("Enter a new description (or press enter to keep current): ") - sess.State = net.StateChangeDescription + sess.State = net.StateChangeDesc } -func (g *Game) handleDescriptionChange(sess *net.Session, input string) { +func (g *Game) handleDescChange(sess *net.Session, input string) { if input != "" { p := sess.Player p.Description = input diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 551b014..ca3c244 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -194,7 +194,7 @@ func (g *Game) executeDrop(sess *net.Session, args []string, rawInput string) { } } -func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { +func (g *Game) handleDropAll(sess *net.Session, input string) { p := sess.Player if p == nil { sess.State = net.StateGame diff --git a/internal/game/cmd_fletch.go b/internal/game/cmd_fletch.go index e218dca..5a4bc19 100644 --- a/internal/game/cmd_fletch.go +++ b/internal/game/cmd_fletch.go @@ -184,7 +184,7 @@ func (g *Game) availableFletchRecipes(p *player.Player, allFletch []action.Recip return result } -func (g *Game) findFletchRecipesForItems(p *player.Player, itemAID, itemBID string) []action.RecipeDef { +func (g *Game) findFletchRecipes(p *player.Player, itemAID, itemBID string) []action.RecipeDef { allRecipes, err := g.RecipeStore.LoadAll() if err != nil { return nil diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index f3485fe..30bc8c2 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -77,7 +77,7 @@ func (g *Game) doGet(sess *net.Session, input string) { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID { - if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name); !ok { + if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name); !ok { sess.WriteLine("That's not yours!") return } @@ -92,7 +92,7 @@ func (g *Game) doGet(sess *net.Session, input string) { sess.WriteLine("Your inventory is full.") return } - if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name); !ok { + if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name); !ok { sess.WriteLine("That's not yours!") return } @@ -112,7 +112,7 @@ func (g *Game) doGet(sess *net.Session, input string) { if fs == -1 { break } - if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name); !ok { + if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name); !ok { break } p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1}) @@ -193,7 +193,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID { - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, wanted, p.Name) + removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name) if !ok { sess.WriteLine("That's not yours!") return @@ -209,7 +209,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { sess.WriteLine("Your inventory is full.") return } - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, wanted, p.Name) + removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name) if !ok { sess.WriteLine("That's not yours!") return @@ -230,7 +230,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { if fs == -1 { break } - _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name) + _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name) if !ok { break } @@ -266,7 +266,7 @@ func (g *Game) doGetAll(sess *net.Session) { continue } if itemID == "credits" { - taken, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name) + taken, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 999999, p.Name) if !ok { continue } @@ -286,7 +286,7 @@ func (g *Game) doGetAll(sess *net.Session) { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID { - _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) + _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) if !ok { qty = 0 stacked = true @@ -313,7 +313,7 @@ func (g *Game) doGetAll(sess *net.Session) { g.AccountStore.SaveCharacter(p) return } - _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) + _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) if !ok { break } @@ -339,7 +339,7 @@ func (g *Game) doGetAll(sess *net.Session) { } take := 1 - _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, take, p.Name) + _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, take, p.Name) if !ok { break } @@ -364,7 +364,7 @@ func (g *Game) doGetAll(sess *net.Session) { } func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) { - qty, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name) + qty, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 999999, p.Name) if !ok { sess.WriteLine("That's not yours!") return diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go index 16f8b2a..f33ea85 100644 --- a/internal/game/cmd_jack.go +++ b/internal/game/cmd_jack.go @@ -52,7 +52,7 @@ func (g *Game) doJack(sess *net.Session, target string) { } g.cancelAction(p) - g.cancelBackgroundAction(p) + g.cancelBgAction(p) minigame := tDef.Minigame() display := minigame.Init(hackLevel) @@ -68,13 +68,7 @@ func (g *Game) doJack(sess *net.Session, target string) { 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)) - } - } - } + g.broadcastAction(sess, "\n%s jacks into a terminal.", p.Name) sess.WriteLine(display) sess.Write("\nhack> ") diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 6913333..505f7a6 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -48,7 +48,7 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo roomDescSpec := g.resolveColor(sess, "room_desc") descLines := make([]string, len(rawLines)) for i, l := range rawLines { - descLines[i] = color.ExpandTagsWithDefault(mode, roomDescSpec, l) + descLines[i] = color.ExpandTagsDefault(mode, roomDescSpec, l) } wroteDesc := false if p.OptionString("tiny_map") != "off" { @@ -546,7 +546,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } } - farmSuffix := g.farmLookSuffixForObj(p, st.DefID, st.Index) + farmSuffix := g.farmObjSuffix(p, st.DefID, st.Index) if farmSuffix != "" { sess.WriteLine(farmSuffix) } diff --git a/internal/game/cmd_mix.go b/internal/game/cmd_mix.go index 7bf1f7b..337bb18 100644 --- a/internal/game/cmd_mix.go +++ b/internal/game/cmd_mix.go @@ -3,7 +3,6 @@ package game import ( "strings" - "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) @@ -13,110 +12,5 @@ func (g *Game) executeMix(sess *net.Session, args []string, rawInput string) { } func (g *Game) doMix(sess *net.Session, input string) { - p := sess.Player - g.cancelAction(p) - - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - sess.WriteLine("Error loading recipes.") - return - } - - if input == "" { - g.showMixMenu(sess, p, allRecipes) - return - } - - qty, itemName := parseQty(input) - _ = qty - - var matched []action.RecipeDef - for _, r := range allRecipes { - if r.Type != "pharmacy" { - continue - } - outDef, _ := g.ItemStore.Load(r.Output) - name := r.Output - if outDef != nil { - name = outDef.Name - } - if action.WordPrefixMatch(itemName, name) { - matched = append(matched, r) - } - } - - if len(matched) == 0 { - sess.WriteLine("You can't mix that.") - return - } - - var available []action.RecipeDef - for _, r := range matched { - if r.HasAllItems(p.HasItem) && p.Level(player.Pharmacy) >= r.Level { - available = append(available, r) - } - } - - if len(available) == 0 { - sess.WriteLine("You don't have the materials for that.") - return - } - - if len(available) == 1 { - g.promptHowMany(sess, available[0].ID) - return - } - - sess.State = net.StateRecipeChoice - var names []string - for _, r := range available { - names = append(names, g.recipeName(sess, &r)) - } - g.showMenuTable(sess, "What would you like to mix?", names) - sess.PendingMenu = recipeMenuData(recipeDefIDs(available)) -} - -func (g *Game) showMixMenu(sess *net.Session, p *player.Player, allRecipes []action.RecipeDef) { - seen := make(map[string]bool) - var entries []recipeEntry - - for _, r := range allRecipes { - if r.Type != "pharmacy" { - continue - } - if seen[r.ID] { - continue - } - if !r.HasAllItems(p.HasItem) { - continue - } - seen[r.ID] = true - entries = append(entries, recipeEntry{g.recipeName(sess, &r), r}) - } - - if len(entries) == 0 { - sess.WriteLine("You don't have anything you can mix.") - return - } - - if len(entries) == 1 { - if p.OptionBool("mix_all") { - g.startProductionFromRecipe(sess, p, &entries[0].Recipe, 0) - return - } - g.promptHowMany(sess, entries[0].Recipe.ID) - return - } - - sess.State = net.StateRecipeChoice - var names []string - for _, e := range entries { - names = append(names, e.ItemName) - } - g.showMenuTable(sess, "What would you like to mix?", names) - ids := make([]string, len(entries)) - for i, e := range entries { - ids[i] = e.Recipe.ID - } - sess.PendingMenu = recipeMenuData(ids) + g.doStationSkill(sess, input, "pharmacy", player.Pharmacy, "last_mix", "Mixing", "mix", "Mix") } diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 46403f4..187d766 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -1,8 +1,6 @@ package game import ( - "fmt" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" @@ -24,13 +22,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s sits down to rest.", p.Name) ticksLeft := 10 id := g.Ticks.Subscribe(engine.ToTicks(1), func() bool { diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index c4f7700..ea741d9 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -149,7 +149,7 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI switch a { case "gather": if len(args) == 0 { - target := g.resolveDefaultTarget(p.RoomID, cmd) + target := g.defaultTarget(p.RoomID, cmd) if target == "" { sess.WriteLine(fmt.Sprintf("%s what?", cmd)) return @@ -176,7 +176,7 @@ func (g *Game) executeGather(sess *net.Session, args []string, rawInput string) p := sess.Player g.cancelAction(p) if len(args) == 0 { - target := g.resolveDefaultTarget(p.RoomID, verb) + target := g.defaultTarget(p.RoomID, verb) if target == "" { sess.WriteLine(fmt.Sprintf("%s what?", verb)) return diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go index a72cf53..f1ff4e2 100644 --- a/internal/game/cmd_smelt.go +++ b/internal/game/cmd_smelt.go @@ -53,69 +53,29 @@ func (g *Game) doSmelt(sess *net.Session, input string) { if r.Type != "smelting" || !stationMatch(stationDefID, r.Station) || !r.MatchesEntry(itemID) { continue } - if !r.HasAllItemsQty(p.CountItem) || p.Level(player.SkillName(r.EffectiveSkill())) < r.Level { + if !r.HasAllItemsQty(p.CountItem) || p.Level(player.SkillName(r.EffectiveSkill())) < r.Level { continue } recipes = append(recipes, r) } - if len(recipes) == 0 { - sess.WriteLine("You can't smelt that.") - return - } - - if len(recipes) == 1 { - g.promptHowMany(sess, recipes[0].ID) - return - } - - sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) - sess.State = net.StateRecipeChoice - var names []string - for _, r := range recipes { - names = append(names, g.recipeName(sess, &r)) - } - g.showMenuTable(sess, "What would you like to smelt?", names) + g.showRecipeChoice(sess, p, recipes, "You can't smelt that.", "What would you like to smelt?", "") } func (g *Game) showSmeltMenu(sess *net.Session, p *player.Player, allRecipes []action.RecipeDef, stationDefID, stationName string) { seen := make(map[string]bool) - var entries []recipeEntry + var recipes []action.RecipeDef for _, r := range allRecipes { if r.Type != "smelting" || !stationMatch(stationDefID, r.Station) { continue } - if seen[r.ID] || !r.HasAllItemsQty(p.CountItem) || p.Level(player.SkillName(r.EffectiveSkill())) < r.Level { + if seen[r.ID] || !r.HasAllItemsQty(p.CountItem) || p.Level(player.SkillName(r.EffectiveSkill())) < r.Level { continue } seen[r.ID] = true - entries = append(entries, recipeEntry{g.recipeName(sess, &r), r}) - } - - if len(entries) == 0 { - sess.WriteLine("You don't have anything you can smelt.") - return - } - - if len(entries) == 1 { - if p.OptionBool("smelt_all") { - g.startProductionFromRecipe(sess, p, &entries[0].Recipe, 0) - return - } - g.promptHowMany(sess, entries[0].Recipe.ID) - return + recipes = append(recipes, r) } - sess.State = net.StateRecipeChoice - var names []string - for _, e := range entries { - names = append(names, e.ItemName) - } - g.showMenuTable(sess, "What would you like to smelt?", names) - ids := make([]string, len(entries)) - for i, e := range entries { - ids[i] = e.Recipe.ID - } - sess.PendingMenu = recipeMenuData(ids) + g.showRecipeChoice(sess, p, recipes, "You don't have anything you can smelt.", "What would you like to smelt?", "smelt_all") } diff --git a/internal/game/cmd_smith.go b/internal/game/cmd_smith.go index a2eaad7..c7a32cd 100644 --- a/internal/game/cmd_smith.go +++ b/internal/game/cmd_smith.go @@ -63,7 +63,7 @@ func (g *Game) doSmith(sess *net.Session, input string) { if len(barTypes) == 1 { if p.OptionBool("smith_all") { - if recipe := g.findSingleSmithRecipe(p, barTypes[0], allRecipes); recipe != nil { + if recipe := g.findSmithRecipe(p, barTypes[0], allRecipes); recipe != nil { g.startProductionFromRecipe(sess, p, recipe, 0) return } @@ -148,7 +148,7 @@ func barMenuData(barIDs []string) []map[string]string { return out } -func (g *Game) findSingleSmithRecipe(p *player.Player, barID string, allRecipes []action.RecipeDef) *action.RecipeDef { +func (g *Game) findSmithRecipe(p *player.Player, barID string, allRecipes []action.RecipeDef) *action.RecipeDef { var makeable []*action.RecipeDef skillLevel := p.Level(player.Smithing) for i, r := range allRecipes { diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go index 62c5083..444f80d 100644 --- a/internal/game/cmd_trigger_combat.go +++ b/internal/game/cmd_trigger_combat.go @@ -23,7 +23,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob g.consumeJunkCost(p, mod) - equipSciBonus := g.totalEquipScienceAttack(p) + equipSciBonus := g.totalScienceAttack(p) attRoll := (p.Level(player.Science) + g.buffLevelBonus(p, "science") + 8) * (equipSciBonus + 64) mobSciDef := mob.ScienceDefense diff --git a/internal/game/cmd_trigger_enchant.go b/internal/game/cmd_trigger_enchant.go index 535616b..a1a6950 100644 --- a/internal/game/cmd_trigger_enchant.go +++ b/internal/game/cmd_trigger_enchant.go @@ -41,13 +41,9 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, g.cancelAction(p) } - g.consumeJunkCost(p, mod) - inv.ItemID = outputID - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, 1.0) outputDef, _ := g.ItemStore.Load(outputID) outputName := outputID @@ -61,9 +57,6 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, } sess.WriteLine(fmt.Sprintf("You enchant the %s and it becomes a %s!", inputName, outputName)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } } func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef, chip chipEntry) { @@ -82,8 +75,6 @@ func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef g.cancelAction(p) } - g.consumeJunkCost(p, mod) - p.RemoveItem(chip.Input, chip.Qty) placed := false for i := 0; i < 28; i++ { @@ -99,9 +90,7 @@ func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: chip.Output, Quantity: chip.Qty}) } - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, 1.0) inputDef, _ := g.ItemStore.Load(chip.Input) name := chip.Input @@ -110,7 +99,4 @@ func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef } sess.WriteLine(fmt.Sprintf("You chip %d %s with arcane circuitry.", chip.Qty, name)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } } diff --git a/internal/game/cmd_trigger_transport.go b/internal/game/cmd_trigger_transport.go index 2ca8db2..2354c27 100644 --- a/internal/game/cmd_trigger_transport.go +++ b/internal/game/cmd_trigger_transport.go @@ -18,14 +18,7 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef g.cancelAction(p) } - g.consumeJunkCost(p, mod) - - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, 1.0) if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index efb23e7..ce59975 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -14,7 +14,7 @@ func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, case "low_process", "high_process": g.triggerProcessing(sess, p, mod, targetArg) case "bones_to_nutrients": - g.triggerBonesToNutrients(sess, p, mod) + g.triggerBonesToNuts(sess, p, mod) case "em_grab": g.triggerEmGrab(sess, p, mod, targetArg) case "superheat": @@ -51,8 +51,6 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe g.cancelAction(p) } - g.consumeJunkCost(p, mod) - creditValue := itemDef.Value if mod.ID == "low_process" { creditValue = itemDef.Value / 2 @@ -69,19 +67,13 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe p.Credits += creditValue - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, 1.0) sess.WriteLine(fmt.Sprintf("You process the %s. You receive %s credits.", itemDef.Name, g.colorize(sess, "credits_pickup", fmt.Sprint(creditValue)))) - - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } } -func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod *ModDef) { +func (g *Game) triggerBonesToNuts(sess *net.Session, p *player.Player, mod *ModDef) { if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return @@ -97,8 +89,6 @@ func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod g.cancelAction(p) } - g.consumeJunkCost(p, mod) - for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == "bones" { @@ -106,14 +96,9 @@ func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod } } - sciXP := mod.BaseXP * boneCount - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, float64(boneCount)) sess.WriteLine(fmt.Sprintf("You convert %d bones into nutrient bars.", boneCount)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } } func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { @@ -150,8 +135,6 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t g.cancelAction(p) } - g.consumeJunkCost(p, mod) - qty := groundItems[matchedID] g.World.RemoveGroundItem(p.RoomID, matchedID, qty) @@ -164,14 +147,9 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t freeSlot := p.FirstFreeSlot() p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: qty}) - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, 1.0) sess.WriteLine(fmt.Sprintf("You magnetically pull the %s toward you.", name)) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) - } } func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { @@ -224,8 +202,6 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef g.cancelAction(p) } - g.consumeJunkCost(p, mod) - for _, e := range recipe.Consume { for _, itemID := range e.Items { qty := e.Quantity @@ -254,12 +230,11 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) } - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) + var extraGains []xpGain if recipe.XP > 0 { - g.awardSkillXP(sess, p, player.SkillName(recipe.Skill), recipe.XP) + extraGains = []xpGain{{recipe.Skill, recipe.XP}} } - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, 1.0, extraGains...) outputDef, _ := g.ItemStore.Load(recipe.Output) outputName := recipe.Output @@ -273,13 +248,6 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef } sess.WriteLine(fmt.Sprintf("You superheat the %s and produce a %s.", inputName, outputName)) - if p.OptionBool("xp_drops") { - parts := []string{fmt.Sprintf("+%dxp sci", sciXP)} - if recipe.XP > 0 { - parts = append(parts, fmt.Sprintf("+%dxp %s", recipe.XP, player.SkillAbbr[player.SkillName(recipe.Skill)])) - } - sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) - } } func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { @@ -320,8 +288,6 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef g.cancelAction(p) } - g.consumeJunkCost(p, mod) - p.RemoveItem(inv.ItemID, 1) p.Credits -= info.cost @@ -332,16 +298,9 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) - sciXP := mod.BaseXP - g.awardSkillXP(sess, p, player.Science, sciXP) - g.awardSkillXP(sess, p, player.Construction, 10) - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, 1.0, xpGain{Skill: string(player.Construction), XP: 10}) 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 } @@ -360,8 +319,6 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef g.cancelAction(p) } - g.consumeJunkCost(p, mod) - p.RemoveItem(logID, qty) p.Credits -= totalCost @@ -376,20 +333,12 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef processed++ } - sciXP := mod.BaseXP * qty - g.awardSkillXP(sess, p, player.Science, sciXP) - conXP := 10 * qty - g.awardSkillXP(sess, p, player.Construction, conXP) - g.AccountStore.SaveCharacter(p) + g.triggerModReward(sess, p, mod, float64(qty), xpGain{Skill: string(player.Construction), XP: 10 * qty}) 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 } diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 4f830a8..e653c93 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -83,7 +83,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) } recipes := g.stationRecipes(p, objSt.DefID) if len(recipes) > 0 { - g.showStationRecipeMenu(sess, p, recipes, def.Name) + g.showRecipeMenu(sess, p, recipes, def.Name) return true } bt := def.BehaviorType() @@ -105,13 +105,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) g.applyNodeAction(sess, ui.Action) } p.ActionState = &ActionState{Type: ActionUsing, 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 uses the %s.", p.Name, def.Name))) - } - } - } + g.broadcastAction(sess, "\n%s uses the %s.", p.Name, def.Name) return true } if len(def.UseInteractions) > 0 { @@ -160,7 +154,7 @@ func (g *Game) stationRecipes(p *player.Player, stationDefID string) []action.Re return results } -func (g *Game) showStationRecipeMenu(sess *net.Session, p *player.Player, recipes []action.RecipeDef, stationName string) { +func (g *Game) showRecipeMenu(sess *net.Session, p *player.Player, recipes []action.RecipeDef, stationName string) { if len(recipes) == 0 { sess.WriteLine(fmt.Sprintf("You don't have anything you can make at the %s.", stationName)) return @@ -320,8 +314,8 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, itemBID := matchesB[0].ID - craftRecipes := g.findCraftRecipesForItems(p, itemAID, itemBID) - fletchRecipes := g.findFletchRecipesForItems(p, itemAID, itemBID) + craftRecipes := g.findCraftRecipes(p, itemAID, itemBID) + fletchRecipes := g.findFletchRecipes(p, itemAID, itemBID) if len(craftRecipes) > 0 && len(fletchRecipes) > 0 { var menu []map[string]string @@ -353,12 +347,12 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, if len(craftRecipes) > 0 { var available []action.RecipeDef for _, r := range craftRecipes { - if g.canDoGenericRecipe(p, &r, player.Crafting) { + if g.canDoRecipe(p, &r, player.Crafting) { available = append(available, r) } } if len(available) == 1 { - g.startGenericRecipe(sess, p, &available[0], 0, "last_craft") + g.startRecipe(sess, p, &available[0], 0, "last_craft") return } if len(available) > 1 { @@ -429,7 +423,7 @@ func (g *Game) findCombineResults(sess *net.Session, p *player.Player, itemAID, } } } - if aEntry >= 0 && bEntry >= 0 && aEntry != bEntry && madeFromItemsAvailable(p, def.MadeFrom) { + if aEntry >= 0 && bEntry >= 0 && aEntry != bEntry && madeFromAvailable(p, def.MadeFrom) { matched = append(matched, def) } } @@ -444,7 +438,7 @@ func combineMenuData(defs []*object.ItemDef) []map[string]string { return out } -func madeFromItemsAvailable(p *player.Player, madeFrom []object.MadeFromEntry) bool { +func madeFromAvailable(p *player.Player, madeFrom []object.MadeFromEntry) bool { for _, entry := range madeFrom { found := false for _, id := range entry.Items { diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 87f8c62..a2c1bd8 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -83,7 +83,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) { g.showMenu(sess) } -func (g *Game) handleNewAccountPass(sess *net.Session, input string) { +func (g *Game) handleNewPass(sess *net.Session, input string) { input = strings.TrimSpace(input) if input == "" { sess.Conn.SetEcho(true) @@ -98,11 +98,11 @@ func (g *Game) handleNewAccountPass(sess *net.Session, input string) { return } sess.PendingPass = input - sess.State = net.StateNewAccountConfirm + sess.State = net.StateNewAccount sess.Write("Confirm password: ") } -func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { +func (g *Game) handleNewAccount(sess *net.Session, input string) { input = strings.TrimSpace(input) if input == "" { sess.Conn.SetEcho(true) diff --git a/internal/game/core_messages.go b/internal/game/core_messages.go deleted file mode 100644 index 1c9e85a..0000000 --- a/internal/game/core_messages.go +++ /dev/null @@ -1,13 +0,0 @@ -package game - -const ( - MsgErrLoadRecipes = "Error loading recipes." - MsgNoCombat = "You can't do that during combat!" - MsgNoDontHave = "You don't have any '%s'." - MsgNeverMind = "Never mind." - MsgSomethingWrongObject = "Something is wrong with this object." - MsgSomethingWrongThat = "Something is wrong with that." - MsgInventoryFull = "Your inventory is too full!" - MsgAmbiguous = "That's ambiguous, which one?" - MsgWhichOne = "Which one?" -) diff --git a/internal/game/core_production.go b/internal/game/core_production.go index 227c4ad..59517d5 100644 --- a/internal/game/core_production.go +++ b/internal/game/core_production.go @@ -18,7 +18,7 @@ import ( func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, actionType, displayVerb, startMsg, endMsg string, count int) { g.cancelAction(p) - g.cancelBackgroundAction(p) + g.cancelBgAction(p) skill := recipe.EffectiveSkill() if skill != "" { @@ -65,13 +65,7 @@ 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))) - } - } - } + g.broadcastAction(sess, "\n%s starts %s.", p.Name, displayVerb) } func (g *Game) startProductionFromRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { @@ -106,14 +100,9 @@ func (g *Game) startProductionFromRecipe(sess *net.Session, p *player.Player, re g.startProduction(sess, p, recipe, actionType, displayVerb, startMsg, endMsg, count) } -func (g *Game) loadProductionRecipe(recipeID string) *action.RecipeDef { - all, err := g.RecipeStore.LoadAll() - if err == nil { - for _, r := range all { - if r.ID == recipeID { - return &r - } - } +func (g *Game) loadRecipe(recipeID string) *action.RecipeDef { + if r := g.RecipeStore.GetByID(recipeID); r != nil { + return r } if strings.HasPrefix(recipeID, "combine_") { itemID := strings.TrimPrefix(recipeID, "combine_") @@ -132,7 +121,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { endMsg, _ := p.Action.Data["end_msg"].(string) remaining, _ := p.Action.Data["remaining"].(int) - recipe := g.loadProductionRecipe(recipeID) + recipe := g.loadRecipe(recipeID) if recipe == nil { g.cancelAction(p) return false @@ -350,25 +339,19 @@ func (g *Game) handleRecipeChoice(sess *net.Session, input string) { func (g *Game) menuEntryName(entry map[string]string) string { if rid, ok := entry["recipe_id"]; ok { - all, _ := g.RecipeStore.LoadAll() - for _, r := range all { - if r.ID == rid { - if def, err := g.ItemStore.Load(r.Output); err == nil { - return def.Name - } - return r.Output + if r := g.RecipeStore.GetByID(rid); r != nil { + if def, err := g.ItemStore.Load(r.Output); err == nil { + return def.Name } + return r.Output } } if rid, ok := entry["fletch_recipe_id"]; ok { - all, _ := g.RecipeStore.LoadAll() - for _, r := range all { - if r.ID == rid { - if def, err := g.ItemStore.Load(r.Output); err == nil { - return def.Name - } - return r.Output + if r := g.RecipeStore.GetByID(rid); r != nil { + if def, err := g.ItemStore.Load(r.Output); err == nil { + return def.Name } + return r.Output } } if barID, ok := entry["bar_id"]; ok { @@ -399,12 +382,9 @@ func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { } if rid, ok := entry["fletch_recipe_id"]; ok { p := sess.Player - allRecipes, _ := g.RecipeStore.LoadAll() - for _, r := range allRecipes { - if r.ID == rid { - g.startFletchAction(sess, p, &r, 0) - return - } + if r := g.RecipeStore.GetByID(rid); r != nil { + g.startFletchAction(sess, p, r, 0) + return } g.reprompt(sess) return @@ -416,7 +396,7 @@ func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { g.reprompt(sess) } -func (g *Game) formatRecipeMaterials(r *action.RecipeDef) string { +func (g *Game) formatMaterials(r *action.RecipeDef) string { var parts []string for _, e := range r.Consume { itemName := e.Items[0] @@ -461,7 +441,7 @@ func (g *Game) showProductionTable(sess *net.Session, p *player.Player, recipes productName = fmt.Sprintf("%s x%d", productName, outputQty) } - matStr := g.formatRecipeMaterials(&r) + matStr := g.formatMaterials(&r) levelStr := fmt.Sprint(r.Level) numStr := fmt.Sprintf("%d", i+1) @@ -524,20 +504,11 @@ func (g *Game) handleProductChoice(sess *net.Session, input string) { input = strings.TrimSpace(input) - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - g.reprompt(sess) - return - } - var recipes []action.RecipeDef for _, entry := range menuData { rid := entry["recipe_id"] - for _, r := range allRecipes { - if r.ID == rid { - recipes = append(recipes, r) - break - } + if r := g.RecipeStore.GetByID(rid); r != nil { + recipes = append(recipes, *r) } } @@ -587,10 +558,15 @@ func (g *Game) handleProductChoice(sess *net.Session, input string) { } } -func (g *Game) hasToolType(p *player.Player, toolType string) bool { +func (g *Game) findTool(p *player.Player, toolTypes []string) (toolSpeed float64, toolName string, found bool) { + toolSpeed = -1 if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil && def.ToolType == toolType { - return true + if def, err := g.ItemStore.Load(itemID); err == nil { + for _, t := range toolTypes { + if def.ToolType == t { + return def.ToolSpeed, def.Name, true + } + } } } for i := 0; i < 28; i++ { @@ -598,11 +574,22 @@ func (g *Game) hasToolType(p *player.Player, toolType string) bool { if slot == nil { continue } - if def, err := g.ItemStore.Load(slot.ItemID); err == nil && def.ToolType == toolType { - return true + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil { + continue + } + for _, t := range toolTypes { + if def.ToolType == t { + return def.ToolSpeed, def.Name, true + } } } - return false + return -1, "", false +} + +func (g *Game) hasToolType(p *player.Player, toolType string) bool { + _, _, found := g.findTool(p, []string{toolType}) + return found } type recipeEntry struct { ItemName string @@ -729,6 +716,37 @@ func (g *Game) showMenuTable(sess *net.Session, title string, names []string) { } } +func (g *Game) recipeName(sess *net.Session, r *action.RecipeDef) string { + if r.Output != "" { + if def, err := g.ItemStore.Load(r.Output); err == nil { + return g.itemColorize(sess, def, def.Name) + } + } + return r.DisplayName() +} + +func (g *Game) showRecipeChoice(sess *net.Session, p *player.Player, recipes []action.RecipeDef, emptyMsg, title string, autoOption string) { + if len(recipes) == 0 { + sess.WriteLine(emptyMsg) + return + } + if len(recipes) == 1 { + if autoOption != "" && p.OptionBool(autoOption) { + g.startProductionFromRecipe(sess, p, &recipes[0], 0) + return + } + g.promptHowMany(sess, recipes[0].ID) + return + } + sess.State = net.StateRecipeChoice + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) + var names []string + for i := range recipes { + names = append(names, g.recipeName(sess, &recipes[i])) + } + g.showMenuTable(sess, title, names) +} + func (g *Game) handleHowMany(sess *net.Session, input string) { p := sess.Player recipeID := sess.PendingRecipeID @@ -758,17 +776,7 @@ func (g *Game) handleHowMany(sess *net.Session, input string) { } recipe = g.buildCombineRecipe(itemDef) } else { - all, err := g.RecipeStore.LoadAll() - if err != nil { - g.reprompt(sess) - return - } - for _, r := range all { - if r.ID == recipeID { - recipe = &r - break - } - } + recipe = g.RecipeStore.GetByID(recipeID) } if recipe == nil { diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go index ab52762..ef33ff7 100644 --- a/internal/game/core_startup.go +++ b/internal/game/core_startup.go @@ -8,6 +8,7 @@ import ( "strings" "thehouseoficarus/internal/action" + "thehouseoficarus/internal/world" ) type StartupIssue struct { @@ -19,29 +20,29 @@ type StartupIssue struct { func (g *Game) ValidateStartup() []StartupIssue { var issues []StartupIssue - issues = append(issues, validateDuplicateIDs(g.DataDir, "items", "item")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "rooms", "room")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "mobs", "mob")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "objects", "object")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "drops", "drop table")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "recipes", "recipe")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "courses", "course")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "modules", "module")...) - issues = append(issues, validateDuplicateIDs(g.DataDir, "help", "help topic")...) - - issues = append(issues, validateRoomReferences(g)...) - issues = append(issues, validateMobReferences(g)...) - issues = append(issues, validateObjectReferences(g)...) - issues = append(issues, validateItemReferences(g)...) - issues = append(issues, validateRecipeReferences(g)...) - issues = append(issues, validateDropTableReferences(g)...) - issues = append(issues, validateCourseReferences(g)...) + issues = append(issues, validateIDs(g.DataDir, "items", "item")...) + issues = append(issues, validateIDs(g.DataDir, "rooms", "room")...) + issues = append(issues, validateIDs(g.DataDir, "mobs", "mob")...) + issues = append(issues, validateIDs(g.DataDir, "objects", "object")...) + issues = append(issues, validateIDs(g.DataDir, "drops", "drop table")...) + issues = append(issues, validateIDs(g.DataDir, "recipes", "recipe")...) + issues = append(issues, validateIDs(g.DataDir, "courses", "course")...) + issues = append(issues, validateIDs(g.DataDir, "modules", "module")...) + issues = append(issues, validateIDs(g.DataDir, "help", "help topic")...) + + issues = append(issues, validateRooms(g)...) + issues = append(issues, validateMobs(g)...) + issues = append(issues, validateObjects(g)...) + issues = append(issues, validateItems(g)...) + issues = append(issues, validateRecipes(g)...) + issues = append(issues, validateDropTables(g)...) + issues = append(issues, validateCourses(g)...) issues = append(issues, validateRoomWiring(g)...) return issues } -func validateDuplicateIDs(dataDir, subDir, label string) []StartupIssue { +func validateIDs(dataDir, subDir, label string) []StartupIssue { dir := filepath.Join(dataDir, subDir) dups := action.CheckDuplicateIDs(dir) var issues []StartupIssue @@ -60,7 +61,7 @@ func validateDuplicateIDs(dataDir, subDir, label string) []StartupIssue { return issues } -func validateRoomReferences(g *Game) []StartupIssue { +func validateRooms(g *Game) []StartupIssue { var issues []StartupIssue roomIndex := g.World.RoomIndex() itemIDs := g.ItemStore.IDSet() @@ -86,6 +87,16 @@ func validateRoomReferences(g *Game) []StartupIssue { } for dir, exit := range room.Exits { + switch dir { + case world.North, world.South, world.East, world.West, world.Up, world.Down: + default: + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: invalid exit direction %q (only north/south/east/west/up/down supported)", id, dir), + }) + continue + } if exit.Room <= 0 { continue } @@ -159,7 +170,7 @@ func validateRoomReferences(g *Game) []StartupIssue { return issues } -func validateMobReferences(g *Game) []StartupIssue { +func validateMobs(g *Game) []StartupIssue { var issues []StartupIssue itemIDs := g.ItemStore.IDSet() dropIDs := dropTableIDSet(g.DataDir) @@ -233,7 +244,7 @@ func validateMobReferences(g *Game) []StartupIssue { return issues } -func validateObjectReferences(g *Game) []StartupIssue { +func validateObjects(g *Game) []StartupIssue { var issues []StartupIssue objIDs := g.ObjectStore.IDSet() itemIDs := g.ItemStore.IDSet() @@ -301,7 +312,7 @@ func validateObjectReferences(g *Game) []StartupIssue { } if obj.Gather != nil { - issues = append(issues, validateGatherConfig(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...) + issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...) } if obj.Talk != nil { @@ -316,7 +327,7 @@ func validateObjectReferences(g *Game) []StartupIssue { return issues } -func validateItemReferences(g *Game) []StartupIssue { +func validateItems(g *Game) []StartupIssue { var issues []StartupIssue itemIDs := g.ItemStore.IDSet() dropIDs := dropTableIDSet(g.DataDir) @@ -393,7 +404,7 @@ func validateItemReferences(g *Game) []StartupIssue { return issues } -func validateRecipeReferences(g *Game) []StartupIssue { +func validateRecipes(g *Game) []StartupIssue { var issues []StartupIssue itemIDs := g.ItemStore.IDSet() @@ -456,7 +467,7 @@ func validateRecipeReferences(g *Game) []StartupIssue { return issues } -func validateDropTableReferences(g *Game) []StartupIssue { +func validateDropTables(g *Game) []StartupIssue { var issues []StartupIssue dropIDs := dropTableIDSet(g.DataDir) itemIDs := g.ItemStore.IDSet() @@ -494,7 +505,7 @@ func validateDropTableReferences(g *Game) []StartupIssue { return issues } -func validateCourseReferences(g *Game) []StartupIssue { +func validateCourses(g *Game) []StartupIssue { var issues []StartupIssue roomIndex := g.World.RoomIndex() @@ -582,7 +593,7 @@ func validateRoomWiring(g *Game) []StartupIssue { return issues } -func validateGatherConfig(prefix string, cfg *action.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []StartupIssue { +func validateGather(prefix string, cfg *action.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []StartupIssue { var issues []StartupIssue if cfg.Bait != "" && !itemIDs[cfg.Bait] { diff --git a/internal/game/core_types.go b/internal/game/core_types.go index d41b18a..898078b 100644 --- a/internal/game/core_types.go +++ b/internal/game/core_types.go @@ -22,7 +22,7 @@ type xpGain struct { type deathDrop struct { itemID string quantity int - totalVal int + totalValue int isEquip bool equipSlot object.EquipSlot invSlot int diff --git a/internal/game/core_utils.go b/internal/game/core_utils.go index 409422e..c169580 100644 --- a/internal/game/core_utils.go +++ b/internal/game/core_utils.go @@ -104,6 +104,22 @@ func formatPickupList(sess *net.Session, picked []string) { } } +func (g *Game) broadcastAction(sess *net.Session, format string, args ...interface{}) { + if g.Hub == nil { + return + } + p := sess.Player + if p == nil { + return + } + msg := fmt.Sprintf(format, args...) + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", msg)) + } + } +} + func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { slot := &player.InventorySlot{ItemID: itemID, Quantity: qty} if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 { diff --git a/internal/game/game.go b/internal/game/game.go index 5d6e21e..ac8622e 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -47,7 +47,7 @@ type Game struct { WorldFlags map[string]any ColorConfig *config.ColorsConfig DataDir string - ValidationConfig config.StartupValidationConfig + ValidationConfig config.ValidationConfig restTimers map[string]uint64 charsMu sync.Mutex loggedInChars map[string]*net.Session @@ -60,7 +60,7 @@ type Game struct { hackingStates map[string]*hacking.Session } -func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.StartupValidationConfig) *Game { +func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game { g := &Game{ World: world.New(dataDir), ObjectStore: object.NewObjectStore(dataDir), @@ -110,9 +110,9 @@ func (g *Game) HandleSession(sess *net.Session, input string) { case net.StatePassword: g.handlePassword(sess, input) case net.StateNewAccountPass: - g.handleNewAccountPass(sess, input) - case net.StateNewAccountConfirm: - g.handleNewAccountConfirm(sess, input) + g.handleNewPass(sess, input) + case net.StateNewAccount: + g.handleNewAccount(sess, input) case net.StateMenu: g.handleMenu(sess, input) case net.StateNewCharName: @@ -129,8 +129,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handlePurgeAccount(sess, input) case net.StateGame: g.handleGameCommand(sess, input) - case net.StateChangeDescription: - g.handleDescriptionChange(sess, input) + case net.StateChangeDesc: + g.handleDescChange(sess, input) case net.StateTalk: g.handleTalkInput(sess, input) case net.StateShop: @@ -138,7 +138,7 @@ func (g *Game) HandleSession(sess *net.Session, input string) { case net.StateBank: g.handleBankInput(sess, input) case net.StateDropAllConfirm: - g.handleDropAllConfirm(sess, input) + g.handleDropAll(sess, input) case net.StateRecipeChoice: g.handleRecipeChoice(sess, input) case net.StateHowMany: diff --git a/internal/game/hacking/hacking_liars_dice.go b/internal/game/hacking/hacking_liars_dice.go deleted file mode 100644 index bcfbd40..0000000 --- a/internal/game/hacking/hacking_liars_dice.go +++ /dev/null @@ -1,395 +0,0 @@ -package hacking - -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 - 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 , call, exact, status, jack out", false, false - } - - cmd := parts[0] - - switch cmd { - case "bid", "b": - if len(parts) < 3 { - return "Usage: bid (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 (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 , 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/hacking_mastermind.go b/internal/game/hacking/hacking_mastermind.go deleted file mode 100644 index 8d4eb1c..0000000 --- a/internal/game/hacking/hacking_mastermind.go +++ /dev/null @@ -1,177 +0,0 @@ -package hacking - -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/hacking_wumpus.go b/internal/game/hacking/hacking_wumpus.go deleted file mode 100644 index 91b9243..0000000 --- a/internal/game/hacking/hacking_wumpus.go +++ /dev/null @@ -1,286 +0,0 @@ -package hacking - -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 - Move to an adjacent node (1-20)\n") - sb.WriteString(" shoot - 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 , shoot , 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/hacking/liars_dice.go b/internal/game/hacking/liars_dice.go new file mode 100644 index 0000000..bcfbd40 --- /dev/null +++ b/internal/game/hacking/liars_dice.go @@ -0,0 +1,395 @@ +package hacking + +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 - 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 , call, exact, status, jack out", false, false + } + + cmd := parts[0] + + switch cmd { + case "bid", "b": + if len(parts) < 3 { + return "Usage: bid (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 (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 , 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..8d4eb1c --- /dev/null +++ b/internal/game/hacking/mastermind.go @@ -0,0 +1,177 @@ +package hacking + +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..91b9243 --- /dev/null +++ b/internal/game/hacking/wumpus.go @@ -0,0 +1,286 @@ +package hacking + +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 - Move to an adjacent node (1-20)\n") + sb.WriteString(" shoot - 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 , shoot , 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/sys_combat.go b/internal/game/sys_combat.go index 030bed1..702871c 100644 --- a/internal/game/sys_combat.go +++ b/internal/game/sys_combat.go @@ -31,7 +31,7 @@ func (g *Game) dropItemsOnDeath(p *player.Player) { items = append(items, deathDrop{ itemID: inv.ItemID, quantity: inv.Quantity, - totalVal: val, + totalValue: val, invSlot: slot, }) } @@ -44,7 +44,7 @@ func (g *Game) dropItemsOnDeath(p *player.Player) { items = append(items, deathDrop{ itemID: itemID, quantity: 1, - totalVal: val, + totalValue: val, isEquip: true, equipSlot: eqSlot, }) @@ -55,7 +55,7 @@ func (g *Game) dropItemsOnDeath(p *player.Player) { } sort.Slice(items, func(i, j int) bool { - return items[i].totalVal > items[j].totalVal + return items[i].totalValue > items[j].totalValue }) for i := 3; i < len(items); i++ { diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go index e35105a..376d7c7 100644 --- a/internal/game/sys_science.go +++ b/internal/game/sys_science.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v3" "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -182,7 +183,7 @@ func (g *Game) hasDeckEquipped(p *player.Player) bool { return def.WeaponType == object.WeaponScience } -func (g *Game) equippedProvidesJunk(p *player.Player) string { +func (g *Game) providesJunk(p *player.Player) string { itemID, ok := p.Equipment[object.SlotMainHand] if !ok { return "" @@ -206,7 +207,7 @@ func (g *Game) effectiveJunkCost(p *player.Player, mod *ModDef) map[string]int { delete(cost, "scrap_metal") } - providesJunk := g.equippedProvidesJunk(p) + providesJunk := g.providesJunk(p) if providesJunk != "" { delete(cost, providesJunk) } @@ -235,6 +236,31 @@ func (g *Game) consumeJunkCost(p *player.Player, mod *ModDef) bool { return true } +func (g *Game) triggerModReward(sess *net.Session, p *player.Player, mod *ModDef, multiplier float64, extraGains ...xpGain) int { + g.consumeJunkCost(p, mod) + + sciXP := int(float64(mod.BaseXP) * multiplier) + if sciXP < 1 { + sciXP = 1 + } + g.awardSkillXP(sess, p, player.Science, sciXP) + + for _, gain := range extraGains { + g.awardSkillXP(sess, p, player.SkillName(gain.Skill), gain.XP) + } + + g.AccountStore.SaveCharacter(p) + + if p.OptionBool("xp_drops") { + parts := []string{fmt.Sprintf("+%dxp %s", sciXP, player.SkillAbbr[player.Science])} + for _, gain := range extraGains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) + } + sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) + } + return sciXP +} + func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { cost := g.effectiveJunkCost(p, mod) delete(cost, "scrap_metal") @@ -259,7 +285,7 @@ func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { return strings.Join(parts, ", ") } -func (g *Game) totalEquipScienceAttack(p *player.Player) int { +func (g *Game) totalScienceAttack(p *player.Player) int { total := 0 for _, itemID := range p.Equipment { def, err := g.ItemStore.Load(itemID) diff --git a/internal/net/server.go b/internal/net/server.go index f82f82b..6706a3a 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -18,7 +18,7 @@ const ( StateAccountName SessionState = iota StatePassword StateNewAccountPass - StateNewAccountConfirm + StateNewAccount StateMenu StateNewCharName StateNewCharConfirm @@ -28,7 +28,7 @@ const ( StateDeleteChar StatePurgeAccount StateGame - StateChangeDescription + StateChangeDesc StateTalk StateDropAllConfirm StateRecipeChoice diff --git a/internal/player/validate.go b/internal/player/validate.go index d836089..1a2a5b9 100644 --- a/internal/player/validate.go +++ b/internal/player/validate.go @@ -6,10 +6,10 @@ import ( "unicode" ) -var validNameRE = regexp.MustCompile(`^[A-Za-z0-9 ]{1,30}$`) +var validNameRe = regexp.MustCompile(`^[A-Za-z0-9 ]{1,30}$`) func ValidName(name string) error { - if !validNameRE.MatchString(name) { + if !validNameRe.MatchString(name) { return fmt.Errorf("name must be 1-30 alphanumeric characters or spaces") } return nil diff --git a/internal/world/ground.go b/internal/world/ground.go index 6c3ecf3..879cc71 100644 --- a/internal/world/ground.go +++ b/internal/world/ground.go @@ -63,7 +63,7 @@ func (w *World) GroundItems(roomID int) map[string]int { return out } -func (w *World) AddReservedGroundItem(roomID int, itemID string, qty int, owner string) { +func (w *World) AddReservedItem(roomID int, itemID string, qty int, owner string) { w.mu.Lock() defer w.mu.Unlock() e := &groundEntry{ @@ -87,7 +87,7 @@ func (w *World) AddGroundItem(roomID int, itemID string, qty int) { w.groundItems[roomID] = append(w.groundItems[roomID], e) } -func (w *World) RemoveReservedGroundItem(roomID int, itemID string, qty int, owner string) (int, bool) { +func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner string) (int, bool) { w.mu.Lock() defer w.mu.Unlock() removed := 0 @@ -151,14 +151,14 @@ func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { return removed } -func (w *World) tickGroundItemsLocked() { +func (w *World) tickGroundItems() { for _, entries := range w.groundItems { for _, e := range entries { if e.respawnTimer > 0 { e.respawnTimer-- if e.respawnTimer <= 0 { e.quantity = e.respawnQty - w.mergeNearbyItemsLocked(entries, e) + w.mergeNearbyItems(entries, e) } } if e.despawnTimer > 0 { @@ -177,7 +177,7 @@ func (w *World) tickGroundItemsLocked() { } } -func (w *World) mergeNearbyItemsLocked(entries []*groundEntry, target *groundEntry) { +func (w *World) mergeNearbyItems(entries []*groundEntry, target *groundEntry) { for _, other := range entries { if other != target && other.quantity > 0 && strings.EqualFold(other.itemID, target.itemID) && (other.reserveTimer <= 0 || other.reservedFor == "") && diff --git a/internal/world/objects.go b/internal/world/objects.go index 568ea40..72e4a6d 100644 --- a/internal/world/objects.go +++ b/internal/world/objects.go @@ -39,10 +39,10 @@ func (w *World) ObjStateKey(roomID int, defID string, index int) string { func (w *World) EnsureObjectStates(roomID int, defIDs []string) { w.mu.Lock() defer w.mu.Unlock() - w.ensureObjectStatesLocked(roomID, defIDs) + w.ensureObjStates(roomID, defIDs) } -func (w *World) ensureObjectStatesLocked(roomID int, defIDs []string) { +func (w *World) ensureObjStates(roomID int, defIDs []string) { if w.objStates == nil { w.objStates = make(map[string]*ObjState) } diff --git a/internal/world/room.go b/internal/world/room.go index f006acc..9226a16 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -35,9 +35,7 @@ var OppositeExit = map[ExitDir]ExitDir{ } var ExitOrder = []ExitDir{ - "northwest", North, "northeast", - East, "southeast", South, "southwest", - West, Up, Down, + North, South, East, West, Up, Down, } type SpawnDef struct { diff --git a/internal/world/world.go b/internal/world/world.go index 0b40918..b305c4b 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -124,6 +124,6 @@ func (w *World) RoomIndex() map[int]bool { func (w *World) Tick() { w.mu.Lock() defer w.mu.Unlock() - w.tickGroundItemsLocked() + w.tickGroundItems() w.tickObjStatesLocked() } -- cgit v1.2.3