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