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 } if p.GodMode { 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 || hz.Combat == 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.SpeedTicks() 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() _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) totals := g.playerEquipBonuses(p) var attRoll int var maxHit int switch attackType { case combat.AttackRanged: attRoll = combat.AttackRoll(combat.NPCEffective(hz.Combat.Stats.Ranged), hz.Combat.Stats.Bonuses.RangedBonus) maxHit = hz.Combat.Stats.MaxRangedHit case combat.AttackScience: attRoll = combat.AttackRoll(combat.ScienceEffective(hz.Combat.Stats.Science), hz.Combat.Stats.Bonuses.ScienceBonus) maxHit = hz.EffectiveMaxScienceHit() default: attRoll = combat.AttackRoll(combat.NPCEffective(hz.Combat.Stats.Attack), hz.Combat.Stats.Bonuses.AttackBonus) maxHit = hz.Combat.Stats.MaxMeleeHit } equipDef := combat.SelectBonus(attackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) defRoll := combat.AttackRoll(combat.PlayerEffective( p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus), equipDef) if combat.HitCheck(attRoll, defRoll) { g.applyHazardHit(sess, p, hz, attackType, maxHit) } else { miss := hz.MissMessage if miss == "" { miss = fmt.Sprintf("You weather the %s.", hz.Name) } sess.WriteLine(g.colorize(sess, "miss", miss)) g.writePrompt(sess) } } func (g *Game) applyHazardHit(sess *net.Session, p *player.Player, hz *world.HazardDef, attackType string, maxHit int) { if maxHit < 1 { maxHit = 1 } dmg := combat.RollDamage(maxHit) dmg = g.damageAfterTechProtection(p, 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(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) }