diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/action.go | 57 | ||||
| -rw-r--r-- | internal/game/action_room.go | 179 | ||||
| -rw-r--r-- | internal/game/action_state.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_attack.go | 275 | ||||
| -rw-r--r-- | internal/game/cmd_consume.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 92 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 67 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 1 | ||||
| -rw-r--r-- | internal/game/cmd_score.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_stats.go | 2 | ||||
| -rw-r--r-- | internal/game/condition_test.go | 155 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 21 | ||||
| -rw-r--r-- | internal/game/core_startup.go | 53 | ||||
| -rw-r--r-- | internal/game/game.go | 6 | ||||
| -rw-r--r-- | internal/game/map_test.go | 36 | ||||
| -rw-r--r-- | internal/game/sys_hazard.go | 136 | ||||
| -rw-r--r-- | internal/game/sys_labor.go | 81 | ||||
| -rw-r--r-- | internal/game/sys_safespot.go | 7 | ||||
| -rw-r--r-- | internal/game/sys_technology.go | 30 |
19 files changed, 1047 insertions, 160 deletions
diff --git a/internal/game/action.go b/internal/game/action.go index 9a07419..8d50eea 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -284,22 +284,17 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { } if c.PlayerFlag != "" { - if p == nil || p.Flags == nil { - return c.Not + var val any + present := false + if p != nil && p.Flags != nil { + val, present = p.Flags[c.PlayerFlag] } - val, ok := p.Flags[c.PlayerFlag] - if c.Not { - return !ok || val != c.Value - } - return ok && val == c.Value + return flagMatches(present, val, c.Value, c.Not) } if c.Flag != "" { - val, ok := g.WorldFlags[c.Flag] - if c.Not { - return !ok || val != c.Value - } - return ok && val == c.Value + val, present := g.WorldFlags[c.Flag] + return flagMatches(present, val, c.Value, c.Not) } if c.HasItem != "" { has := p != nil && p.HasItem(c.HasItem) @@ -317,3 +312,41 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { } return true } + +// flagMatches evaluates a flag/player_flag condition. +// +// If the condition specifies no `value`, the flag matches when it is present +// and truthy (so `{player_flag: x}` means "x is set" and +// `{player_flag: x, not: true}` means "x is not set"). If a `value` is given, +// the flag must be present and equal to it. `not` inverts the result. +func flagMatches(present bool, val, want any, not bool) bool { + var match bool + if want == nil { + match = present && isTruthy(val) + } else { + match = present && val == want + } + if not { + return !match + } + return match +} + +func isTruthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case int: + return x != 0 + case int64: + return x != 0 + case float64: + return x != 0 + case string: + return x != "" + default: + return true + } +} diff --git a/internal/game/action_room.go b/internal/game/action_room.go index af5b99b..6c886c1 100644 --- a/internal/game/action_room.go +++ b/internal/game/action_room.go @@ -5,19 +5,194 @@ import ( "strings" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" ) +// enterSeq is an in-flight on_enter cutscene for a single player. It is driven +// one step at a time by EnterSeqTick. +type enterSeq struct { + sess *net.Session + playerName string + roomID int + steps []world.EnterStep + wait int + started bool +} + +// runEnterSteps evaluates a room's on_enter script for the player. Steps whose +// condition fails are dropped. If none of the surviving steps are "timed" +// (no delay, no flag mutation) the messages are printed synchronously, exactly +// as before. Otherwise the surviving steps become a scheduled cutscene driven +// by EnterSeqTick. func (g *Game) runEnterSteps(sess *net.Session, roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil || len(room.OnEnter) == 0 { return } + p := sess.Player + if p == nil { + return + } + + var steps []world.EnterStep + cutscene := false for _, step := range room.OnEnter { if step.Condition != nil && !g.checkCondition(sess, step.Condition) { continue } - if step.Message != "" { - sess.WriteLine(fmt.Sprintf("%s", step.Message)) + steps = append(steps, step) + if step.IsTimed() { + cutscene = true + } + } + if len(steps) == 0 { + return + } + + if !cutscene { + for _, step := range steps { + if step.Message != "" { + sess.WriteLine(step.Message) + } + } + return + } + + g.startEnterCutscene(sess, p, roomID, steps) +} + +// startEnterCutscene registers a scheduled on_enter sequence and persists a +// marker on the player so the cutscene can be resumed on reconnect if it is +// interrupted (disconnect, server restart). +func (g *Game) startEnterCutscene(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) { + g.cancelEnterSeq(p.Name) + if p.CutsceneRoom != roomID { + p.CutsceneRoom = roomID + g.AccountStore.SaveCharacter(p) + } + g.enterMu.Lock() + g.enterSeqs[p.Name] = &enterSeq{ + sess: sess, + playerName: p.Name, + roomID: roomID, + steps: steps, + wait: steps[0].Delay, + } + g.enterMu.Unlock() +} + +// EnterSeqTick advances every in-flight cutscene by one tick. Sequences for a +// player who is no longer connected are paused (left for resume); sequences are +// removed from the map on completion or cancellation. +func (g *Game) EnterSeqTick() { + g.enterMu.Lock() + seqs := make([]*enterSeq, 0, len(g.enterSeqs)) + for _, s := range g.enterSeqs { + seqs = append(seqs, s) + } + g.enterMu.Unlock() + + for _, seq := range seqs { + if !g.isCharLive(seq.playerName, seq.sess) { + continue + } + + var jobs []world.EnterStep + done := false + + g.enterMu.Lock() + if g.enterSeqs[seq.playerName] != seq { + g.enterMu.Unlock() + continue + } + seq.wait-- + for seq.wait <= 0 && len(seq.steps) > 0 { + step := seq.steps[0] + seq.steps = seq.steps[1:] + jobs = append(jobs, step) + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Delay + } else { + seq.wait = 0 + } + } + if len(seq.steps) == 0 { + done = true + delete(g.enterSeqs, seq.playerName) + } + g.enterMu.Unlock() + + for _, step := range jobs { + g.fireEnterStep(seq, step) + } + if done { + g.completeEnterSeq(seq) + } + } +} + +func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) { + p := seq.sess.Player + if p == nil || p.RoomID != seq.roomID { + return + } + if step.Message != "" { + // Break from the prompt on the first line only, so a multi-line + // cutscene reads as tight consecutive lines. + if !seq.started { + seq.sess.WriteLine("\n" + step.Message) + seq.started = true + } else { + seq.sess.WriteLine(step.Message) + } + } + if len(step.SetFlags) > 0 || len(step.SetPlayerFlags) > 0 { + g.applyFlagMutations(p, step.SetFlags, step.SetPlayerFlags) + g.AccountStore.SaveCharacter(p) + } +} + +func (g *Game) completeEnterSeq(seq *enterSeq) { + if !g.isCharLive(seq.playerName, seq.sess) { + return + } + p := seq.sess.Player + if p == nil { + return + } + if p.CutsceneRoom == seq.roomID { + p.CutsceneRoom = 0 + g.AccountStore.SaveCharacter(p) + } + g.writePrompt(seq.sess) +} + +// cancelEnterSeq drops any in-flight cutscene for the player. It does not touch +// the persisted CutsceneRoom marker, so a cutscene cancelled by disconnect can +// still be resumed on reconnect. +func (g *Game) cancelEnterSeq(name string) { + g.enterMu.Lock() + delete(g.enterSeqs, name) + g.enterMu.Unlock() +} + +func (g *Game) isCharLive(name string, sess *net.Session) bool { + g.charsMu.Lock() + defer g.charsMu.Unlock() + return g.loggedInChars[name] == sess +} + +// applyFlagMutations sets world and player flags, shared by on_enter steps and +// exit traversal. +func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) { + for k, v := range setFlags { + g.WorldFlags[k] = v + } + if len(setPlayerFlags) > 0 { + p.EnsureFlags() + for k, v := range setPlayerFlags { + p.Flags[k] = v } } } diff --git a/internal/game/action_state.go b/internal/game/action_state.go index ffce545..bed936d 100644 --- a/internal/game/action_state.go +++ b/internal/game/action_state.go @@ -30,6 +30,7 @@ const ( ActionTraversing ActionType = "traversing" ActionHacking ActionType = "hacking" ActionHiding ActionType = "hiding" + ActionWorking ActionType = "working" ) type ActionState struct { @@ -101,6 +102,8 @@ func (a *ActionState) Description() string { return "jacked into a " + a.TargetName case ActionHiding: return "positioning behind a " + a.TargetName + case ActionWorking: + return "working on a " + a.TargetName } return "" } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 1f18d00..f3b99c0 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -35,7 +35,11 @@ func (g *Game) doAttack(sess *net.Session, input string) { } if mob.HP <= 0 { - sess.WriteLine("That is already dead.") + if mob.IsTask() { + sess.WriteLine(fmt.Sprintf("%s is already complete.", mobDisplayName(mob, true))) + } else { + sess.WriteLine("That is already dead.") + } return } @@ -133,6 +137,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn combat.EnterCombat(p.Name, mob.InstanceID) p.AttackTimer = 0 + isTask := mob.IsTask() autotriggerActive := p.AutotriggerMod != "" if !autotriggerActive { @@ -151,20 +156,35 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn if len(styleParts) > 0 { styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")" } - sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr)) + if isTask { + sess.WriteLine(fmt.Sprintf("You begin to %s %s.%s", taskWorkVerb(mob), g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr)) + } else { + sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr)) + } } else { mod := GetMod(p.AutotriggerMod) modName := p.AutotriggerMod if mod != nil { modName = mod.Name } - sess.WriteLine(fmt.Sprintf("You attack %s with %s!", - g.colorize(sess, "mob", mobDisplayName(mob, true)), - g.colorize(sess, "science_mod", modName))) + if isTask { + sess.WriteLine(fmt.Sprintf("You begin to %s %s using %s!", + taskWorkVerb(mob), + g.colorize(sess, "mob", mobDisplayName(mob, true)), + g.colorize(sess, "science_mod", modName))) + } else { + sess.WriteLine(fmt.Sprintf("You attack %s with %s!", + g.colorize(sess, "mob", mobDisplayName(mob, true)), + g.colorize(sess, "science_mod", modName))) + } + } + if isTask { + p.ActionState = &ActionState{Type: ActionWorking, TargetName: mob.Name} + g.broadcastAction(sess, "\n%s begins working on %s.", p.Name, mobDisplayName(mob, true)) + } else { + p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} + g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true)) } - p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} - - g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true)) g.Ticks.Subscribe(1, func() bool { cs := combat.GetCombat(p.Name) @@ -223,22 +243,24 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn return true }) - g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool { - cs := combat.GetCombat(p.Name) - if cs == nil || !cs.Active { - return false - } - currentMob := g.MobStore.GetInstance(cs.MobID) - if currentMob == nil || currentMob.HP <= 0 { - return false - } - g.mobAttack(sess, p, currentMob) - if p.HP <= 0 { - g.endCombat(sess, p, currentMob) - return false - } - return true - }) + if !isTask { + g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool { + cs := combat.GetCombat(p.Name) + if cs == nil || !cs.Active { + return false + } + currentMob := g.MobStore.GetInstance(cs.MobID) + if currentMob == nil || currentMob.HP <= 0 { + return false + } + g.mobAttack(sess, p, currentMob) + if p.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + return true + }) + } } func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { @@ -299,8 +321,8 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc 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.EffectiveRoll(effectiveAttack, attBonus, equipAttack) + effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") + attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack) mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, @@ -331,8 +353,8 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan weaponType = object.WeaponMelee attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack") - attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, 0) + effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") + attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0) mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, @@ -374,30 +396,34 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo g.consumeAmmo(sess, p) } - mobName := mobDisplayName(mob, true) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)) - if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w { - w = w2 - } - prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg))) - visLen := color.VisibleLen(prefix) - if visLen < w { - prefix += strings.Repeat(" ", w-visLen+1) - } - hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP) - line := prefix + hpSuffix - if p.OptionBool("xp_drops") && len(gains) > 0 { - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) + if mob.IsTask() { + g.writeTaskProgress(sess, p, mob, dmg, gains) + } else { + mobName := mobDisplayName(mob, true) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)) + if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w { + w = w2 + } + prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg))) + visLen := color.VisibleLen(prefix) + if visLen < w { + prefix += strings.Repeat(" ", w-visLen+1) + } + hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP) + line := prefix + hpSuffix + if p.OptionBool("xp_drops") && len(gains) > 0 { + var parts []string + for _, gain := range gains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) + } + line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") } - line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") + sess.WriteLine(line) } - sess.WriteLine(line) if isRanged && p.AmmoQty <= 0 { sess.WriteLine("You've run out of ammo!") @@ -559,60 +585,51 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst p.ActionState = nil if p.HP <= 0 { - if p.HasActiveTech("retribution") { - retDef := GetTechDef("retribution") - if retDef != nil && mob != nil && mob.HP > 0 { - retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct) - if retDmg > 0 { - mob.HP -= retDmg - if mob.HP < 0 { - mob.HP = 0 - } - sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!", - mobDisplayName(mob, true), retDmg)) - } - } - } - p.DeactivateAllTechs() - 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 - g.AccountStore.SaveCharacter(p) - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - g.doLook(sess) - g.writePrompt(sess) + g.killPlayer(sess, p, mob) return } if mob != nil && mob.HP <= 0 { + isTask := mob.IsTask() p.Stats.RecordMobKill(mob.DefID) - sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) - g.onAssassinKill(sess, p, mob) + if isTask { + complete := mob.CompleteMessage + if complete == "" { + complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true)) + } + sess.WriteLine(g.colorize(sess, "victory", "\n"+complete)) + } else { + sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) + g.onAssassinKill(sess, p, mob) + } if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess && other.Player != nil { - op := other.Player - mobLvl := mobCombatLevel(mob) - levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) + if isTask { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s finishes working on %s.", p.Name, mobDisplayName(mob, false)))) + } else { + op := other.Player + mobLvl := mobCombatLevel(mob) + levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) + } } } } + dropLabel := func() string { + if isTask { + return "You receive:" + } + dropper := mob.Name + if !mob.Unique { + dropper = "The " + mob.Name + } + return dropper + " drops:" + }() + if mob.Drops.Remains != "" { g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name) def, _ := g.ItemStore.Load(mob.Drops.Remains) @@ -620,12 +637,8 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst if def != nil { name = def.Name } - dropper := mob.Name - if !mob.Unique { - dropper = "The " + mob.Name - } coloredName := g.itemColorize(sess, def, name) - sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName)) + sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) } if len(mob.Drops.Loot) > 0 { @@ -641,15 +654,11 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst if def != nil { name = def.Name } - dropper := mob.Name - if !mob.Unique { - dropper = "The " + mob.Name - } coloredName := g.itemColorize(sess, def, name) if qty > 1 { - sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropper+" drops:"), qty, coloredName)) + sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName)) } else { - sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName)) + sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) } } } @@ -667,6 +676,50 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } } +// killPlayer handles a player death from any source (combat or a room hazard). +// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch +// retribution. +func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) { + combat.LeaveCombat(p.Name) + p.ActionState = nil + + if p.HasActiveTech("retribution") { + retDef := GetTechDef("retribution") + if retDef != nil && mob != nil && mob.HP > 0 { + retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct) + if retDmg > 0 { + mob.HP -= retDmg + if mob.HP < 0 { + mob.HP = 0 + } + sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!", + mobDisplayName(mob, true), retDmg)) + } + } + } + p.DeactivateAllTechs() + p.Stats.RecordDeath() + sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) + p.ClearMoveState() + p.HazardTimer = 0 + 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 = 1001 + g.AccountStore.SaveCharacter(p) + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + g.doLook(sess) + g.writePrompt(sess) +} + func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain { baseXP := dmg * 4 var gains []xpGain @@ -679,7 +732,7 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan } else { switch p.AttackStyle { case player.Accurate: - gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} + gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} case player.Aggressive: gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} case player.Defensive: @@ -687,7 +740,7 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan case player.Balanced: quarter := baseXP / 4 gains = []xpGain{ - {string(player.Attack), quarter}, + {string(player.Accuracy), quarter}, {string(player.Strength), quarter}, {string(player.Defense), quarter}, {string(player.Hitpoints), quarter}, @@ -722,6 +775,22 @@ func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string) } } +// executeWork is the non-violent alias of attack. It routes to the same engine; +// doAttack auto-detects whether the target is a task worksite or a combat mob. +func (g *Game) executeWork(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + p := sess.Player + target := g.resolveDefaultMob(p.RoomID) + if target == "" { + sess.WriteLine("Work on what?") + return + } + g.doAttack(sess, target) + } else { + g.doAttack(sess, strings.Join(args, " ")) + } +} + func (g *Game) respawnMob(instanceID string) { inst := g.MobStore.GetInstance(instanceID) if inst == nil { diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go index fa55d3d..223d803 100644 --- a/internal/game/cmd_consume.go +++ b/internal/game/cmd_consume.go @@ -258,7 +258,7 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d case "all_combat": duration := engine.ToTicks(def.PotionDuration) - buffs := []string{"attack", "strength", "defense"} + buffs := []string{"accuracy", "strength", "defense"} for _, stat := range buffs { p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{ Stat: stat, diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 98d48fd..266cd15 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -62,7 +62,7 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo if descWidth <= 0 { descWidth = 70 } - rawLines := wrapText(room.Description, descWidth) + rawLines := wrapText(g.roomDescription(sess, room), descWidth) mode := g.colorMode(sess) roomDescSpec := g.resolveColor(sess, "room_desc") lines := make([]string, len(rawLines)) @@ -72,6 +72,35 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo return lines } +// roomDescription resolves the room's effective description for this player, +// picking the first conditional variant whose condition passes, else the base +// Description. +func (g *Game) roomDescription(sess *net.Session, room *world.Room) string { + for _, d := range room.Descriptions { + if d.Condition == nil || g.checkCondition(sess, d.Condition) { + return d.Text + } + } + return room.Description +} + +// resolveObjDesc resolves the description an object shows to this player. For an +// object with conditional Descriptions, the first variant whose condition +// passes wins; if none pass the object is reported as not present (false), so +// look falls through as if it were not there. Objects without Descriptions use +// their plain Description and are always present. +func (g *Game) resolveObjDesc(sess *net.Session, def *object.ObjectDef) (string, bool) { + if len(def.Descriptions) == 0 { + return def.Description, true + } + for _, d := range def.Descriptions { + if d.Condition == nil || g.checkCondition(sess, d.Condition) { + return d.Text, true + } + } + return "", false +} + func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Room) []string { mobs := g.MobStore.MobsInRoom(p.RoomID) if len(mobs) == 0 { @@ -91,7 +120,15 @@ func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Roo for _, m := range mobs { hp := "" if m.HP < m.MaxHP { - hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP) + if m.IsTask() { + pct := 0 + if m.MaxHP > 0 { + pct = (m.MaxHP - m.HP) * 100 / m.MaxHP + } + hp = fmt.Sprintf(" [%d%% complete]", pct) + } else { + hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP) + } } var desc string if combat.IsMobInCombat(m.InstanceID) { @@ -467,6 +504,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { sess.WriteLine("You can't see anything that way.") return } + if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { + msg := exitDef.BlockedMessage + if msg == "" { + msg = fmt.Sprintf("The way %s is blocked.", exitDir) + } + sess.WriteLine(msg) + return + } g.World.SeedGroundItems(exitDef.Room) g.seedRoomMobs(exitDef.Room) origRoom := p.RoomID @@ -521,8 +566,43 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - instances := g.World.FindObjInstances(p.RoomID, lower) - if len(instances) > 0 { + rawInstances := g.World.FindObjInstances(p.RoomID, lower) + + // Group matched object instances by definition, keeping only those visible + // to this player (an object whose conditional descriptions all fail is + // absent). If more than one distinct object matches, disambiguate. + var presentDefs []string + byDef := map[string][]world.ObjState{} + descByDef := map[string]string{} + for _, ist := range rawInstances { + def, err := g.ObjectStore.Load(ist.DefID) + if err != nil { + continue + } + text, present := g.resolveObjDesc(sess, def) + if !present { + continue + } + if _, seen := byDef[ist.DefID]; !seen { + presentDefs = append(presentDefs, ist.DefID) + descByDef[ist.DefID] = text + } + byDef[ist.DefID] = append(byDef[ist.DefID], ist) + } + + if len(presentDefs) > 1 { + sess.WriteLine("That's ambiguous, which one?") + for _, defID := range presentDefs { + if def, err := g.ObjectStore.Load(defID); err == nil { + sess.WriteLine(fmt.Sprintf(" %s", def.Name)) + } + } + return + } + + if len(presentDefs) == 1 { + instances := byDef[presentDefs[0]] + objDescText := descByDef[presentDefs[0]] st := &instances[0] def, _ := g.ObjectStore.Load(st.DefID) @@ -538,8 +618,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { sess.WriteLine(def.Name) } - if def.Description != "" && !strings.Contains(def.Description, "{quality}") { - sess.WriteLine(fmt.Sprintf(" %s", def.Description)) + if objDescText != "" && !strings.Contains(objDescText, "{quality}") { + sess.WriteLine(fmt.Sprintf(" %s", objDescText)) } if def.Safespot != nil { diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 85abbdf..b7cab37 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -42,12 +42,34 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { targetID := exitDef.Room - _, err = g.World.LoadRoom(targetID) + targetRoom, err := g.World.LoadRoom(targetID) if err != nil { sess.WriteLine("That path seems blocked.") return } + // Warn before walking from a safe room into a dangerous (hazardous) one. + // Dangerous->dangerous never prompts (the current room is already hazardous). + if targetRoom.Hazard != "" && room.Hazard == "" && p.OptionBool("danger_warning") { + if p.PendingDangerDir == dir { + p.PendingDangerDir = "" + } else { + p.PendingDangerDir = dir + p.WalkSequence = nil + sess.State = net.StateDangerConfirm + name := targetRoom.Hazard + if hz, herr := g.World.LoadHazard(targetRoom.Hazard); herr == nil && hz.Name != "" { + name = hz.Name + } + sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf("WARNING: that area is exposed to %s.", name))) + sess.WriteLine("Enter anyway? [Y/n]") + return + } + } + + p.MovePendingFlags = exitDef.SetFlags + p.MovePendingPlayerFlags = exitDef.SetPlayerFlags + inCombat := combat.GetCombat(p.Name) != nil if !inCombat && p.Action != nil { @@ -108,6 +130,8 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int { func (g *Game) completeMove(sess *net.Session, p *player.Player) { exitDir := p.MoveDirection targetID := p.MoveTarget + pendingFlags := p.MovePendingFlags + pendingPlayerFlags := p.MovePendingPlayerFlags p.ClearMoveState() @@ -127,7 +151,16 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { oldRoom := p.RoomID p.RoomID = targetID + p.HazardTimer = 0 p.Stats.RecordRoomVisit(targetID) + + g.cancelEnterSeq(p.Name) + if p.CutsceneRoom != 0 && p.CutsceneRoom == oldRoom { + p.CutsceneRoom = 0 + } + if len(pendingFlags) > 0 || len(pendingPlayerFlags) > 0 { + g.applyFlagMutations(p, pendingFlags, pendingPlayerFlags) + } g.AccountStore.SaveCharacter(p) g.World.SeedGroundItems(p.RoomID) @@ -179,6 +212,35 @@ func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) { g.doMove(sess, strings.TrimSpace(rawInput), 1.0) } +// handleDangerConfirm processes the [Y/n] reply to the dangerous-area warning. +// Pressing return (empty), "y", or "yes" enters; anything else cancels. +func (g *Game) handleDangerConfirm(sess *net.Session, input string) { + p := sess.Player + if p == nil { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + dir := p.PendingDangerDir + choice := strings.TrimSpace(strings.ToLower(input)) + sess.State = net.StateGame + + if choice != "" && choice != "y" && choice != "yes" { + p.PendingDangerDir = "" + sess.WriteLine("You decide against it.") + g.writePrompt(sess) + return + } + + if dir == "" { + g.writePrompt(sess) + return + } + g.doMove(sess, dir, 1.0) + g.writePrompt(sess) +} + func (g *Game) seedRoomObjects(roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil { @@ -195,6 +257,9 @@ func (g *Game) seedRoomObjects(roomID int) { continue } g.World.SetObjName(roomID, obj.ID, def.Name) + if len(def.Aliases) > 0 { + g.World.SetObjAliases(roomID, obj.ID, def.Aliases) + } if len(obj.WanderRooms) > 0 { g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval) } diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index fe3d90a..be1c276 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -22,6 +22,7 @@ var commandRegistry = map[string]commandDef{ "drop": {(*Game).executeDrop, ClassActive}, "attack": {(*Game).executeAttack, ClassActive}, "kill": {(*Game).executeAttack, ClassActive}, + "work": {(*Game).executeWork, ClassActive}, "style": {(*Game).executeStyle, ClassInstant}, "look": {(*Game).executeLook, ClassInstant}, "l": {(*Game).executeLook, ClassInstant}, diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index 9f3c786..635c4b4 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -43,7 +43,7 @@ func (g *Game) doScore(sess *net.Session) { } skillDisplay := map[player.SkillName]string{ - player.Attack: "Attack", player.Strength: "Strength", player.Defense: "Defense", + player.Accuracy: "Accuracy", player.Strength: "Strength", player.Defense: "Defense", player.Hitpoints: "Hitpoints", player.Ranged: "Ranged", player.Science: "Science", player.Technology: "Technology", player.Assassin: "Assassin", player.Fishing: "Fishing", player.Woodcutting: "Woodcutting", player.Mining: "Mining", @@ -61,7 +61,7 @@ func (g *Game) doScore(sess *net.Session) { } categories := []skillCat{ {"Combat", []player.SkillName{ - player.Attack, player.Strength, player.Defense, player.Hitpoints, + player.Accuracy, player.Strength, player.Defense, player.Hitpoints, player.Ranged, player.Science, player.Technology, player.Assassin, }}, {"Gathering", []player.SkillName{ diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go index 9987a1d..2466a5f 100644 --- a/internal/game/cmd_stats.go +++ b/internal/game/cmd_stats.go @@ -67,7 +67,7 @@ func (g *Game) doStats(sess *net.Session) { equipAtt := combat.SelectBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) - attRoll = combat.EffectiveRoll(p.Level(player.Attack), attBonus, equipAtt) + attRoll = combat.EffectiveRoll(p.Level(player.Accuracy), attBonus, equipAtt) maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus) } diff --git a/internal/game/condition_test.go b/internal/game/condition_test.go new file mode 100644 index 0000000..5e5967d --- /dev/null +++ b/internal/game/condition_test.go @@ -0,0 +1,155 @@ +package game + +import ( + "testing" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func sessWithFlags(flags map[string]any) *net.Session { + return &net.Session{Player: &player.Player{Flags: flags}} +} + +func TestIsTruthy(t *testing.T) { + cases := []struct { + v any + want bool + }{ + {nil, false}, + {true, true}, + {false, false}, + {0, false}, + {1, true}, + {int64(0), false}, + {int64(2), true}, + {0.0, false}, + {1.5, true}, + {"", false}, + {"x", true}, + } + for _, c := range cases { + if got := isTruthy(c.v); got != c.want { + t.Errorf("isTruthy(%v) = %v, want %v", c.v, got, c.want) + } + } +} + +func TestCheckConditionPlayerFlagTruthy(t *testing.T) { + g := &Game{WorldFlags: map[string]any{}} + + set := sessWithFlags(map[string]any{"x": true}) + unset := sessWithFlags(map[string]any{}) + + // {player_flag: x} -> set means present+truthy + posCond := &action.Condition{PlayerFlag: "x"} + if !g.checkCondition(set, posCond) { + t.Error("expected positive flag check to pass when flag is true") + } + if g.checkCondition(unset, posCond) { + t.Error("expected positive flag check to fail when flag is unset") + } + + // {player_flag: x, not: true} -> negative + negCond := &action.Condition{PlayerFlag: "x", Not: true} + if g.checkCondition(set, negCond) { + t.Error("expected negative flag check to fail when flag is true") + } + if !g.checkCondition(unset, negCond) { + t.Error("expected negative flag check to pass when flag is unset") + } + + // flag explicitly set to false counts as not-set for truthy checks + falseSet := sessWithFlags(map[string]any{"x": false}) + if g.checkCondition(falseSet, posCond) { + t.Error("expected positive check to fail when flag is false") + } + if !g.checkCondition(falseSet, negCond) { + t.Error("expected negative check to pass when flag is false") + } +} + +func TestCheckConditionValueComparisonPreserved(t *testing.T) { + g := &Game{WorldFlags: map[string]any{}} + sess := sessWithFlags(map[string]any{"n": 1}) + + if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 1}) { + t.Error("value match should pass for equal int") + } + if g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2}) { + t.Error("value match should fail for unequal int") + } + if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2, Not: true}) { + t.Error("negated value match should pass for unequal int") + } +} + +func TestCheckConditionWorldFlag(t *testing.T) { + g := &Game{WorldFlags: map[string]any{"gate_open": true}} + sess := sessWithFlags(map[string]any{}) + + if !g.checkCondition(sess, &action.Condition{Flag: "gate_open"}) { + t.Error("world flag truthy check should pass") + } + if g.checkCondition(sess, &action.Condition{Flag: "gate_open", Not: true}) { + t.Error("negated world flag should fail when set") + } + if g.checkCondition(sess, &action.Condition{Flag: "missing"}) { + t.Error("missing world flag should not pass") + } +} + +func TestResolveObjDescPresence(t *testing.T) { + g := &Game{WorldFlags: map[string]any{}} + + def := &object.ObjectDef{ + Descriptions: []object.ConditionalDesc{ + {Text: "lined", Condition: &action.Condition{PlayerFlag: "lined_up"}}, + {Text: "milling", Condition: &action.Condition{PlayerFlag: "lined_up", Not: true}}, + }, + } + + // not lined up -> milling, present + if text, present := g.resolveObjDesc(sessWithFlags(map[string]any{}), def); !present || text != "milling" { + t.Errorf("expected milling/present, got %q/%v", text, present) + } + // lined up -> lined, present + if text, present := g.resolveObjDesc(sessWithFlags(map[string]any{"lined_up": true}), def); !present || text != "lined" { + t.Errorf("expected lined/present, got %q/%v", text, present) + } + + // gated entirely off -> absent + gated := &object.ObjectDef{ + Descriptions: []object.ConditionalDesc{ + {Text: "x", Condition: &action.Condition{PlayerFlag: "done", Not: true}}, + }, + } + if _, present := g.resolveObjDesc(sessWithFlags(map[string]any{"done": true}), gated); present { + t.Error("expected object to be absent when no variant matches") + } + + // plain object (no Descriptions) always present + plain := &object.ObjectDef{Description: "plain"} + if text, present := g.resolveObjDesc(sessWithFlags(nil), plain); !present || text != "plain" { + t.Errorf("expected plain/present, got %q/%v", text, present) + } +} + +func TestRoomDescriptionSelection(t *testing.T) { + g := &Game{WorldFlags: map[string]any{}} + room := &world.Room{ + Description: "empty pad", + Descriptions: []world.RoomDesc{ + {Text: "crowd", Condition: &action.Condition{PlayerFlag: "done", Not: true}}, + }, + } + if got := g.roomDescription(sessWithFlags(map[string]any{}), room); got != "crowd" { + t.Errorf("first visit: want crowd, got %q", got) + } + if got := g.roomDescription(sessWithFlags(map[string]any{"done": true}), room); got != "empty pad" { + t.Errorf("revisit: want empty pad, got %q", got) + } +} diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index f0d6863..dec6dae 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -41,8 +41,8 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { } p := player.New(name) - p.RoomID = 1 - p.Stats.RecordRoomVisit(1) + p.RoomID = 1001 + p.Stats.RecordRoomVisit(1001) if err := g.AccountStore.SaveCharacter(p); err != nil { sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) @@ -101,6 +101,23 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { g.doLook(sess) g.checkAggro(sess) + + // Resume an interrupted on_enter cutscene (e.g. disconnected mid-sequence). + // Only fires when a persisted marker matches the room the player is in, so + // ordinary on_enter scripts never run on login. + if p.CutsceneRoom != 0 { + if p.CutsceneRoom == p.RoomID { + g.runEnterSteps(sess, p.RoomID) + } + g.enterMu.Lock() + _, active := g.enterSeqs[p.Name] + g.enterMu.Unlock() + if !active { + p.CutsceneRoom = 0 + g.AccountStore.SaveCharacter(p) + } + } + sess.Write("\r\n> ") } diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go index 4bdb50d..323e519 100644 --- a/internal/game/core_startup.go +++ b/internal/game/core_startup.go @@ -31,6 +31,7 @@ func (g *Game) ValidateStartup() []StartupIssue { issues = append(issues, validateRooms(g)...) issues = append(issues, validateMobs(g)...) + issues = append(issues, validateHazards(g)...) issues = append(issues, validateObjects(g)...) issues = append(issues, validateItems(g)...) issues = append(issues, validateCraftBlocks(g)...) @@ -66,6 +67,7 @@ func validateRooms(g *Game) []StartupIssue { itemIDs := g.ItemStore.IDSet() mobIDs := g.MobStore.AllDefIDs() objIDs := g.ObjectStore.IDSet() + hazardIDs := g.World.HazardIndex() for id := range roomIndex { room, err := g.World.LoadRoom(id) @@ -164,6 +166,15 @@ func validateRooms(g *Game) []StartupIssue { } } } + + if room.Hazard != "" && !hazardIDs[room.Hazard] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: references nonexistent hazard %q", + id, room.Hazard), + }) + } } return issues @@ -193,6 +204,14 @@ func validateMobs(g *Game) []StartupIssue { }) } + if def.Kind != "" && def.Kind != "combat" && def.Kind != "task" { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: invalid kind %q (must be \"combat\" or \"task\")", id, def.Kind), + }) + } + if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] { issues = append(issues, StartupIssue{ Level: "ERROR", @@ -243,6 +262,40 @@ func validateMobs(g *Game) []StartupIssue { return issues } +func validateHazards(g *Game) []StartupIssue { + var issues []StartupIssue + itemIDs := g.ItemStore.IDSet() + validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true} + + for id := range g.World.HazardIndex() { + def, err := g.World.LoadHazard(id) + if err != nil { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Hazard %q: failed to load: %v", id, err), + }) + continue + } + if def.AttackType != "" && !validTypes[def.AttackType] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, def.AttackType), + }) + } + if def.RequiredItem != "" && !itemIDs[def.RequiredItem] { + issues = append(issues, StartupIssue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Hazard %q: required_item %q does not exist", id, def.RequiredItem), + }) + } + } + + return issues +} + func validateObjects(g *Game) []StartupIssue { var issues []StartupIssue objIDs := g.ObjectStore.IDSet() diff --git a/internal/game/game.go b/internal/game/game.go index a1b7bbe..b9b54ba 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -59,6 +59,8 @@ type Game struct { hackingStates map[string]*hacking.Session safespotMu sync.Mutex safespotStates map[string]*SafespotState + enterMu sync.Mutex + enterSeqs map[string]*enterSeq } type SafespotState struct { @@ -92,6 +94,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali guardWatchTimers: make(map[string]int), hackingStates: make(map[string]*hacking.Session), safespotStates: make(map[string]*SafespotState), + enterSeqs: make(map[string]*enterSeq), } g.CourseStore.LoadAll() g.LoadMods() @@ -105,6 +108,7 @@ func (g *Game) SetHub(hub *net.Hub) { hub.OnRemove(func(sess *net.Session) { if p := sess.Player; p != nil { p.DeactivateAllTechs() + g.cancelEnterSeq(p.Name) g.charsMu.Lock() delete(g.loggedInChars, p.Name) g.charsMu.Unlock() @@ -167,6 +171,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleColorChoice(sess, input) case net.StateHacking: g.handleHackingInput(sess, input) + case net.StateDangerConfirm: + g.handleDangerConfirm(sess, input) } } diff --git a/internal/game/map_test.go b/internal/game/map_test.go index b7d9f01..dded62e 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -21,32 +21,32 @@ func TestBuildTinyMap(t *testing.T) { want []string // expected lines, or nil to just check count }{ { - name: "room 1 has east exit", - roomID: 1, + name: "room 999991 has east exit", + roomID: 999991, }, { - name: "room 2 has west/north/east", - roomID: 2, + name: "room 999992 has west/north/east", + roomID: 999992, }, { - name: "room 4 has west/north/east", - roomID: 4, + name: "room 999994 has west/north/east", + roomID: 999994, }, { - name: "room 21 has west only", - roomID: 21, + name: "room 9999921 has west only", + roomID: 9999921, }, { - name: "room 22 west of town square", - roomID: 22, + name: "room 9999922 west of town square", + roomID: 9999922, }, { - name: "room 25 east of south of west", - roomID: 25, + name: "room 9999925 east of south of west", + roomID: 9999925, }, { - name: "room 8 hacking lab", - roomID: 8, + name: "room 999998 hacking lab", + roomID: 999998, }, } @@ -108,10 +108,10 @@ func TestBuildFullMap(t *testing.T) { width int height int }{ - {"room 1 30x20", 1, 30, 20}, - {"room 1 20x10", 1, 20, 10}, - {"room 25 30x20", 25, 30, 20}, - {"min size 5x5", 1, 5, 5}, + {"room 999991 30x20", 999991, 30, 20}, + {"room 999991 20x10", 999991, 20, 10}, + {"room 9999925 30x20", 9999925, 30, 20}, + {"min size 5x5", 999991, 5, 5}, } for _, tt := range tests { diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go new file mode 100644 index 0000000..29bf65e --- /dev/null +++ b/internal/game/sys_hazard.go @@ -0,0 +1,136 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +// HazardTick rolls environmental attacks against every player standing in a +// hazardous room. It reuses the combat formulas exactly: the hazard "attacks" +// and the player defends with their Defense level + equipment bonus selected by +// the hazard's attack type. It runs independently of combat, so a player can be +// hit by BOTH a mob and the room hazard in the same area. It deliberately never +// touches movement state — a hazard hit does NOT interrupt walking. +func (g *Game) HazardTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p := sess.Player + if p == nil { + continue + } + + room, err := g.World.LoadRoom(p.RoomID) + if err != nil || room.Hazard == "" { + p.HazardTimer = 0 + continue + } + hz, err := g.World.LoadHazard(room.Hazard) + if err != nil || hz == nil { + p.HazardTimer = 0 + continue + } + + // A safespot shields the player from all hazard damage while hidden. + if g.safespotBlocksHazard(p) { + continue + } + + // The right protective gear negates the hazard entirely. + if hz.RequiredItem != "" && g.hasEquipped(p, hz.RequiredItem) { + continue + } + + speed := hz.Speed + if speed <= 0 { + speed = 5 + } + p.HazardTimer++ + if float64(p.HazardTimer) >= speed { + p.HazardTimer = 0 + g.rollHazard(sess, p, hz) + } + } +} + +func (g *Game) hasEquipped(p *player.Player, itemID string) bool { + for _, id := range p.Equipment { + if id == itemID { + return true + } + } + return false +} + +func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardDef) { + attackType := hz.AttackType + if attackType == "" { + attackType = "crush" + } + + attRoll := combat.EffectiveRoll(hz.Attack, 0, hz.AttackBonus) + + _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) + totals := g.playerEquipBonuses(p) + equipDef := combat.SelectBonus(attackType, + totals.StabDefense, totals.SlashDefense, totals.CrushDefense, + totals.ScienceDefense, totals.RangedDefense) + defRoll := combat.EffectiveRoll( + p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), + defStyleBonus, equipDef) + + if combat.HitCheck(attRoll, defRoll) { + g.applyHazardHit(sess, p, hz) + } else { + miss := hz.MissMessage + if miss == "" { + miss = fmt.Sprintf("You weather the %s.", hz.Name) + } + sess.WriteLine("\n" + g.colorize(sess, "miss", miss)) + g.writePrompt(sess) + } +} + +func (g *Game) applyHazardHit(sess *net.Session, p *player.Player, hz *world.HazardDef) { + maxHit := hz.MaxHit + if maxHit < 1 { + maxHit = 1 + } + dmg := combat.RollDamage(maxHit) + dmg = g.damageAfterTechProtection(p, hz.AttackType, dmg) + if dmg < 0 { + dmg = 0 + } + + p.HP -= dmg + if p.HP < 0 { + p.HP = 0 + } + p.StartRegen() + g.AccountStore.SaveCharacter(p) + + msg := hz.HitMessage + switch { + case msg == "": + msg = fmt.Sprintf("The %s hits you for %d damage.", hz.Name, dmg) + case strings.Contains(msg, "%d"): + msg = fmt.Sprintf(msg, dmg) + } + hpSuffix := fmt.Sprintf(" %s [%d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP()) + sess.WriteLine("\n" + g.colorize(sess, "damage_taken", msg) + hpSuffix) + + // NB: a hazard never clears MoveState, so it cannot interrupt a walk the way + // a mob hit ("Can't escape!") does. + + if p.HP <= 0 { + g.killPlayer(sess, p, nil) + return + } + g.writePrompt(sess) +} diff --git a/internal/game/sys_labor.go b/internal/game/sys_labor.go new file mode 100644 index 0000000..102c54e --- /dev/null +++ b/internal/game/sys_labor.go @@ -0,0 +1,81 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +// taskWorkVerb returns the flavor verb for working on a task worksite. Designers +// set `verb:` on the mob (e.g. "survey", "prospect", "build"); the default is +// "work on". The player-facing command is always `work` (or `attack`). +func taskWorkVerb(mob *world.MobInstance) string { + if mob != nil && mob.Verb != "" { + return mob.Verb + } + return "work on" +} + +// progressBar renders a bar that fills as progress rises toward max — the +// inverse of hpBar. It is used for task worksites whose internal HP drains to 0 +// to complete the work (progress = MaxHP - HP). +func (g *Game) progressBar(sess *net.Session, progress, max int) string { + if max <= 0 { + max = 1 + } + if progress < 0 { + progress = 0 + } + if progress > max { + progress = max + } + + pct := float64(progress) / float64(max) * 100.0 + var fgColor int + switch { + case pct >= 70: + fgColor = 82 + case pct >= 40: + fgColor = 220 + default: + fgColor = 39 + } + + unicode := sess.Player != nil && sess.Player.OptionBool("unicode") + style := &BarStyle{FilledColor: fgColor, EmptyColor: 238, EmptyDim: true} + bar := RenderColoredBar(progress, max, 10, unicode, style, g.colorMode(sess)) + return "[" + bar + "]" +} + +// writeTaskProgress prints the per-tick progress line for a task worksite, +// mirroring the combat hit line but framed as labor instead of damage. +func (g *Game) writeTaskProgress(sess *net.Session, p *player.Player, mob *world.MobInstance, dmg int, gains []xpGain) { + progress := mob.MaxHP - mob.HP + if progress < 0 { + progress = 0 + } + pct := 0 + if mob.MaxHP > 0 { + pct = progress * 100 / mob.MaxHP + } + + noun := mob.ProgressNoun + if noun == "" { + noun = "work" + } + + prefix := fmt.Sprintf("You advance the %s on %s.", noun, g.colorize(sess, "mob", mobDisplayName(mob, true))) + line := fmt.Sprintf("%s %s %3d%%", prefix, g.progressBar(sess, progress, mob.MaxHP), pct) + + if p.OptionBool("xp_drops") && len(gains) > 0 { + var parts []string + for _, gain := range gains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) + } + line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") + } + sess.WriteLine(line) +} diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go index da999c0..44a8f9f 100644 --- a/internal/game/sys_safespot.go +++ b/internal/game/sys_safespot.go @@ -46,6 +46,13 @@ func blocksMob(maxBlockSize, mobSize string) bool { return mob <= max } +// safespotBlocksHazard reports whether the player's active safespot shields them +// from room hazard damage. Unlike mobs, hazard type does not matter: an active +// safespot blocks ALL hazard damage, no safespot blocks none. +func (g *Game) safespotBlocksHazard(p *player.Player) bool { + return g.isSafespotted(p.Name) +} + func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool { g.safespotMu.Lock() ss, ok := g.safespotStates[p.Name] diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go index bb48375..e673dcb 100644 --- a/internal/game/sys_technology.go +++ b/internal/game/sys_technology.go @@ -10,7 +10,7 @@ import ( ) type TechEffects struct { - AttackPercent int + AccuracyPercent int StrengthPercent int DefensePercent int RangedPercent int @@ -39,9 +39,9 @@ var techByID map[string]*TechDef func init() { AllTechs = []TechDef{ - {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 5}}, - {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 10}}, - {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15}}, + {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 5}}, + {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 10}}, + {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 15}}, {ID: "amplifier_1", Name: "Power Amplifier", Level: 7, DrainRate: 0.05, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 5}}, {ID: "amplifier_2", Name: "Enhanced Amplifier", Level: 23, DrainRate: 0.10, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 10}}, @@ -68,7 +68,7 @@ func init() { {ID: "preserve", Name: "Power Saver", Level: 55, DrainRate: 0.05, Category: "utility", Group: "", Effects: TechEffects{PreserveDrain: 0.2}}, {ID: "retribution", Name: "Dead Man's Switch", Level: 46, DrainRate: 0.10, Category: "utility", Group: "", Effects: TechEffects{RetributionPct: 0.25}}, - {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15, StrengthPercent: 15}}, + {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 15, StrengthPercent: 15}}, {ID: "fortify", Name: "Fortify", Level: 65, DrainRate: 0.25, Category: "combo", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15, HPRegenMulti: 2.0}}, } @@ -104,8 +104,8 @@ func TechByPrefixMatch(input string) []*TechDef { func techEffectString(tech TechDef) string { var parts []string e := tech.Effects - if e.AttackPercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Attack", e.AttackPercent)) + if e.AccuracyPercent > 0 { + parts = append(parts, fmt.Sprintf("+%d%% Accuracy", e.AccuracyPercent)) } if e.StrengthPercent > 0 { parts = append(parts, fmt.Sprintf("+%d%% Strength", e.StrengthPercent)) @@ -163,8 +163,8 @@ func (g *Game) techLevelBonus(p *player.Player, stat string) int { continue } switch stat { - case "attack": - totalPercent += def.Effects.AttackPercent + case "accuracy": + totalPercent += def.Effects.AccuracyPercent case "strength": totalPercent += def.Effects.StrengthPercent case "defense": @@ -182,8 +182,8 @@ func (g *Game) techLevelBonus(p *player.Player, stat string) int { var baseLevel int switch stat { - case "attack": - baseLevel = p.Level(player.Attack) + case "accuracy": + baseLevel = p.Level(player.Accuracy) case "strength": baseLevel = p.Level(player.Strength) case "defense": @@ -217,11 +217,17 @@ func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bo } func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int { + return g.damageAfterTechProtection(p, mob.AttackType, dmg) +} + +// damageAfterTechProtection reduces incoming damage if the player has the +// protection tech matching the given attack type active. Shared by mob combat +// and room hazards. +func (g *Game) damageAfterTechProtection(p *player.Player, attackType string, dmg int) int { if len(p.ActiveTechs) == 0 { return dmg } - attackType := mob.AttackType if attackType == "" { attackType = "crush" } |
