diff options
| author | historia <[not public]> | 2026-06-21 22:19:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-21 22:19:17 -0400 |
| commit | a3ae1e6aad696f584d138d9363c05dedff136a65 (patch) | |
| tree | 81e615f7227bc9d1ec79ceefc7311ecde917b074 /internal/game | |
| parent | 84072ffe6365ad57c4976e9272762aa6db41de7e (diff) | |
| download | thehouseoficarus-a3ae1e6aad696f584d138d9363c05dedff136a65.tar.gz | |
feat: safespot system implemented
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/action.go | 1 | ||||
| -rw-r--r-- | internal/game/action_state.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_attack.go | 88 | ||||
| -rw-r--r-- | internal/game/cmd_hide.go | 171 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 46 | ||||
| -rw-r--r-- | internal/game/cmd_map.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_option.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_score.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_stats.go | 6 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 29 | ||||
| -rw-r--r-- | internal/game/game.go | 18 | ||||
| -rw-r--r-- | internal/game/sys_combat.go | 3 | ||||
| -rw-r--r-- | internal/game/sys_safespot.go | 330 |
15 files changed, 684 insertions, 39 deletions
diff --git a/internal/game/action.go b/internal/game/action.go index d6ef1bc..9a07419 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -25,6 +25,7 @@ var verbAliases = map[string]string{ "ask": "talk", "steal": "steal", "thieve": "steal", + "hide": "safespot", } var verbSkill = map[string]string{ diff --git a/internal/game/action_state.go b/internal/game/action_state.go index 0ba3767..ffce545 100644 --- a/internal/game/action_state.go +++ b/internal/game/action_state.go @@ -29,6 +29,7 @@ const ( ActionCuring ActionType = "curing" ActionTraversing ActionType = "traversing" ActionHacking ActionType = "hacking" + ActionHiding ActionType = "hiding" ) type ActionState struct { @@ -98,6 +99,8 @@ func (a *ActionState) Description() string { return a.Verb + " across " + a.TargetName case ActionHacking: return "jacked into a " + a.TargetName + case ActionHiding: + return "positioning behind a " + a.TargetName } return "" } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 703b9ac..ce396e8 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "math/rand" "sort" "strconv" "strings" @@ -248,6 +249,15 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob) + if g.isSafespotted(p.Name) && attackType != "ranged" && attackType != "science" { + g.safespotMu.Lock() + ss, ok := g.safespotStates[p.Name] + g.safespotMu.Unlock() + if ok { + g.forceLeaveSafespot(sess, p, ss, "You leave your cover to close in for melee!") + } + } + if combat.HitCheck(attRoll, defRoll) { g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit) } else { @@ -277,25 +287,25 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) equipAttack := totals.RangedAttack effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged") - attRoll = combat.AttackRoll(effectiveRanged, rangedBonus, equipAttack) + attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack) - mobDefBonus := combat.SelectDefenseBonus(attackType, + mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) + defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength) } else { attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - equipAttack := combat.SelectAttackBonus(attackType, + equipAttack := combat.SelectBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack") - attRoll = combat.AttackRoll(effectiveAttack, attBonus, equipAttack) + attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, equipAttack) - mobDefBonus := combat.SelectDefenseBonus(attackType, + mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) + defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) } return @@ -322,12 +332,12 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack") - attRoll = combat.AttackRoll(effectiveAttack, attBonus, 0) + attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, 0) - mobDefBonus := combat.SelectDefenseBonus(attackType, + mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) + defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0) return } @@ -361,12 +371,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo gains := g.awardCombatXP(sess, p, dmg, isRanged) if isRanged { - p.AmmoQty-- - if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - } - g.AccountStore.SaveCharacter(p) + g.consumeAmmo(sess, p) } mobName := mobDisplayName(mob, true) @@ -404,18 +409,39 @@ func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.M sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You miss %s.", mobDisplayName(mob, true)))) if weaponType == object.WeaponRanged { - p.AmmoQty-- + g.consumeAmmo(sess, p) if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - g.AccountStore.SaveCharacter(p) sess.WriteLine("You've run out of ammo!") combat.LeaveCombat(p.Name) } } } +func (g *Game) consumeAmmo(sess *net.Session, p *player.Player) { + ammoID := p.Equipment[object.SlotAmmo] + + p.AmmoQty-- + if p.AmmoQty <= 0 { + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + } + + if ammoID != "" { + if def, err := g.ItemStore.Load(ammoID); err == nil && def.Recoverable { + if rand.Float64() < 0.80 { + g.World.AddGroundItem(p.RoomID, def.ID, 1) + } + } + } + + g.AccountStore.SaveCharacter(p) +} + func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) { + return + } + attRoll, defRoll := g.calculateMobAttack(p, mob) if combat.HitCheck(attRoll, defRoll) { @@ -433,13 +459,13 @@ func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (att mobAttackType = "crush" } - attRoll = combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) + attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus) totals := g.playerEquipBonuses(p) - equipDef := combat.SelectDefenseBonus(mobAttackType, + equipDef := combat.SelectBonus(mobAttackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) - defRoll = combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) + defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) return } @@ -504,6 +530,15 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP()) sess.WriteLine(prefix + hpSuffix) + g.safespotMu.Lock() + ss, hasSS := g.safespotStates[p.Name] + if hasSS && ss.HideCountdown > 0 { + delete(g.safespotStates, p.Name) + p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} + sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide.")) + } + g.safespotMu.Unlock() + if p.MoveTicks > 0 { p.ClearMoveState() p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} @@ -542,6 +577,13 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst p.Stats.RecordDeath() sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) p.ClearMoveState() + g.safespotMu.Lock() + if ss, ok := g.safespotStates[p.Name]; ok { + g.safespotMu.Unlock() + g.forceLeaveSafespot(sess, p, ss, "") + } else { + g.safespotMu.Unlock() + } g.dropItemsOnDeath(p) p.HP = p.MaxHP() p.RoomID = 1 diff --git a/internal/game/cmd_hide.go b/internal/game/cmd_hide.go new file mode 100644 index 0000000..9445949 --- /dev/null +++ b/internal/game/cmd_hide.go @@ -0,0 +1,171 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) { + p := sess.Player + + if len(args) == 0 { + sess.WriteLine("Hide behind what?") + return + } + + // Skip optional "behind" + target := strings.Join(args, " ") + if args[0] == "behind" { + if len(args) < 2 { + sess.WriteLine("Hide behind what?") + return + } + target = strings.Join(args[1:], " ") + } + + instances := g.World.FindObjInstances(p.RoomID, target) + if len(instances) == 0 { + sess.WriteLine("You don't see that here.") + return + } + + if len(instances) > 1 && objectInstancesNeedDisambiguation(instances) { + sess.WriteLines( + "That's ambiguous, which one?", + formatObjInstanceChoices(instances), + ) + return + } + + objDef, err := g.ObjectStore.Load(instances[0].DefID) + if err != nil || !objDef.IsSafespot() { + sess.WriteLine("You can't hide behind that.") + return + } + + cfg := objDef.Safespot + st := g.World.GetObjState(p.RoomID, objDef.ID, instances[0].Index) + if st == nil { + sess.WriteLine("You can't hide behind that.") + return + } + + g.safespotMu.Lock() + existingSS, alreadyHiding := g.safespotStates[p.Name] + g.safespotMu.Unlock() + + if alreadyHiding && existingSS.Active { + if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == instances[0].Index { + if existingSS.HideCountdown > 0 { + sess.WriteLine("You're already moving into position there.") + } else { + sess.WriteLine("You're already hiding there.") + } + return + } + g.leaveSafespot(sess, p) + } + + if g.safespotTier(p) < cfg.Tier { + sess.WriteLine("You can't figure out how to use this as effective cover.") + return + } + + if cfg.MaxOccupants > 0 && len(st.SafespotOccupants) >= cfg.MaxOccupants { + sess.WriteLines( + "", + fmt.Sprintf("%s already hiding there!", + joinSafespotOccupants(st.SafespotOccupants)), + ) + return + } + + if st.SafespotLevel <= 0 { + if cfg.RespawnTicks > 0 { + sess.WriteLine(fmt.Sprintf("The %s has been destroyed and hasn't reformed yet.", objDef.Name)) + } else { + sess.WriteLine(fmt.Sprintf("The %s has been completely destroyed.", objDef.Name)) + } + return + } + + inCombat := combat.GetCombat(p.Name) != nil + hideCountdown := 1 + if inCombat { + hideCountdown = 4 + } + + g.safespotMu.Lock() + g.safespotStates[p.Name] = &SafespotState{ + Active: true, + ObjectDefID: objDef.ID, + ObjectIndex: instances[0].Index, + RoomID: p.RoomID, + HideCountdown: hideCountdown, + } + g.safespotMu.Unlock() + + p.ActionState = &ActionState{Type: ActionHiding, TargetName: objDef.Name} + + if inCombat { + sess.WriteLine(fmt.Sprintf("\nYou try to get behind the %s for cover...", objDef.Name)) + } else { + sess.WriteLine(fmt.Sprintf("You position yourself behind the %s.", objDef.Name)) + } +} + +func (g *Game) executeUnhide(sess *net.Session, args []string, rawInput string) { + p := sess.Player + + g.safespotMu.Lock() + ss, ok := g.safespotStates[p.Name] + g.safespotMu.Unlock() + + if !ok || !ss.Active { + sess.WriteLine("You're not hiding behind anything.") + return + } + + if ss.HideCountdown > 0 { + g.safespotMu.Lock() + delete(g.safespotStates, p.Name) + g.safespotMu.Unlock() + p.ActionState = nil + sess.WriteLine("You stop trying to hide.") + return + } + + g.leaveSafespot(sess, p) +} + +func objectInstancesNeedDisambiguation(instances []world.ObjState) bool { + if len(instances) < 2 { + return false + } + first := instances[0].DefID + for i := 1; i < len(instances); i++ { + if instances[i].DefID != first { + return true + } + } + return false +} + +func formatObjInstanceChoices(instances []world.ObjState) string { + var lines []string + for _, inst := range instances { + lines = append(lines, fmt.Sprintf(" %d.%s", inst.Index+1, inst.Name)) + } + return strings.Join(lines, "\n") +} + +func joinSafespotOccupants(names []string) string { + if len(names) == 1 { + return fmt.Sprintf("%s is", names[0]) + } + return fmt.Sprintf("%s are", strings.Join(names, ", ")) +} diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 936a6b0..4cc556a 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -124,7 +124,11 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. for _, objID := range order { count := grouped[objID] def, err := g.ObjectStore.Load(objID) - if err != nil || def.Hidden { + if err != nil { + continue + } + + if def.Hidden && (def.Safespot == nil || g.safespotTier(p) < def.Safespot.Tier) { continue } @@ -132,6 +136,24 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. continue } + if def.Hidden && def.Safespot != nil { + st := g.World.GetObjState(p.RoomID, objID, 0) + levelInfo := "" + if st != nil { + if st.SafespotLevel <= 0 && def.Safespot.RespawnTicks > 0 { + continue + } + if st.SafespotLevel <= 0 { + levelInfo = " (destroyed)" + } else if st.SafespotLevel < len(def.Safespot.Levels) { + levelInfo = " (degraded)" + } + } + sess.WriteLine(fmt.Sprintf(" %s that could provide cover.%s", + g.objColorize(sess, def, def.Name), levelInfo)) + continue + } + type instInfo struct { idx int depleted bool @@ -522,6 +544,28 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf(" %s", def.Description)) } + if def.Safespot != nil { + realSt := g.World.GetObjState(p.RoomID, st.DefID, st.Index) + if realSt != nil { + levelStr := "intact" + if realSt.SafespotLevel <= 0 { + levelStr = "destroyed" + } else if realSt.SafespotLevel < len(def.Safespot.Levels) { + levelStr = fmt.Sprintf("degraded (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels)) + } else { + levelStr = fmt.Sprintf("intact (level %d/%d)", realSt.SafespotLevel, len(def.Safespot.Levels)) + } + blockInfo := "anything" + if def.Safespot.MaxBlockSize != "" { + blockInfo = fmt.Sprintf("up to %s mobs", def.Safespot.MaxBlockSize) + } + sess.WriteLine(fmt.Sprintf(" Safespot: %s — blocks %s", levelStr, blockInfo)) + if len(realSt.SafespotOccupants) > 0 { + sess.WriteLine(fmt.Sprintf(" Occupied by: %s", strings.Join(realSt.SafespotOccupants, ", "))) + } + } + } + if p.OptionBool("depletion") { for _, ist := range instances { if ist.Depleted { diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go index be35a66..90f58ab 100644 --- a/internal/game/cmd_map.go +++ b/internal/game/cmd_map.go @@ -10,8 +10,8 @@ func (g *Game) executeMap(sess *net.Session, args []string, rawInput string) { func (g *Game) doMap(sess *net.Session) { p := sess.Player - mapWidth := p.OptionInt("mapwidth") - mapHeight := p.OptionInt("mapheight") + mapWidth := p.OptionInt("map_width") + mapHeight := p.OptionInt("map_height") if mapWidth < 5 { mapWidth = 5 } @@ -21,7 +21,7 @@ func (g *Game) doMap(sess *net.Session) { lines := buildFullMap(g, p.RoomID, mapWidth, mapHeight, mapGlyphsForPlayer(p.OptionBool("unicode"))) - mode := p.OptionString("mappadding") + mode := p.OptionString("map_padding") if mode == "none" || mode == "x" { lines = stripBlankRows(lines) } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index f921a81..a5f1690 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -117,6 +117,14 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { p.Action = nil + g.safespotMu.Lock() + if ss, ok := g.safespotStates[p.Name]; ok { + g.safespotMu.Unlock() + g.forceLeaveSafespot(sess, p, ss, "") + } else { + g.safespotMu.Unlock() + } + oldRoom := p.RoomID p.RoomID = targetID p.Stats.RecordRoomVisit(targetID) diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go index 19256ba..ff7725c 100644 --- a/internal/game/cmd_option.go +++ b/internal/game/cmd_option.go @@ -26,6 +26,7 @@ func (g *Game) doOption(sess *net.Session, input string) { } for _, def := range player.OptionDefs { val := formatOptionValue(p, &def) + val = truncateForTable(val) valid := formatValidValues(&def) table.Rows = append(table.Rows, []string{ color.Render(mode, color.Parse("75"), def.Name), @@ -148,3 +149,10 @@ func parseOptionValue(def *player.OptionDef, input string) (any, bool) { } return nil, false } + +func truncateForTable(s string) string { + if len(s) <= 10 { + return s + } + return s[:7] + "..." +} diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 4824684..381f34d 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -125,6 +125,10 @@ var commandRegistry = map[string]commandDef{ "syntax": {(*Game).executeVerbs, ClassInstant}, "commands": {(*Game).executeVerbs, ClassInstant}, "stop": {(*Game).executeStop, ClassInstant}, + "hide": {(*Game).executeHide, ClassActive}, + "safespot": {(*Game).executeHide, ClassActive}, + "unhide": {(*Game).executeUnhide, ClassActive}, + "unsafespot": {(*Game).executeUnhide, ClassActive}, } func classifyCommand(cmd string) CommandClass { diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index d0302c2..9f3c786 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -98,7 +98,7 @@ func (g *Game) doScore(sess *net.Session) { creditsStr := g.colorize(sess, "credits_pickup", formatInt(p.Credits)) styleStr := color.Render(mode, color.Parse("75"), fmt.Sprintf("Style: %s", p.AttackStyle)) - invStr := fmt.Sprintf("Inv: %d/28 used", 28-p.FreeSlots()) + invStr := fmt.Sprintf("Inv: %d/28", 28-p.FreeSlots()) row2 := padded("Credits: "+creditsStr, 20) + padded(styleStr, 24) + invStr content(row2) diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go index 333d535..9987a1d 100644 --- a/internal/game/cmd_stats.go +++ b/internal/game/cmd_stats.go @@ -60,14 +60,14 @@ func (g *Game) doStats(sess *net.Session) { var attRoll, maxHitVal int if weaponType == object.WeaponRanged { rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) - attRoll = combat.AttackRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack) + attRoll = combat.EffectiveRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack) maxHitVal = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength) } else { attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - equipAtt := combat.SelectAttackBonus(attackType, + equipAtt := combat.SelectBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) - attRoll = combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) + attRoll = combat.EffectiveRoll(p.Level(player.Attack), attBonus, equipAtt) maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus) } diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index a2c1bd8..093ae71 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) @@ -172,8 +173,20 @@ func (g *Game) handleColorChoice(sess *net.Session, input string) { g.showMenu(sess) } +func menuColorMode(sess *net.Session) string { + if sess != nil && sess.Account != nil && sess.Account.Options != nil { + if v, ok := sess.Account.Options["color"]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "xterm256" +} + func (g *Game) showMenu(sess *net.Session) { sess.State = net.StateMenu + mode := menuColorMode(sess) lines := []string{ "", fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name), @@ -181,18 +194,18 @@ func (g *Game) showMenu(sess *net.Session) { } if len(sess.Account.Characters) > 0 { lines = append(lines, - " (C)onnect character to THOI", + color.ExpandTags(mode, " {3}(C){/}{4}onnect character to THOI{/}"), "", - " (L)ist characters", - " (R)ename character", - " (D)elete character", + color.ExpandTags(mode, " {3}(L){/}{4}ist characters{/}"), + color.ExpandTags(mode, " {3}(R){/}{4}ename character{/}"), + color.ExpandTags(mode, " {3}(D){/}{4}elete character{/}"), ) } lines = append(lines, - " (N)ew character", - " (P)urge account", - " (A)ccount rename", - " (Q)uit", + color.ExpandTags(mode, " {3}(N){/}{4}ew character{/}"), + color.ExpandTags(mode, " {3}(P){/}{4}urge account{/}"), + color.ExpandTags(mode, " {3}(A){/}{4}ccount rename{/}"), + color.ExpandTags(mode, " {3}(Q){/}{4}uit{/}"), "", ) sess.WriteLines(lines...) diff --git a/internal/game/game.go b/internal/game/game.go index 8bf289b..d3bb391 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -57,6 +57,16 @@ type Game struct { guardWatchTimers map[string]int farmTickCounter int hackingStates map[string]*hacking.Session + safespotMu sync.Mutex + safespotStates map[string]*SafespotState +} + +type SafespotState struct { + Active bool + ObjectDefID string + ObjectIndex int + RoomID int + HideCountdown int } func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game { @@ -81,6 +91,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali pendingDepletions: nil, guardWatchTimers: make(map[string]int), hackingStates: make(map[string]*hacking.Session), + safespotStates: make(map[string]*SafespotState), } g.CourseStore.LoadAll() g.LoadMods() @@ -98,6 +109,13 @@ func (g *Game) SetHub(hub *net.Hub) { delete(g.loggedInChars, p.Name) g.charsMu.Unlock() delete(g.hackingStates, p.Name) + g.safespotMu.Lock() + if ss, ok := g.safespotStates[p.Name]; ok { + g.safespotMu.Unlock() + g.forceLeaveSafespot(sess, p, ss, "") + } else { + g.safespotMu.Unlock() + } } }) } diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go index 81b2c83..91a07cf 100644 --- a/internal/game/sys_combat.go +++ b/internal/game/sys_combat.go @@ -90,6 +90,9 @@ func (g *Game) checkAggro(sess *net.Session) { if playerLevel > mobLevel*2 { continue } + if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) { + continue + } aggMob := mob g.Ticks.Subscribe(engine.ToTicks(1), func() bool { if combat.GetCombat(p.Name) != nil { diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go new file mode 100644 index 0000000..ffb7948 --- /dev/null +++ b/internal/game/sys_safespot.go @@ -0,0 +1,330 @@ +package game + +import ( + "fmt" + "math/rand" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +var sizeRank = map[string]int{ + "small": 1, + "medium": 2, + "large": 3, + "massive": 4, +} + +func (g *Game) isSafespotted(playerName string) bool { + g.safespotMu.Lock() + defer g.safespotMu.Unlock() + ss, ok := g.safespotStates[playerName] + return ok && ss.Active && ss.HideCountdown <= 0 +} + +func (g *Game) safespotTier(p *player.Player) int { + tier := 1 + if getPlayerFlagInt(p, "unlocked_rock_cover") > 0 { + tier++ + } + return tier +} + +func blocksMob(maxBlockSize, mobSize string) bool { + if maxBlockSize == "" { + return true + } + max := sizeRank[maxBlockSize] + mob := sizeRank[mobSize] + if mob == 0 { + mob = sizeRank["medium"] + } + return mob <= max +} + +func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool { + g.safespotMu.Lock() + ss, ok := g.safespotStates[p.Name] + g.safespotMu.Unlock() + if !ok || !ss.Active || ss.HideCountdown > 0 { + return false + } + + objDef, err := g.ObjectStore.Load(ss.ObjectDefID) + if err != nil || objDef.Safespot == nil { + return false + } + + if !blocksMob(objDef.Safespot.MaxBlockSize, mob.Size) { + return false + } + + if mob.AttackType == "ranged" || mob.AttackType == "science" { + return false + } + + return true +} + +func (g *Game) sessionInRoom(name string, roomSessions []*net.Session) *net.Session { + for _, sess := range roomSessions { + if sess.Player != nil && sess.Player.Name == name { + return sess + } + } + return nil +} + +func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *SafespotState) { + objDef, err := g.ObjectStore.Load(ss.ObjectDefID) + if err != nil || objDef.Safespot == nil { + g.safespotMu.Lock() + delete(g.safespotStates, p.Name) + g.safespotMu.Unlock() + sess.WriteLine("The safespot no longer exists.") + return + } + + cfg := objDef.Safespot + st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex) + if st == nil { + g.safespotMu.Lock() + delete(g.safespotStates, p.Name) + g.safespotMu.Unlock() + sess.WriteLine("The safespot no longer exists.") + return + } + + if cfg.RespawnOnHide { + st.SafespotLevel = len(cfg.Levels) + st.SafespotTicksAtLevel = 0 + } else if st.SafespotLevel <= 0 { + g.safespotMu.Lock() + delete(g.safespotStates, p.Name) + g.safespotMu.Unlock() + sess.WriteLine(fmt.Sprintf("The %s has been completely destroyed.", objDef.Name)) + return + } + + st.SafespotOccupants = append(st.SafespotOccupants, p.Name) + p.ActionState = nil + + var msg string + levelIdx := st.SafespotLevel - 1 + if levelIdx >= 0 && levelIdx < len(cfg.Levels) && cfg.Levels[levelIdx].Message != "" { + msg = cfg.Levels[levelIdx].Message + } else { + msg = fmt.Sprintf("You crouch behind the %s, using it as cover.", objDef.Name) + } + sess.WriteLine(g.colorize(sess, "combat_info", msg)) +} + +func (g *Game) safespotMaintenance(sess *net.Session, p *player.Player, ss *SafespotState) { + objDef, err := g.ObjectStore.Load(ss.ObjectDefID) + if err != nil || objDef.Safespot == nil { + g.forceLeaveSafespot(sess, p, ss, "The safespot no longer exists.") + return + } + + cfg := objDef.Safespot + st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex) + if st == nil { + g.forceLeaveSafespot(sess, p, ss, "The safespot no longer exists.") + return + } + + if st.SafespotLevel <= 0 { + if cfg.RespawnTicks > 0 { + return + } + g.forceLeaveSafespot(sess, p, ss, fmt.Sprintf("The %s has crumbled away!", objDef.Name)) + return + } + + if len(st.SafespotOccupants) == 0 { + return + } + + if cfg.UnsafeChance > 0 && rand.Float64() < cfg.UnsafeChance { + alert := p.OptionString("safespot_alert") + g.forceLeaveSafespot(sess, p, ss, "You slip and are no longer covered!") + if alert != "" { + sess.WriteLine(color.ExpandTags(g.colorMode(sess), alert)) + } + return + } + + levelIdx := st.SafespotLevel - 1 + degraded := false + + if cfg.DecayChance > 0 && rand.Float64() < cfg.DecayChance { + degraded = true + } + + if !degraded && cfg.DecayTicks > 0 { + st.SafespotTicksAtLevel++ + if st.SafespotTicksAtLevel >= engine.ToTicks(cfg.DecayTicks) { + degraded = true + } + } + + if degraded { + g.degradeSafespot(st, objDef, cfg, levelIdx) + } +} + +func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg *object.SafespotConfig, currentLevelIdx int) { + roomSessions := g.Hub.PlayersInRoom(st.RoomID) + + if currentLevelIdx >= 0 && currentLevelIdx < len(cfg.Levels) { + msg := cfg.Levels[currentLevelIdx].DegradeMessage + if msg != "" { + for _, rs := range roomSessions { + rs.WriteLine("") + rs.WriteLine(color.ExpandTags(g.colorMode(rs), msg)) + } + } + } + + st.SafespotLevel-- + st.SafespotTicksAtLevel = 0 + + if st.SafespotLevel <= 0 { + for _, name := range st.SafespotOccupants { + g.safespotMu.Lock() + delete(g.safespotStates, name) + g.safespotMu.Unlock() + if occSess := g.sessionInRoom(name, roomSessions); occSess != nil { + occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), + fmt.Sprintf("The %s crumbles away!", objDef.Name))) + if occSess.Player != nil { + alert := occSess.Player.OptionString("safespot_alert") + if alert != "" { + occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), alert)) + } + } + g.writePrompt(occSess) + } + } + st.SafespotOccupants = nil + g.scheduleSafespotRespawn(st, objDef, cfg) + return + } + + newLevelIdx := st.SafespotLevel - 1 + if newLevelIdx >= 0 && newLevelIdx < len(cfg.Levels) && cfg.Levels[newLevelIdx].Message != "" { + msg := cfg.Levels[newLevelIdx].Message + for _, name := range st.SafespotOccupants { + if occSess := g.sessionInRoom(name, roomSessions); occSess != nil { + occSess.WriteLine("") + occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), msg)) + } + } + } +} + +func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.ObjectDef, cfg *object.SafespotConfig) { + if cfg.RespawnTicks <= 0 { + return + } + roomID := st.RoomID + defID := objDef.ID + g.Ticks.Subscribe(engine.ToTicks(cfg.RespawnTicks), func() bool { + respawnSt := g.World.GetObjState(roomID, defID, 0) + if respawnSt == nil { + return false + } + respawnSt.SafespotLevel = len(cfg.Levels) + respawnSt.SafespotTicksAtLevel = 0 + for _, sess := range g.Hub.PlayersInRoom(roomID) { + sess.WriteLine("") + sess.WriteLine(color.ExpandTags(g.colorMode(sess), + fmt.Sprintf("The %s reforms and looks stable again.", objDef.Name))) + } + return false + }) +} + +func (g *Game) leaveSafespot(sess *net.Session, p *player.Player) { + g.safespotMu.Lock() + ss, ok := g.safespotStates[p.Name] + if !ok { + g.safespotMu.Unlock() + return + } + delete(g.safespotStates, p.Name) + g.safespotMu.Unlock() + + g.removeSafespotOccupant(p.Name, ss) + + objDef, _ := g.ObjectStore.Load(ss.ObjectDefID) + objName := "cover" + if objDef != nil { + objName = objDef.Name + } + sess.WriteLine(fmt.Sprintf("You step out from behind the %s.", objName)) + p.ActionState = nil +} + +func (g *Game) forceLeaveSafespot(sess *net.Session, p *player.Player, ss *SafespotState, reason string) { + g.safespotMu.Lock() + delete(g.safespotStates, p.Name) + g.safespotMu.Unlock() + + g.removeSafespotOccupant(p.Name, ss) + + if reason != "" { + sess.WriteLine(reason) + } + p.ActionState = nil +} + +func (g *Game) removeSafespotOccupant(playerName string, ss *SafespotState) { + st := g.World.GetObjState(ss.RoomID, ss.ObjectDefID, ss.ObjectIndex) + if st == nil { + return + } + for i, name := range st.SafespotOccupants { + if name == playerName { + st.SafespotOccupants = append(st.SafespotOccupants[:i], st.SafespotOccupants[i+1:]...) + return + } + } +} + +func (g *Game) SafespotTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p := sess.Player + if p == nil { + continue + } + + g.safespotMu.Lock() + ss, ok := g.safespotStates[p.Name] + if !ok || !ss.Active { + g.safespotMu.Unlock() + continue + } + + if ss.HideCountdown > 0 { + ss.HideCountdown-- + if ss.HideCountdown > 0 { + g.safespotMu.Unlock() + continue + } + g.safespotMu.Unlock() + g.activateSafespot(sess, p, ss) + continue + } + g.safespotMu.Unlock() + + g.safespotMaintenance(sess, p, ss) + } +} |
