From 9d4b799db4868bcab291b83599ff088763cd4ed6 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 24 Jun 2026 20:58:25 -0400 Subject: feat: new type of non-violent 'combat': work --- internal/game/cmd_attack.go | 275 +++++++++++++++++++++++++++----------------- 1 file changed, 172 insertions(+), 103 deletions(-) (limited to 'internal/game/cmd_attack.go') 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 { -- cgit v1.2.3